Go协程调度器原理
- M:N调度模型:Go语言的调度器采用M:N调度模型,即多个用户态线程(goroutine)映射到多个内核线程(M)上。它将操作系统线程(M)与goroutine(G)解耦,使得一个M可以运行多个G,提高了并发性能。
- G-M-P模型:
- G(goroutine):代表一个协程,包含了协程的栈、指令指针和其他信息。每个G都有自己独立的栈空间,初始栈大小较小,随着需求动态增长。
- M(machine):对应一个内核线程,负责执行G。M有自己的本地队列,存放待运行的G。M从P的本地队列或全局队列获取G来执行。
- P(processor):处理器,它包含一个本地G队列和一些与调度相关的状态。P的数量通常由GOMAXPROCS决定,P在M和G之间起桥梁作用,M只有获取到P才能执行G。
- 调度流程:
- 创建:当一个新的goroutine被创建时,它被放入P的本地队列。如果本地队列已满,会被放入全局队列。
- 运行:M从P的本地队列获取G并运行。如果本地队列为空,M会尝试从全局队列获取一批G(通常为一半)到本地队列,或者从其他P的本地队列偷取一个G。
- 阻塞:当G发生系统调用等阻塞操作时,M会与P分离,P可以调度其他M来运行本地队列中的其他G。而阻塞的G会被放入等待队列,当阻塞操作完成后,G会被重新放入P的本地队列。
高并发场景下协程饥饿问题分析与解决
分析
- 排查长时间占用资源的协程:
- 使用pprof:通过在程序中引入pprof包,开启pprof服务。例如,在
main
函数中添加import _ "net/http/pprof"
和go http.ListenAndServe("localhost:6060", nil)
。然后使用go tool pprof
工具分析CPU和内存使用情况,找出占用资源多的函数,进而定位到相关的goroutine。
- 日志分析:在关键的资源占用代码处添加日志,记录goroutine的ID、进入和离开时间等信息,通过分析日志找出长时间运行的goroutine。
- 确定饥饿原因:
- 资源竞争:可能是由于多个goroutine竞争同一个资源(如锁),导致部分goroutine长时间等待。可以使用
runtime/race
检测工具,在编译时加上-race
标志,它能检测出数据竞争问题。
- 死循环或复杂计算:某些goroutine可能陷入死循环,或者进行了非常复杂的计算,没有给其他goroutine运行机会。通过上述方法定位到相关代码段后进行分析。
解决
- 优化资源占用代码:
- 避免死循环:检查并修复陷入死循环的代码,确保在适当的时候退出循环。
- 拆分复杂计算:将复杂计算拆分成多个较小的任务,在计算过程中适当使用
runtime.Gosched()
,它会暂停当前goroutine,让其他可运行的goroutine有机会执行。例如:
func complexCalculation() {
for i := 0; i < 1000000; i++ {
// 复杂计算代码
if i%1000 == 0 {
runtime.Gosched()
}
}
}
- 合理使用锁:
- 减小锁粒度:如果存在锁竞争,尽量减小锁保护的代码块范围,让锁的持有时间尽可能短。
- 使用读写锁:如果读操作远多于写操作,可以使用读写锁(
sync.RWMutex
),读操作可以并发执行,提高效率。
- 调整调度策略:
- 设置抢占式调度:Go 1.14及以后版本默认启用了协作式抢占调度,在一些情况下,可能需要强制启用更积极的抢占式调度,通过设置环境变量
GODEBUG=gctrace=1,preempt=1
来观察和测试,让长时间运行的goroutine能被更及时地抢占。
- 优先级调度:可以自己实现一个简单的优先级调度器,为不同的goroutine设置优先级,调度器优先从高优先级队列中获取goroutine执行。例如,通过维护多个不同优先级的队列来实现。