Go的协程调度

Golang由于其轻量级线程goroutine的特性得到大家的关注,轻量级线程的出现也使得各种回调加上下文恢复的工作转移到了内置的runtime调度器中,不用程序员自己处理了,极大的降低了程序员的负担,终于可以用同步的思想去写业务逻辑了。

在c++中,网络处理基本都基于iocp(windows)或者epoll(linux),当然两者一个是proactor,一个是reactor,有所区别,这里不做讨论,但是程序员始终要做的就是处理完某个逻辑,等待数据包读完整,然后再次根据任务的上下文继续做接下来的逻辑。然而在Golang中,你只要把业务逻辑写成一个整体的流程,然后顺序写下来就行,所以在心智负担上,go会减轻很多。

这里简单的记录下golang runtime中的调度器,这个和操作系统调度进程、线程有点儿类似,在这里我们可以简单的探究下golang内部的调度大概是怎样做的。

golang的调度概念中,主要有以下三个角色:

  • M

    在这里可以简单的看做是操作系统的线程,代码的执行者。

  • P

    在google的实现文档中,它的名字为processor,在调度中,它主要起到实现M:N调度的功能。P主要是记录一些上下文信息,还有本地的G列表。在最早版本的go设计中,只有全局的G列表,用一个全局锁来避免读写冲突,每一个M要获取任务必须得访问该全局列表,造成了性能损耗。新版本中由于P的出现,每个P都有本地的G队列,M获取G任务是无锁的,这个和tcmalloc中的内存分配策略也比较像,一个线程会绑定属于自己线程的cache来做无锁的分配。

  • G

    G代表了一个goroutine的上下文,包含routine的堆栈、代码指针等信息,用于在切换后可以恢复执行现场的功能。

不少文章其实这儿写的不够明白,其实这三个概念比较清晰,线程M始终是执行者,用来执行G,但是不是1:1的关系,M在执行G的时候,必须要获取P,然后再来执行P中的runable G list中的G任务。

在go程序初始化后,有一个参数GOMAXPROCS,也有函数可以自己设置,但是在程序运行中调用会stw,消耗太大,不建议使用。golang早前的版本默认该值为1,现在已经和cpu的核数一致了。

该参数不是M有多少个的意思,其实是P有多少个的意思。在程序执行的初期,1个M会独占一个P,但是M在整个程序的生命周期中,并不是一直和P想到的,大致的关系为Number of P >= Number of M。由于一个M最多对应一个P,而P会有多个G,所以一个M就会对应多个G,M:N也就实现了。

会用golang的都知道,相比c中创建线程需要提供回调函数、栈大小等信息返回一个线程句柄,golang创建一个goroutine是相当的简单:

go func() {
    // Do something
}

虽然语法简单,但是其实内部的实现和c中创建线程也差不多,也包含将函数、参数压栈并call一个函数来创建goroutine。但是这里创建goroutine就比较简单了,在go的实现中,一定会是某个已经在跑的goroutine中创建的,既然该goroutine在跑,那么肯定属于某一个P,相同地,肯定属于一个操作系统线程,这里也就是M了。所以创建仅仅是简单的创建一个G对象,包含着函数、参数等信息,注意这里的参数是复制一份的,然后简单的push到P的runable G list的尾部,这样创建goroutine的工作就完成了。于是当前P的队列中新增了一个G。

但是P的本地队列长度是有限制的,这主要是为了防止某个goroutine任务太多,而别的goroutine没有G来执行导致任务分配不均,所以在P的本地G列表假设满了之后,则会放入全局的空闲待调度G列表,等待P来获取;同时P在执行一定数量的G之后,也会去全局空闲待调度G列表中取G来放入自己的本地G队列。

G的运行也比较简单,G的对象内,保存了函数的起始地址,以及G自分配的堆栈地址,所以运行G的时候只要把sp和ip恢复到寄存器,那么就可以在任意线程恢复当前G的执行上下文而继续执行。G的初始栈很小,所以我们可以大量的创建而不至于消耗太多的内存。当某个G的堆栈不够用的时候,会进行扩容,扩容尺寸为当前尺寸的2倍,然后把旧堆栈的数据拷贝进新的堆栈,只需要调整sp的值,就可以把堆栈给替换掉了。和传统的c程序一样,在goroutine内,栈上的变量是通过目前和栈底的偏移来进行寻址,所以替换sp,只要偏移不变,程序就能正常运行。

上面讲到了M很有可能会多于P,这种情况在M在执行某个P的G的时候,该G进行了一个系统调用并且没有返回(阻塞),那么调度器会将M与P进行分离,M将直接接管执行系统调用的G,同时会创建或者唤醒休眠的M来绑定之前的P,于是M会比P数量更多。当系统调用完成后,M必须得获取P来执行G,假设没有可用的P,那么会将G放入全局的G队列,同时自身进入休眠状态。

调度还有一个很重要的方面就是偷取任务,这个工作主要是P来进行的。当一些P处于饥饿状态的时候,则会去全局的G队列中取可执行的G,当取完了,则会去别的P的本地队列中偷取一半可执行的G,这里的实现用的是cas,所以性能损耗比较小。

共 0 条回复
暂时没有人回复哦,赶紧抢沙发
发表新回复

作者

sryan
today is a good day