写Go一段时间的朋友,大概率都遇到过这一类怪问题:明明协程数量不多,CPU却被某个核吃满;或者某个goroutine迟迟得不到调度,仿佛被“饿”在了队列里;再或者一个不带任何函数调用的for循环,能把整个进程拖到近乎卡死。这些现象的根源,最后都会指向一个绕不开的话题——Go调度器的时间片和公平性是怎么实现的。
网上讲GMP模型的文章很多,但大多停在“G是协程、M是线程、P是处理器”的层面。至于时间片怎么分、抢占靠什么触发、为什么一个P会去偷另一个P的活、Go为什么不用操作系统那种固定时间片,这些细节反而很少被讲透。这篇就顺着调度器源码和实际运行现象,把时间片与公平性这两件事拆开聊一聊,给正在研究调度原理或者排查并发问题的同学做参考。
1. 调度器的基本盘:从GMP说起
1.1 G、M、P到底是干什么的
先花点篇幅把模型捋一下。G就是goroutine,编译之后的协程对象,里面保存栈、PC、状态、等待原因这些信息。M是操作系统线程,真正在CPU上执行代码的实体。P不是“处理器”物理核心,而是调度器为M准备的运行环境,可以理解成一个“工位”,每个工位上挂着一个本地可运行队列,同时也控制着同时并行执行任务的上限。
类比来说:M是工人,P是工位,G是待处理的活。工人必须坐在工位上才能干活,一个工位同一时刻只能坐一个工人。GOMAXPROCS设置的就是工位数,默认等于CPU核心数,也就是最多同时有多少个M在真正并行执行任务。至于剩下的M,要么在阻塞等待,要么在休眠。
P和M的关系不是绑死的。G发生系统调用阻塞时,M会带着G一起卡住,但P不会跟着浪费,它会和当前M解绑,然后唤醒或者创建一个空闲M继续从这个P的队列里取别的G运行。这种“解绑-接管”机制保证了系统调用不会把执行资源一起带进坑里,代价是M的创建和唤醒有额外开销。
1.2 一个协程从创建到运行的完整流转
当代码里写一句go func(),背后其实是调用了newproc。新创建的G不会直接“跑到”某个闲置的CPU核心上,而是先进入一个可运行队列,等待某个P把它取出来。
Go的调度队列分为两层:每个P上有一个本地可运行队列(runq),所有P共享一个全局可运行队列(schedt.runq)。新G的入队优先级是这样:优先放进当前P的runnext字段,这个字段是特设的“下一个执行”位置;如果runnext已经有东西了,原来那个G会被挤到本地队列队头;本地队列也满(默认长度256)了,才会把一半G挪到全局队列去。为什么要设runnext?核心目的是让新创建的子G尽快获得执行,同时减少从队列头部取G的开销。这玩意儿对公平性的影响后面会细说。
调度循环的入口是schedule()。它要回答一个问题:下一个跑谁?顺序大致是:
- 如果当前G被抢占且允许,先做抢占处理;
- 优先从本地队列的
runnext取一个G,再从本地队列头部取; - 本地队列空的,就去全局队列拿一批(后面会讲那套61次调度一次的权重逻辑);
- 全局队列也空,就进入
findrunnable(),尝试从其他P偷一半任务、查网络轮询器、查定时器、最后实在没活就休眠。
这个顺序本身就是公平性的骨架。本地优先是为了缓存友好和减少锁竞争,全局队列兜底是为了防止某些协程长期靠边站,偷取机制则是为了在核数多的时候保持所有P都有事干。
1.3 队列设计为什么直接影响公平性
表面上看,调度器就是“取一个G、跑一下、再取一个”,但公平性恰恰藏在队列细节里。
本地队列是一个环形数组,通过runqhead和runqtail维护头尾,正常情况下先进先出(FIFO)。这意味着,同一P上等待的协程,谁先来谁先跑,不会出现后来者插队的情况——唯一能插队的只有runnext。
全局队列则是一个双向链表,入队加锁,出队也需要锁。它的价值在于:当一个P的本地队列清了,或者某个G主动让出时,能去全局队列捞到别的P产生的任务。没有全局队列,一个P从创建到被偷走只能留在原P的本地队列,一旦这个P长时间繁忙,其他P闲得冒烟也帮不上忙。
真正复杂的平衡逻辑在findrunnable()里:P发现本地没活时,不能立刻去偷,不然所有P都会互相偷,反而造成锁开销。于是Go引入了“spinning线程”的概念——只有处于spinning状态的M才有资格去随机偷其他P的任务,且数量会控制在P总数的一定比例内。偷的时候也不是把目标P的任务全部搬走,而是取走大约一半,让两边都有活干,避免过度迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
