Go 语言再次拿下 TIBOE 年度编程语言,充分证明 Go 语言在业内的受欢迎程度。
Go 语言的高并发是大多数人选择 Go 的一大原因,下面就来介绍 Go 语言高并发调度是如何实现的。
Go 由 Runtime 对 Goroutine 进行调度,即何时决定调度哪个 Goroutine 开始执行,哪个 Goroutine 应该停止执行让出资源、哪个 Goroutine 应该被唤醒恢复执行等。
Go Runtime
Go Runtime 管理着 Goroutine 的调度以及运行时的垃圾回收。
Go 程序可以被看成两层,一层是用户代码,一层是 Runtime, 并且调用一些管理 Goroutine, Channel, 以及一些其他的高层抽象。
G P M 调度模型
Go 中的调度模型称为 G P M 调度模型。
Runtime 调度器通过把 Goroutine 绑定到操作系统线程来运行它们。Goroutine 可以看作是轻量级的线程。每个 Goroutine 用 G 来表示,它包含了用来跟踪栈的字段和当前状态。
Runtime 跟踪每一个 G 并且把它们绑定到 P(Logical Processor)。P可以被看作抽象资源或者上下文,操作系统线程用 M 来表示(OS Thread)需要获取它以便来执行 G。程序中可以通过 runtime.GOMAXPROCS ( numLogicalProcessors )
来调整 P(Logical Processors)。
每一个 G(Goroutine)需要绑定到 P(Logical Processor)才能调度执行;就像每一个 User-level Thread 绑定到 Kernel Thread 才能被调度执行。
G – Goroutine
1 2 3 4 5 6 7 8 9 10 11 12 13 |
type g struct { stack stack // 描述了真实的栈内存,包括上下界 m *m // 当前的m sched gobuf // goroutine切换时,用于保存g的上下文 param unsafe.Pointer // 用于传递参数,睡眠时其他goroutine可以设置param,唤醒时该goroutine可以获取 atomicstatus uint32 stackLock uint32 goid int64 // goroutine的ID waitsince int64 // g被阻塞的大体时间 lockedm *m // G被锁定只在这个m上运行 } |
对于一个 Goroutine ,每个结构体 G 中有一个 sched 的属性就是用来保存它上下文的。这样,Goroutine 就可以很轻易的来回切换。
由于其上下文切换在用户态下发生,根本不必进入内核态,所以速度很快,而且只有当前 Goroutine 的 PC, SP 等少量信息需要保存。
- 内存
- Goroutine 的创建大约 2KB 栈空间
- 线程一创建就要约 1MB 内存
- 切换
- 线程切换,调度器需要保存所有的寄存器,通用寄存器、程序计数器、栈指针
- 保存 3 个寄存器:程序计数寄存器 PC、栈指针 SP 以及 DX
创建一个 Goroutine 并准备运行,这个 Goroutine 会被放到调度器的 Global 队列中。然后调度器就将这些队列中的 Goroutine 分配给一个P(Logical Processor),并放到这个 P 对应的 Local 队列中。Local 队列中的 Goroutine 会一直等待直到自己被分配的 P 执行。
如果正在运行的 Goroutine 被一个系统调用阻塞,如打开一个文件。当这种情况发生时,M2(OS Thread)和 Goroutine 会从 P0(Logical Processor)上分离,这个 M2 会一直阻塞直到系统调用返回。
与此同时,P0 就失去了用来运行的 M2。所以,调度器会创建一个新的 M3,并将其绑定到 P0 上。之后,调度器会从 Local 队列中选择另外一个 Goroutine 运行。一旦刚才阻塞的系统调用执行完毕并返回,对应的 Goroutine 会放回到 Local 队列。
M – OS Thread
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
type m struct { g0 *g // 带有调度栈的goroutine gsignal *g // 处理信号的goroutine tls [6]uintptr // thread-local storage mstartfn func() curg *g // 当前运行的goroutine caughtsig guintptr p puintptr // 关联p和执行的go代码 nextp puintptr id int32 mallocing int32 // 状态 spinning bool // m是否out of work blocked bool // m是否被阻塞 inwb bool // m是否在执行写屏蔽 printlock int8 incgo bool // m在执行cgo吗 fastrand uint32 ncgocall uint64 // cgo调用的总数 ncgo int32 // 当前cgo调用的数目 park note alllink *m // 用于链接allm schedlink muintptr mcache *mcache // 当前m的内存缓存 lockedg *g // 锁定g在当前m上执行,而不会切换到其他m createstack [32]uintptr // thread创建的栈 } |
- M:代表一个线程
M 会从运行队列中取出 G , 然后运行 G , 如果 G 运行完毕或者进入休眠状态,则从运行队列中取出下一个 G 运行,周而复始。
有时候 G 需要调用一些无法避免阻塞的原生代码,这时 M 会释放持有的 P 并进入阻塞状态,其他 M 会取得这个 P 并继续运行队列中的 G。
P – Logical Processor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
type p struct { lock mutex id int32 status uint32 // 状态,可以为pidle/prunning/... link puintptr schedtick uint32 // 每调度一次加1 syscalltick uint32 // 每一次系统调用加1 sysmontick sysmontick m muintptr // 回链到关联的m mcache *mcache racectx uintptr goidcache uint64 // goroutine的ID的缓存 goidcacheend uint64 // 可运行的goroutine的队列 runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr // 下一个运行的g sudogcache []*sudog sudogbuf [128]*sudog palloc persistentAlloc // per-P to avoid mutex pad [sys.CacheLineSize]byte } |
- P:调度的上下文
- runqueue: 等待的 Goroutine
work stealing算法
需要维护多个 P,因为当一个 M 被阻塞时,P 可以转而投奔另一个 M。
P 所分配的任务 G 很快就执行完了(分配不均),这就导致了一个 P 空闲而系统却任然忙碌。但是如果 global runqueue 没有任务 G 了,那么 P 就不得不从其他的上下文 P 那里拿一些 G 来执行。一般来说,如果上下文 P 从其他的上下文 P 那里要偷一个任务的话,一般就‘偷’ run queue 的一半。
本文作者:尹长昕