①golang的协程是基于gpm机制,是可以多核多线程的.Python的协程是eventloop模型(IO多路复用技术)实现,协程是严格的 1:N 关系,也就是一个线程对应了多个协程.虽然可以实现异步I/O,但是不能有效利用多核(GIL).
评论
为什么需要context
在go服务器中,对于每个请求的request都是在单独的goroutine中进行的,处理一个request也可能设计多个goroutine之间的交互, 使用context可以使开发者方便的在这些goroutine里传递request相关的数据、取消goroutine的signal或截止日期
在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作.熟悉channel的朋友应该都见过使用done channel来处理此类问题.比如以下这个例子:
上述例子中定义了一个buffer为0的channel done, 子协程运行着定时任务.如果主协程需要在某个时刻发送消息通知子协程中断任务退出,那么就可以让子协程监听这个done channel,一旦主协程关闭done channel,那么子协程就可以推出了,这样就实现了主协程通知子协程的需求.这很好,但是这也是有限的.
如果我们可以在简单的通知上附加传递额外的信息来控制取消:为什么取消,或者有一个它必须要完成的最终期限,更或者有多个取消选项,我们需要根据额外的信息来判断选择执行哪个取消选项.
如果还是使用done channel的用法,我们需要定义两个done channel,子任务们需要同时监听这两个done channel.嗯,这样其实好像也还行哈.但是如果层级更深,如果这些子任务还有子任务,那么使用done channel的方式将会变得非常繁琐且混乱.
我们需要一种优雅的方案来实现这样一种机制:
上层任务取消后,所有的下层任务都会被取消;中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务以及同级任务.
这个时候context就派上用场了.我们首先看看context的结构设计和实现原理.
context接口
先看Context接口结构,看起来非常简单.
}
Context接口包含四个方法:
Deadline返回绑定当前context的任务被取消的截止时间;如果没有设定期限,将返回ok == false.
Done 当绑定当前context的任务被取消时,将返回一个关闭的channel;如果当前context不会被取消,将返回nil.
Err 如果Done返回的channel没有关闭,将返回nil;如果Done返回的channel已经关闭,将返回非空的值表示任务结束的原因.如果是context被取消,Err将返回Canceled;如果是context超时,Err将返回DeadlineExceeded.
Value 返回context存储的键值对中当前key对应的值,如果没有对应的key,则返回nil.
可以看到Done方法返回的channel正是用来传递结束信号以抢占并中断当前任务;Deadline方法指示一段时间后当前goroutine是否会被取消;以及一个Err方法,来解释goroutine被取消的原因;而Value则用于获取特定于当前任务树的额外信息.而context所包含的额外信息键值对是如何存储的呢?其实可以想象一颗树,树的每个节点可能携带一组键值对,如果当前节点上无法找到key所对应的值,就会向上去父节点里找,直到根节点.
emptyCtx
emptyCtx是一个int类型的变量,但实现了context的接口.emptyCtx没有超时时间,不能取消,也不能存储任何额外信息,所以emptyCtx用来作为context树的根节点.
Background和TODO只是用于不同场景下: Background通常被用于主函数、初始化以及测试中,作为一个顶层的context,也就是说一般我们创建的context都是基于Background;而TODO是在不确定使用什么context的时候才会使用.
用法 :
import asyncio
import threading
async def compute(x, y):
? print("Compute %s + %s ..." % (x, y))
? print(threading.current_thread().name)
? await asyncio.sleep(x + y)
? return x + y
async def print_sum(x, y):
? result = await compute(x, y)
? print("%s + %s = %s" % (x, y, result))
if __name__ == "__main__":
? loop = asyncio.get_event_loop()
? loop.run_until_complete(asyncio.wait(tasks))
? loop.close()
线程是内核进行抢占式的调度的,这样就确保了每个线程都有执行的机会.而 coroutine 运行在同一个线程中,由语言的运行时中的 EventLoop(事件循环) 来进行调度.和大多数语言一样,在 Python 中,协程的调度是非抢占式的,也就是说一个协程必须主动让出执行机会,其他协程才有机会运行.
让出执行的关键字就是 await.也就是说一个协程如果阻塞了,持续不让出 CPU,那么整个线程就卡住了,没有任何并发.
Go语言的协程是 语言本身特性 ,erlang和golang都是采用了CSP(Communicating Sequential Processes)模式(Python中的协程是eventloop模型),但是erlang是基于进程的消息通信,go是基于goroutine和channel的通信.
Python和Go都引入了消息调度系统模型,来避免锁的影响和进程/线程开销大的问题.
协程从本质上来说是一种用户态的线程,不需要系统来执行抢占式调度,而是在语言层面实现线程的调度 .因为协程 不再使用共享内存/数据 ,而是使用 通信 来共享内存/锁,因为在一个超级大系统里具有无数的锁,共享变量等等会使得整个系统变得无比的臃肿,而通过消息机制来交流,可以使得每个并发的单元都成为一个独立的个体,拥有自己的变量,单元之间变量并不共享,对于单元的输入输出只有消息.开发者只需要关心在一个并发单元的输入与输出的影响,而不需要再考虑类似于修改共享内存/数据对其它程序的影响.
线程:
多线程是为了解决CPU利用率的问题,线程则是为了减少上下文切换时的开销,进程和线程在Linux中没有本质区别,最大的不同就是进程有自己独立的内存空间,而线程是共享内存空间.
在进程切换时需要转换内存地址空间,而线程切换没有这个动作,所以线程切换比进程切换代价要小得多.
协程:
想要简单,又要性能高,协程就可以达到我们的目的,它是用户视角的一种抽象,操作系统并没有这个概念,主要思想是在用户态实现调度算法,用少量线程完成大量任务的调度.
Goroutine是GO语言实现的协程,其特点是在语言层面就支持,使用起来十分方便,它的核心是MPG调度模型:M即内核线程;P即处理器,用来执行Goroutine,它维护了本地可运行队列;G即Goroutine,代码和数据结构;S及调度器,维护M和P的信息.
Goroutine调度是一个很复杂的机制,下面尝试用简单的语言描述一下Goroutine调度机制,想要对其有更深入的了解可以去研读一下源码.
首先介绍一下GMP什么意思:
G ----------- goroutine: 即Go协程,每个go关键字都会创建一个协程.
M ---------- thread内核级线程,所有的G都要放在M上才能运行.
P ----------- processor处理器,调度G到M上,其维护了一个队列,存储了所有需要它来调度的G.
Goroutine 调度器P和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行
模型图:
避免频繁的创建、销毁线程,而是对线程的复用.
①.)work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程.
如果有空闲的P,则获取一个P,继续执行G0.
如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度.然后M0将进入缓存池睡眠.
如下图
GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行
在Go中一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死.
具体可以去看另一篇文章
【Golang详解】go语言调度机制 抢占式调度
当创建一个新的G之后优先加入本地队列,如果本地队列满了,会将本地队列的G移动到全局队列里面,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G.
协程经历过程
我们创建一个协程 go func()经历过程如下图:
说明:
G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系.M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;
一个M调度G执行的过程是一个循环机制;会一直从本地队列或全局队列中获取G
上面说到P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用.类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个.
work-stealing调度算法:当M执行完了当前P的本地队列队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从全局队列队列寻找G来执行,如果全局队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行.
如果一切正常,调度器会以上述的那种方式顺畅地运行,但这个世界没这么美好,总有意外发生,以下分析goroutine在两种例外情况下的行为.
Go runtime会在下面的goroutine被阻塞的情况下运行另外一个goroutine:
用户态阻塞/唤醒
系统调用阻塞
当M执行某一个G时候如果发生了阻塞操作,M会阻塞,如果当前有一些G在执行,调度器会把这个线程M从P中摘除,然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P.当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列.如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中.
队列轮转
可见每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度.
M0
M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量rutime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G,在之后M0就和其他的M一样了
G0
G0是每次启动一个M都会第一个创建的goroutine,G0仅用于负责调度G,G0不指向任何可执行的函数,每个M都会有一个自己的G0,在调度或系统调用时会使用G0的栈空间,全局变量的G0是M0的G0
一个G由于调度被中断,此后如何恢复?
中断的时候将寄存器里的栈信息,保存到自己的G对象里面.当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行了.
我这里只是根据自己的理解进行了简单的介绍,想要详细了解有关GMP的底层原理可以去看Go调度器 G-P-M 模型的设计者的文档或直接看源码
参考: ()
()