对The Go scheduler - Morsing's blog的翻译与整理
绪论(Introduction)
Go 1.1版本最重大的改进之一是由Dmitry Vyukov贡献的全新调度器。新调度器显著提升了并行Go程序的动态性能。虽然我已无法对这项成果做出更多贡献,但希望为它的传播尽一份力。
本文的大部分内容已包含在原始设计文档中,但该文档技术性较强。新调度器的核心思想可通过本文结合示意图更直观地理解。
为什么Go运行时需要一个调度器?
在深入新调度器之前,我们需要理解其必要性:既然操作系统已能调度线程,为何还要在用户空间实现调度器?
POSIX线程API本质上是对Unix进程模型的扩展,线程继承了进程的许多控制特性,例如信号掩码、CPU亲和性(绑定线程到特定CPU)、cgroups支持等。但这些特性对Go的goroutine模型来说是不必要的开销。当程序拥有10万个线程时,此类开销将显著增加。
此外,操作系统难以针对Go模型做出最优决策。例如,垃圾回收(GC )要求所有线程暂停且内存处于一致状态。若线程分散在随机执行点,GC需等待所有线程到达一致状态。而Go调度器能确保调度仅在内存一致点发生,从而大幅减少GC等待时间。(ps:我自己的理解就是:调度器会在内存状态一致时进行上下文切换,也就是说在上下文切换的时内存是一致的。如果在此时触发了垃圾回收,调度器会暂停切换操作,等待垃圾回收完成后再继续调度。)
核心角色:M、P、G
当前主流的线程模型包括:
- N:1模型:多个用户线程运行在一个OS线程上。上下文切换快,但无法利用多核。
- 1:1模型:用户线程与OS线程一一对应。可充分利用多核,但上下文切换成本高。
- M:N模型:Go采用的折中方案,任意数量的goroutine(用户线程)映射到任意数量的OS线程,兼顾切换速度与多核利用率,但实现复杂。
为实现M:N调度,Go定义了三个核心实体:
- M(Machine) :OS线程,由操作系统管理,类似标准POSIX线程。
- G(Goroutine) :包含栈、指令指针等信息的轻量级协程。
- P(Processor) :调度上下文,可视为单线程调度器的本地化版本。P的数量由
GOMAXPROCS
决定,通常等于CPU核心数。
示意图:两个OS线程(M)各绑定一个上下文(P),每个P运行一个goroutine(G)。灰色G处于就绪状态,存储在运行队列(runqueue)中 。
每个P维护一个本地运行队列 。当创建新goroutine(通过go
关键字)时,G会被加入当前P的队列尾部。P在执行完当前G后,从队列头部取出下一个G执行。
早期版本使用全局队列并通过互斥锁保护,但在多核机器上锁竞争严重。新调度器通过本地队列减少竞争。
系统调用与上下文切换
当G执行系统调用(如文件I/O)时,当前线程(M0)会阻塞。此时,调度器将P从M0剥离,并分配给其他线程(M1)继续执行其他G。
示意图:M0阻塞时,P转移至M1,M1执行其他G **。
系统调用返回后,M0需尝试获取P来恢复执行原G。若无法获取,G会被放入全局队列,M0进入线程池或休眠。全局队列中的G会被其他P周期性检查以避免饿死。
工作窃取(Work Stealing)
当某P的本地队列为空时,它会按以下顺序获取G:
- 从全局队列获取一批G。
- 若全局队列为空,从其他P的本地队列窃取半数G。
此机制确保负载均衡,使所有线程尽可能满负荷工作。
调度器的其他细节
- 网络轮询器(Netpoller) :处理I/O多路复用(如epoll),将阻塞的G挂起,待I/O就绪后重新加入队列。
- 系统监控器(Sysmon) :后台线程,负责释放闲置内存、强制GC、处理长时间阻塞的P、抢占运行超时的G(10ms以上)。
- 协作式抢占:Go 1.2~1.13通过函数调用插入抢占点;Go 1.14+基于信号实现真正抢占。
总结
Go调度器通过M:N模型在用户态实现高效协程调度,避免了内核态切换的开销。其核心创新在于引入P作为逻辑处理器,结合本地队列、全局队列和工作窃取机制,实现了高并发与低延迟的平衡。