我们先来说一下大家对协程的了解,就是比线程还轻量级的
那思考的点有如下:
1、线程和进程的区别是什么
2、为什么协程就比线程轻量级(创建销毁切换成本更低)
3、python和go对于协程的实现
在解释上面这些问题之前,要先了解一下基本的概念
学习 Linux 时,经常可以看到两个词:User space(用户空间)和 Kernel space(内核空间)。
简单说,Kernel space 是 Linux 内核的运行空间,User space 是用户程序的运行空间。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。
用户空间和内核空间是操作系统的两个重要概念。
用户空间是一个独立的内存空间,其中存储着用户进程(包括应用程序)运行时使用的数据和代码。用户空间是安全的,因为进程之间相互独立,不能直接访问对方的内存空间。
内核空间是操作系统内核代码和数据结构所使用的内存空间。内核空间是不安全的,因为内核代码运行时有最高的权限,可以访问系统的所有资源,并且可以直接控制其他所有进程。
用户空间和内核空间之间的分界线是很重要的,因为它保护了操作系统的安全和稳定。例如,如果一个用户进程的代码出现问题,它可能会崩溃,但是它不会影响整个系统的稳定性。
用户空间按照访问属性一致的地址空间存放在一起的原则,划分成 5个不同的内存区域。 访问属性指的是“可读、可写、可执行等 。
代码段
代码段是用来存放可执行文件的操作指令,可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,它是不可写的。
数据段
数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。
BSS段
BSS段包含了程序中未初始化的全局变量,在内存中 bss 段全部置零。
堆 heap
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
栈 stack
栈是用户存放程序临时创建的局部变量,也就是函数中定义的变量(但不包括 static 声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
上述几种内存区域中数据段、BSS 段、堆通常是被连续存储在内存中,在位置上是连续的,而代码段和栈往往会被独立存放。堆和栈两个区域在 i386 体系结构中栈向下扩展、堆向上扩展,相对而生。
在 x86 32 位系统里,Linux 内核地址空间是指虚拟地址从 0xC0000000 开始到 0xFFFFFFFF 为止的高端内存地址空间,总计 1G 的容量, 包括了内核镜像、物理页面表、驱动程序等运行在内核空间 。
调用者保存寄存器(caller saved registers)
也叫易失性寄存器,在程序调用的过程中,这些寄存器中的值不需要被保存(即压入到栈中再从栈中取出),如果某一个程序需要保存这个寄存器的值,需要调用者自己压入栈;
被调用者保存寄存器(callee saved registers)
也叫非易失性寄存器,在程序调用过程中,这些寄存器中的值需要被保存,不能被覆盖;当某个程序调用这些寄存器,被调用寄存器会先保存这些值然后再进行调用,且在调用结束后恢复被调用之前的值;
什么是上下文切换?上下文切换的时机?
CPU通过分配时间片来执行任务,当一个任务的时间片用完,就会切换到另一个任务。在切换之前会保存上一个任务的状态,当下次再切换到该任务,就会加载这个状态。
——任务从保存到再加载的过程就是一次上下文切换。
按导致上下文切换的因素划分,可将上下文切换分为两点:
自发性上下文切换
非自发性上下文切换
自发性上下文切换指线程由于自身因素导致的切出。
非自发性上下文切换指线程由于线程调度器的原因被迫切出。如:
切出线程的时间片用完
有一个比切出线程优先级更高的线程需要被运行
虚拟机的垃圾回收动作
上下文切换的开销包括直接开销和间接开销。
直接开销有如下几点:
操作系统保存回复上下文所需的开销
线程调度器调度线程的开销
间接开销有如下几点:
处理器高速缓存重新加载的开销
上下文切换可能导致整个一级高速缓存中的内容被冲刷,即被写入到下一级高速缓存或主存
协程的调度完全由用户控制,协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作用户空间栈,完全没有内核切换的开销。
协程切换非常简单,就是把当前协程的CPU寄存器状态保存起来,然后将需要切换进来的协程的CPU寄存器状态加载的CPU寄存器上就可以了。
而且完全在用户态进行,一般来说一次协程上下文切换最多就是几十ns这个量级
因为线程的调度是在内核态运行的,而线程中的代码是在用户态运行。
这个问题比较笼统,通过上面的一些概念的描述,我们已经得出了
为什么协程就比线程轻量级(创建销毁切换成本更低)
的结论
但是还可以再补充一下
协程是用户主动,协程的中断,去执行其他协程,不是函数调用,有点类似CPU的中断,主动中断,重点在于主动
这一点我们可以从python中对于协程的使用
async、yiled的关键词来体验,yiled就说明我把权限交出去了
go语言中文文档
有兴趣的可以看一下上面这篇文章,本模块内容全来自于此,只是做了一些精简
这样,我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.
看到这里,我们就要开脑洞了,既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。
N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上
缺点:
某个程序用不了硬件的多核加速能力
一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,
缺点:
协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。
Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。
Goroutine 特点:
占用内存更小(几 kb)
调度更灵活 (runtime 调度)
好了,既然我们知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器的实现了。
Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?
下面我们来看看被废弃的 golang 调度器是如何实现的?
M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。
老调度器有几个缺点:
创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
(1) GMP 模型
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。
1、全局队列(Global Queue):存放等待运行的 G。
2、P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
3、P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
4、M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
但是相对于python来说,貌似看到的所有文档都是一个线程对应多个协程,而不是多个线程对应多个线程
Python对协程的支持是通过generator实现的。
在generator中,我们不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。
但是Python的yield不但可以返回一个值,它还可以接收调用者发出的参数。
来看例子:
传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。
如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:
def consumer():
r = ''
while True:
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'
def produce(c):
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()
c = consumer()
produce(c)
执行结果
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK
注意到consumer函数是一个generator,把一个consumer传入produce后:
首先调用c.send(None)启动生成器;
然后,一旦生产了东西,通过c.send(n)切换到consumer执行;
consumer通过yield拿到消息,处理,又通过yield把结果传回;
produce拿到consumer处理的结果,继续生产下一条消息;
produce决定不生产了,通过c.close()关闭consumer,整个过程结束。
整个流程无锁,由一个线程执行,produce和consumer协作完成任务,所以称为“协程”,而非线程的抢占式多任务。