本文基于Go 1.13。
Golang runtime库有一套完全基于用户态的协程(goroutine)调度框架。为了配合协程调度框架,提升系统调用时的并发性能,Go对SYSCALL调用机制进行了封装。
通常一个系统调用,在类platform OS上都会进行地址空间的切换,这相当于一个大型的Context Switch,先不论具体是哪个SYSCALL,即使这样一次Context Switch的损耗也是很大的。所以Go的SYSCALL机制,利用了操作系统的并行计算(parallelism)来缓和这一开销,而将其对并发的损害降低。

1
2
3
4
5
6
TEXT ·Syscall(SB),NOSPLIT,$0-28
BL runtime·entersyscall(SB)
...
SWI $0
...
BL runtime·exitsyscall(SB)

Syscall总接口定义在操作系统相关的文件。Linux平台是go/src/syscall/asm_linux_arm.s
Go框架在真正的进内核调用系统调用前后进行接管,插入了配合调度的代码。

entersyscall

假设调用Syscall之前,GPM运行的状态如下图:
747549a28a7a627a3cbd98fffe728fad

PM解绑

当调用了Syscall以后,进入entersyscall,P和M会解绑,变成下面这张图:
96630c3dd34a06fbc5ed5f8b4c8bdfbc

这张图不是很准确,当M1和P解绑时,M2并没有立即上位,P处于_Psyscall状态。P是否与新的M绑定由sysmon这个特殊M来完成。后面sysmon一节会专门介绍。

1
2
3
4
pp := _g_.m.p.ptr()
pp.m = 0
_g_.m.oldp.set(pp)
_g_.m.p = 0

此时原先的P被记入了m.oldp,未来会用到。

exitsyscall

当系统调用结束以后,调用Syscall的G需要归位
5530edf8c481aee13a40ff0b55e8e1ed
上图中G1被放到了oldp的队列里,实际并不一定如此。
exitsyscall的调用流程如下:

graph TD;
A[exitsyscall] --> B[exitsyscallfast];
B --> |success| C["_g_: _Gsyscall => _Grunning"];
B --> |fail| D[exitsyscall0];
C --> E[return];
D --> E;

这其中涉及两个重要函数:

  • exitsyscallfast
  • exitsyscall0

exitsyscallfast

该函数会尝试先绑定oldp,如果不成功,会在尝试其他idlep。M绑定P使用wirep函数。
如果P,M绑定成功,那么G是不需要切换任何队列,G会继续运行。
如果无法绑定,则会停止M,并找到地方放置G。这一过程在exitsyscall0。

exitsyscall0

graph TD;
A[exitsyscall0] --> B[gp: _Gsyscall => _Grunnable];
B --> C[dropg];
C --> D[pidleget];
D --> |no idle P| E[globrunqput];
D --> |find idle P| F["aquirep & execute(gp)"];
E --> G[stopm];
G --> H[schedule];
F --> I[never return ...];

exitsyscall0仍然会尝试获取P,如果能获取到,则会acquirep,acquirep会调用wirep完成P和M的绑定。之后调用execute函数,并且不会再返回。因为在execute G的时候,仍然会遇到调度点。
注:如果没有遇到调度点的情况没有想明白。

如果exitsyscall0仍然没有获取P,则会调用stopm来停止当前M,直到被唤醒。唤醒之后会直接进入调度。

sysmon

sysmon是一个由runtime启动的M,也叫监控线程,它无需P也可以运行,它每20us~10ms唤醒一次,主要执行:

  • 释放闲置超过5分钟的span物理内存;
  • 如果超过2分钟没有垃圾回收,强制执行;
  • 将长时间未处理的netpoll结果添加到任务队列;
  • 向长时间运行的G任务发出抢占调度;
  • 收回因syscall长时间阻塞的P;
    入口在src/runtime/proc.go:sysmon函数。
    因为我主要关注的是Go调度框架,所以sysmon中关于GC,CGO,trace等部分都忽略掉了。所以只剩下retake函数。retake会对syscall和长时间运行的G进行接管。

retake

retake会遍历所有的P,即allp数组,做两件事。

  1. 抢占运行过久的G
  2. handoff处于_Psyscall过久的P

preempt G

这里表示在某一个P上,即一个某个CPU核上运行的G运行时间过于久。
判断G运行过久的方法是:

  • retake传入的参数now = noontime()
  • retake会记录每次调用retake的时间点在p.sysmontick.schedwhen中
  • 如果schedwhen+forcePreemptNS <= now表示G运行过久,其中forcePreemptNS = 10ms

handoff P

与上一节类似,此处要判断P处于Psyscall的时间过久。
每一次retake,记录时间到_p
.sysmontick.schedwhen。当发生下面的情况,则表示P处于_Psyscall时间太久:
syscallwhen+10*1000*1000 <= now
之后将P的状态切换为_Pidle,然后调用handoffp来绑定P和一个新的M。
实际上这里才完成了这幅图上M2与P的绑定:
96630c3dd34a06fbc5ed5f8b4c8bdfbc

参考文献

打赏代表您对我的认可,这会激励我继续向前!
Ben Zhou WeChat Pay WeChat Pay
Ben Zhou Alipay Alipay
Welcome to my other publishing channels