Go 的调度器采用混合的协作和抢占式多任务模型,以防止在不干预操作系统的情况下发生饿死。自 1.14 版本以来,运行时通过向运行超出其时间片(通常为 10 毫秒)的 goroutines 的线程发送 SIGURG 信号来注入异步抢占点。当信号处理程序检测到安全点(例如,当 goroutine 即将调用函数或访问堆栈时)时,调度器保存上下文并切换到另一个可运行的 goroutine。这个机制确保即使是没有函数调用的紧密 CPU 密集型循环也不能无限期地独占一个 Processor (P)。
我们的高频交易平台在市场波动期间经历了灾难性的延迟激增,其中一个执行复杂蒙特卡罗模拟的分析 goroutine 会冻结订单处理管道达数百毫秒。问题源于在 Go 1.14 之前,goroutine 执行紧密的数学循环而没有函数调用,阻止了调度器进行抢占。
我们评估了三种不同的方法来解决这种争用。第一个选项是在仿真循环中手动插入 runtime.Gosched() 调用。这个方法提供了即时的缓解,但引入了显著的维护开销,并且要求开发人员具备深厚的调度器知识,导致代码脆弱,如果重构可能会回退。
第二个解决方案提议将分析工作负载隔离到一个具有 CPU 限制的独立微服务中。虽然这提供了严格的隔离和独立的扩展,但网络序列化开销和额外的进程间通信延迟违反了我们针对风险计算的亚毫秒延迟要求。
我们最终选择将运行时升级到 Go 1.20,并明确调整 GOMAXPROCS 以匹配物理 CPU 核心。此升级通过信号实现了异步抢占,使调度器能够每 10 毫秒强制让 CPU 密集型 goroutine 让出 CPU,而无需修改代码。部署后的指标显示,P99 延迟在峰值负载期间稳定在 8 毫秒,消除了超时级联,并保持了单进程架构的简单性。
为什么没有函数调用的紧密循环在旧版 Go 中会导致调度问题,而在新版中没有?
在 Go 1.14 之前,调度器完全依赖于协作抢占,这意味着 goroutines 仅在函数调用、通道操作或互斥锁争用时自愿让出。执行纯算术操作的紧密循环从不达到安全点,实际上独占了它的 Processor (P),直到完成。现代 Go 利用异步抢占,通过向线程发送 SIGURG 信号,在下一个安全点触发上下文切换,无论是否发生函数调用。
当 Processor (P) 可用时,Go 调度器如何决定下一个运行的 goroutine?
调度器实现了一种工作窃取算法,首先检查当前 P 的本地运行队列,然后尝试使用随机起始索引从另一个 P 的本地队列中窃取一半的 goroutines,以减少争用。如果本地队列为空,它会每 61 次调度滴答检查一次全局运行队列,以防止新创建的 goroutines 饿死。这种分层选择最小化了同步成本,同时确保了所有可用 Machine (M) 线程之间的负载平衡。
当 goroutine 执行阻塞的系统调用(如文件 I/O)时,Processor (P) 会发生什么?
当 goroutine 在系统调用上阻塞时,Go 运行时立即将 Machine (M) 线程从其 P 中分离,并将该 P 分配给一个新的或空闲的 M,使其他 goroutine 能够继续在相同的操作系统线程抽象上执行。原始的 M 进入系统调用并等待内核完成操作;返回后,它尝试重新获取其原始 P,如果 P 现在被绑定到不同的线程,那么它会停放自己。这种 M:N 多路复用防止了在 I/O 期间操作系统线程闲置,保持了数千个 goroutine 之间的高 CPU 使用率。