ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解Go调度器:GMP模型、调度循环与性能排查

深入理解Go调度器:GMP模型、调度循环与性能排查 我第一次在 Go 里看到几十万个 goroutine 同时在跑系统负载居然还能接受时说实话是有点震动。几十万这个量级放在纯线程模型里早就被线程栈内存和内核上下文切换拖垮了。Go 能扛住这个量级核心靠的是 runtime 自带的那套 Goroutine 调度器。你天天写go func()不稀奇但如果你不知道它背后是怎么排队、怎么切换、怎么被抢占的那当你的服务出现莫名其妙的延迟尖刺时你连排查方向都没有。这篇文章不讲泛泛的并发哲学而是把我自己梳理 GMP 模型、调度循环、源码路径和排查手段的结论整理下来。适合已经能熟练写 Go 代码但面对高并发性能问题、goroutine 泄漏、调度延迟这些问题还比较迷茫的人。看完之后你再打开 runtime/proc.go或者看到 pprof 上的调度栈时会感觉熟悉很多。1. 为什么必须理解调度原理而不是只背 GMP1.1 调度器解决的核心矛盾Go 提供 goroutine 给开发者最大目的就是降低“并发单元”的创建和使用成本。一个操作系统线程创建时有固定栈大小通常几 MB还涉及内核态资源分配和线程切换时的完整上下文保存恢复量一大CPU 时间都被上下文切换吃掉了。goroutine 不一样初始栈只有 2KB 左右创建时不需要进内核本质上只是在用户态分配一块内存、初始化一个结构体。运行中的切换也不走内核而是在当前线程内通过汇编代码保存寄存器、栈指针等信息从一个 G 切到另一个 G。这里最容易误解的地方在于内核只认识线程M不认识 goroutineG。真正在 CPU 上执行的是 MG 只是跑在 M 上的“可移动任务”。Go 调度器要做的事情就是维护好 G 和 M 的对应关系让 G 能在合适的时机、合适的 M 上运行。这个抽象解决了资源成本问题同时也要处理好“阻塞”问题一个 G 等锁、等 IO、等 timer 时不能让整个 M 跟着停下来。1.2 理解调度器对日常开发的真实反馈如果你只把并发当成“一个函数前面加 go 就能跑”那迟早会遇到三类问题。第一类是 goroutine 泄漏G 越积越多但一直不退出服务内存和负载持续上涨。第二类是调度延迟某些 goroutine 长期占用 P导致其他本该快速执行的 G 在队列里干等接口延迟变大。第三类是配置短视并发数、GOMAXPROCS、channel buffer 全凭拍脑袋线上出问题后只能靠重启缓解。这些问题的根源其实都在调度原理里。例如为什么在 for 循环里疯狂创建 goroutine会有一批任务进入全局队列为什么通过 channel 通知一组 worker 时唤醒顺序看起来不是完全公平为什么一个死循环可能会把整个进程的 GC 拖长搞清楚这些细节再去做调优和排障就不是靠猜而是靠定位。2. GMP 模型先从骨架上把调度器立起来2.1 G轻量级执行体的内部结构G 在 runtime 中对应的是runtime.g结构体你可以把它理解成一个“协程控制块”。几个核心字段stack保存当前栈内存的上下边界表示这个 G 的栈范围。sched保存运行现场的寄存器上下文最重要的是 PC、SP、BP。atomicstatus记录 G 的当前状态。goid是这个 G 的唯一编号。m指向正在执行它的 M如果它不在运行这里是 nil。G 的栈初始很小只有 2KB 左右但 runtime 会在栈空间不足时自动扩展。这个动态栈机制是“百万 goroutine”成立的前提。如果每个 G 一开始就吃几 MB 固定栈规模根本起不来。栈的扩容是在 G 运行到某个函数调用时检查的如果接近栈上限会按倍数拷贝到更大的新栈上这也就是你能在 trace 中看到stack grow事件的原因。2.2 M真正干活的 OS 线程M 是 runtime 对系统线程的封装是真正在 CPU 上执行的单元。每个 M 都绑定了两个比较特殊的 Gg0调度器自己使用的 G和用户代码无关拥有较大的栈空间。调度器本身跑在 g0 上它不会去执行业务函数。curg当前正在被这个 M 调度的用户 goroutine。M 的数量可以和 P 差很多。M 本身可以通过系统调用创建也可能因为阻塞、cgo、网络轮询等原因多出来。关键点是没有拿到 P 的 M 通常不会一直空转它会进入休眠状态等有 P 可用时被唤醒。所以你看top命令里进程的线程数大于 GOMAXPROCS是正常的。G 需要 M 才能运行但 G 和 M 不是一对一绑定的。一个 G 运行中如果遇到 channel 等待它会主动把 M 让出来M 继续跑下一个 G。等到这个 G 被唤醒它可能回到原来的 M也可能是另一个 M。这也是 goroutine 轻量化的核心阻塞的单元是 G不是 M。2.3 P调度的关键资源池P 不是处理器线程而是一个“调度的逻辑处理器”。默认情况下 GOMAXPROCS 是多少runtime 就会创建多少个 P。P 并不是只当作一个运行队列使用它同时是资源的分区runq本地可运行队列容量固定为 256。runnext指向下一个要优先执行的 G。mcache内存分配缓存和 GC 关联紧密。其他 GC 标记、计数器等状态。P 的设计解决了一个很重要的竞争问题如果所有 G 都放在一个全局队列里调度器每次取和放都要抢全局锁高并发下就成为热点。现在每个 P 有自己的本地队列大部分调度动作都发生在本地只有队列满或空时才会触碰全局队列和锁。同时P 的数量决定了同时处于用户代码运行状态的 goroutine 上限。P 是有限的这也是调度器能做公平调度的前提它没办法让所有 G 同时跑只能尽量让每个 G 都有机会上 P。2.4 状态流转从 G 到 P 再到 M看调度器源码时会看到各种状态常量。G 的状态常见有状态含义_Gidle刚分配还没初始化_Grunnable在运行队列中等待执行_Grunning正在某个 M 上执行_Gsyscall正在执行系统调用_Gwaiting阻塞在 channel、锁、定时器等上_Gpreempted因为异步抢占被强制让出_Gdead已经执行完等待复用P 的状态也很重要状态含义_PidleP 空闲没有绑定 M_Prunning已绑定 M正在调度并运行 G_PsyscallP 上的 M 正在 syscall_PgcstopGC 阶段P 需要全部停止_PdeadP 被销毁这些状态其实是排查性能问题的入口。比如 pprof 的 goroutine 信息里如果堆积了大量_Gwaiting状态的 G说明它们全都卡在等待条件上多半是泄漏或者死锁。3. 调度循环到底在忙什么3.1 入口 schedule() 的优先级设计所有 M 拿到 P 之后进入schedule()函数开始寻找可执行的 G。取 G 的顺序是有明确优先级的先取当前 P 的runnext指向的 G。再从当前 P 的本地队列runq取 G。如果本地队列为空从全局可运行队列取一批 G。还是没有就进入findrunnable()它里面会检查 netpoll、偷取其他 P 的队列、甚至休眠等待。这个顺序不是随便定的。局部优先是因为 goroutine 之间常有数据关联放在同一个 P 上可以利用 CPU 缓存减少跨 P 访问导致的 cache miss。全局队列每次取一批而不是一次全拿走是为了避免个别 P 把全局任务全包导致其他 P 饿肚子。写并发代码时也能感受到这个优先级如果业务协程特别多别让所有任务都挤到全局队列否则所有 P 抢同一把全局锁调度器本身会成为瓶颈。3.2 runnext一个容易被忽略的后进先出设计runnext平时不起眼但它是理解“局部性”的关键。当你写go doSomething()启动一个新 G 时runtime 不会直接把它放到 runq 队尾而是放到runnext也就是当前 P 的下一个执行位。如果 runnext 里已经有了别的 G新的 G 会顶上去旧的被挤到本地队列尾部。这个设计的实际好处是在循环里连续创建 goroutine 时新 G 会优先执行和父协程的数据亲和力更强执行顺序也更自然。例如循环里对一批任务做处理每个任务作为一个 goroutine新任务大概率会第一时间被当前 M 执行而不是排队排在很久以后。但要注意这不是一个严格的公平机制。如果你在一个 G 里疯狂创建新 G旧 G 可能会被反复挤到队尾导致某些任务迟迟得不到执行。极端情况下你可以通过减少一个 G 内创建 G 的数量或者改用带缓冲的任务池来避免这种“年轻化挤压”。3.3 findrunnable()没事干时的完整路线真正复杂的取 G 逻辑在findrunnable()里它是调度器最繁忙的路径之一。整个流程可以拆成这样再次确认本地队列。确认全局队列。从 netpoll 中捞取已就绪的网络事件转换为多个 G。随机挑选其他 P偷取对方 runq 的一部分 G。检查 GC 是否有特殊任务需要运行。仍然没有任务就把当前 P 置为空闲M 准备休眠或被复用。“随机偷取”是调度的灵魂。某个 P 的任务积压另一个 P 闲得发慌偷取机制能从忙的 P 的本地队列里拿走一半左右让整体负载更均衡。偷取对象是随机选择的并不存在“全局排序”所以调度并不保证 100% 公平但实践下来已经足够好。线程发现没活干时不会马上休眠。它会进入一段“自旋”状态过一段时间反复找活。这是一种用 CPU 换延迟的做法避免任务刚到达时线程已经 park 了再唤醒带来额外开销。代价是空闲或低负载时你会看到进程的 CPU 占用并不为 0它在做自旋。3.4 sysmon潜伏在背后的调度哨兵runtime 里有一个特殊的系统线程sysmon它不绑定 P也不和用户 G 关联。它每隔一段时间起来检查全局状态主要工作包括检查有没有 G 长时间占用 M超过 10ms 没有让出触发异步抢占。检查 P 是否在 syscall 里卡了太久如果是就把 P 从当前 M 上摘下来给别人用。检测死锁、触发必要时的 GC。管理 timer 和 netpoll 的调度。Go 1.14 引入的异步抢占就是 sysmon 主导的。一个 G 在 CPU 上跑死循环不在任何地方让出sysmon 会向该线程发送 SIGURG 信号线程在信号 handler 中打断当前执行让出 P。以前协作式调度时这种 G 可能会把整个进程卡死现在不会了。但抢占也有限制。如果 G 卡在真正的系统调用里sysmon 只能把 P 剥离不能杀掉系统调用本身。所以遇到 CGO 那种长阻塞调用时性能问题还是得从业务上解决。4. 关键代码路径拆解从源码看调度器4.1 一声 go 背后发生了什么当你写go doSomething()编译器会把这个调用转成对 runtimenewproc的调用。在newproc内部核心逻辑并不复杂获取当前调用方的 PC 和 SP以及函数参数信息。尝试从空闲 G 缓存池中拿一个 G没有则新建。初始化 G 的栈、执行上下文。调用runqput把新 G 放到当前 P 的 runnext或者本地队列。如果发现当前 P 空闲或存在 syscall 占用的 P唤醒一个 M 来干活。runqput的简化逻辑大致是这样func runqput(pp *p, gp *g, next bool) { if next { oldnext : pp.runnext pp.runnext gp gp oldnext } // 尝试把 gp 放进本地环形队列 // 如果本地队列满了就放到全局队列 lock(sched.lock) globrunqput(pp, gp) unlock(sched.lock) }这段逻辑解释了为什么 go 语句本身成本极低。大部分情况下新 G 只是被塞进当前 P 的本地数组不涉及系统调用也不需要加复杂的锁。只有本地队列满了才会往全局队列放并触发全局锁竞争。所以你的 goroutine 创建密度如果特别高值得关注本地队列是否经常溢出。4.2 M 的创建与线程增长当需要新 M 时runtime 会通过newm创建 OS 线程最终由clone或其他系统调用完成。新线程启动后会先执行一段 runtime 入口代码初始化 TLS然后尝试获取一个空闲 P。如果当前没有空闲 P这个 M 可能进入休眠等待后续被唤醒。这里有个很有意思的现象M 的创建时机不只是“任务太多”。比如一个 G 进入长时间 syscallsysmon 把 P 剥离出来给其他 M 用系统里会临时多出一个 M 来顶替原位置。等 syscall 返回旧 M 可能发现没有 P 可用只能休眠。所以你看线程数上涨不代表任务量上涨也可能是一批短暂 syscall 造成的。如果线上的 M 数量一直在高位不回落要留意两种可能一是 syscall 被卡住线程在等待系统调用返回二是 runtime 频繁创建 M但还没来得及休眠就又有任务到了。后者在“突发流量”场景很常见通常不用管前者就要找那个阻塞的系统调用点。4.3 Goroutine 状态机迁移细节一个 G 从创建到复用的完整生命周期创建后进入_Grunnable在某个 P 的队列里等待。schedule()选中它后状态变成_Grunning开始执行用户函数。如果执行过程中用户代码主动等待比如从 channel 读取但没有数据G 就进入_Gwaiting。这时 M 从 P 上释放继续调度其他 G。G 被唤醒后变回_Grunnable等待再次进入运行状态。函数执行完成调用goexit做清理G 状态变成_Gdead放回空闲池等待复用。状态机本身不复杂但要特别注意_Gwaiting和_Gsyscall的区别。_Gwaiting是 G 主动等待某个条件M 是自由的_Gsyscall是 G 在系统调用里阻塞住了M 也陪着卡住只不过 P 可能已经被调度器摘走。从排障角度看见大量_Gwaiting就要想到 channel、锁、定时器这类等待源。看见_Gsyscall占了很大比例就要排查是不是有 goroutine 在做同步 IO 或 CGO 阻塞调用。4.4 syscall 与 P 释放的实际处理Go 对系统调用做了专门处理核心在entersyscall和exitsyscall。G 进入 syscall 时整个 M 会把 P 标记为_Psyscall。如果 syscall 很快返回P 又会被 M 拿回来继续用。如果 syscall 长时间不返回sysmon 就会介入把 P 从 M 上摘下来转为空闲状态给其他 M 抢。等 syscall 真正返回后旧 M 想继续干活必须重新找 P。能找到就接着跑找不到就把当前的 G 放回某个运行队列M 自己休眠。这套机制保证了即使某个 goroutine 在做很慢的系统调用系统的并发能力也不会被这个“堵住”的 M 拖死。换句话说Go 不会因为一个 goroutine 在线程里阻塞而放任整个调度器瘫痪。它会通过 P 的重新分配让其他 G 继续运行。5. 那些严重影响性能的调度细节5.1 netpoller为什么网络 IO 不阻塞调度Go 的 netpoller 是处理网络 IO 的核心。普通阻塞式网络读写不会让 M 直接 park而是通过 epoll/kqueue 等机制注册 fd。当一个 goroutine 在 accept、read、write 时拿不到数据它进入_Gwaiting对应的 M 从 P 上释放调度器继续执行别的 G。等内核有事件了netpoller 唤醒对应的 G把它放回可运行队列。这个机制在 findrunnable 里也有结合每次寻找任务时会把 netpoll 的结果转成一批 G 放入运行队列。所以如果你的服务是网络 IO 密集大量 goroutine 阻塞在 socket 上本质上它们并不会占满系统线程。这也是 Go 适合写网络服务的重要原因一个进程里撑住上万连接不需要上万个线程。5.2 GC 和调度在 STW 阶段的配合垃圾回收是调度器复杂度飙升的地方。STWStop The World期间所有 P 都要到达安全点并停止执行用户代码。这意味着如果 GOMAXPROCS 很大GC 开始时需要等最后一个 P 停下STW 前的等待时间可能增大。每个 P 都有自己的 mcache画分配和 GC 标记阶段用户 goroutine 和后台标记 goroutine 会交替使用这些 P。你会发现 GC 不只是“暂停一切”它更像一个二段式的协作先快速停止一部分再做标记再恢复用户代码最后再收尾。调度器需要配合好这些阶段否则每轮 GC 开销都会非常大。这给我们的实践提醒是GOMAXPROCS 设置过大在内存分配很频繁的服务里不一定划算。P 越多GC 协作的复杂度和潜在延迟越高。但也不能因此盲目把 GOMAXPROCS 调小还是要结合压测数据。5.3 自旋线程用一点 CPU 换调度延迟当一个 M 找不到 G 可执行时它不会立刻休眠而是可能进入“自旋偷取”状态。自旋不是无限的只有当系统里存在空闲 P或者未来可能有任务到达时M 才会持续自旋一段时间。自旋的本质是用 CPU 资源换调度延迟。线程 park 到 wake 的开销实在太大尤其在任务到达较频繁的场景稍微等一等可能任务就来了比反复休眠靠谱。代价是低负载或空闲时进程会占着一些 CPU 做无谓的空转。你在压测空转服务时如果看到 CPU 不为零不一定是业务在忙可能只是线程在自旋。5.4 channel、mutex 与调度切换channel和sync.Mutex阻塞的是 G不是 M。一个 G 在 channel 上等待时它会进入等待队列M 立即继续调度其他 G。唤醒备用的 G 由 runtime 触发比如通过goready把等待者放回 P 的队列。这种设计让锁和 channel 在高并发下不一定比原子操作更贵但锁竞争激烈时会引发“排队抖动”。多个 G 同时抢一把锁时等待者被加入队列解锁时逐个唤醒唤醒后的 G 又要重新经历排队、调度整体成本会放大。所以高并发编程的经验依然是减少共享资源的临界区而不是简单堆并发。6. 排查调度问题的一些实战套路6.1 profile 里识别调度热点用 pprof 采集 CPU 时如果看到大量采样集中在runtime.futex、runtime.lock、runtime.runqgrab、runtime.stealWork这类函数就说明调度器本身已经忙不过来了。常见排查步骤看整体火焰图用户代码占比高还是 runtime 占比高。如果 runtime 占比高继续展开确认是调度循环、锁、还是 GC 相关。再结合 goroutine 数量判断是任务太多还是调度结构有问题。6.2 用 trace 看调度延迟go run -trace trace.out main.go go tool trace trace.outtrace 工具可以展示每个 P 的时间线、goroutine 状态、网络事件和 syscall 事件。我一般重点关注两点G 从_Grunnable变成_Grunning的耗时以及 syscall 占用 P 的时间比例。如果看到大量 G 在_Grunnable状态停留时间很长说明它们的创建速度已经超过了消费速度。这个场景下单纯调大 GOMAXPROCS 不一定有效更关键的是减少任务积压比如做限流、批量处理、或者优化临界区。6.3 经典 goroutine 泄漏排查第一招通过监控系统看runtime.NumGoroutine()曲线持续增长不回落基本就是泄漏。第二招用 pprof 拉取当前 goroutine 栈go tool pprof http://localhost:6060/debug/pprof/goroutine然后看大量累在一起的等待栈。常见的泄漏原因channel 的发送端或接收端一直不匹配部分 G 永久等待。循环里使用了time.After导致定时器对象无法及时释放。忘记关闭连接池、客户端或done通道。并发请求触发范围过大G 全卡在连接池或限流器上。这些坑在原理上都一样G 进入_Gwaiting后没有合适的事件唤醒它一直占着资源不释放。6.4 GOMAXPROCS 什么时候调整默认情况下 GOMAXPROCS 是 CPU 核心数一般不建议改。容器环境里较新版本的 Go 会尝试读取 CPU quota自动调整。需要手动调整的场景比较有限和其他语言混部时想主动限制 CPU 争抢。性能测试发现调度器开销过大尝试减小并发实验。IO 密集服务想降低 GC 延迟做压测对比。所有调参都必须用 trace 和压测数据说话不要凭感觉调成 1 或调成很大。有时候 GOMAXPROCS 从 8 改成 4吞吐反而更高可能是因为锁竞争和 GC 开销降下来了但在另一个服务上可能完全相反。7. 我在项目里踩过的调度相关坑第一个坑异步任务在队列里堆积。当时做一个公共组件把大量异步任务丢给 goroutine 执行以为很便宜。结果业务方在高峰期疯狂调用goroutine 数量瞬时飙到百万级pprof 一看任务全在_Grunnable队列里没人执行。原因很简单任务创建速度远大于消费速度P 只有那么多本地队列满了全往全局队列塞调度器光顾着抢全局锁了。后来加了限流和任务分批问题才缓解。第二个坑runtime.Gosched()被误用。有同事在 for 循环里加runtime.Gosched()想让出 CPU结果适得其反。Gosched 只是把当前 G 让出去下一个循环立刻又抢回队列整体调度开销更高。真正要降 CPU 应该用time.Sleep或者调整协程数量而不是拿调度器当避让工具。第三个坑盲目认为“goroutine 便宜所以随便开”。goroutine 确实便宜但并不是免费的。它的栈会增长调度器对大量 G 的状态管理也要消耗内存和 CPU。一个 G 的栈初始 2KB100 万个就是 2GB再加上运行缓存和 GC 压力内存不可忽视。我现在写并发代码的习惯是先想清楚谁能唤醒它、什么时候唤醒、唤醒后会不会又把系统压垮。把这些问题想明白再写go func()也不迟。这些经验都是被调度器虐过之后换来的希望你不用再踩一遍。
返回列表