【4】为什么Go能挂住成千上万个goroutine,线程却没爆?一次讲透GMP调度模型
如果你写 Go 写的久了很容易对一件事习以为常请求来了起一个goroutine后台任务想并发跑再起几个goroutine网络连接一多程序里挂着成千上万个goroutine好像也不算什么稀奇事。代码写起来很顺读起来也像普通的阻塞程序。可只要停下来追问一句就会发现这件事并没有看上去那么理所当然。如果这些执行路径都在并发往前推进为什么线程数没有跟着一起膨胀如果线程总会碰到阻塞系统调用、网络等待和 CPU 争抢程序为什么又没有轻易被拖垮再往前追一步如果某个goroutine一直不肯让出 CPU或者某个线程正卡在系统调用里调度器到底靠什么把其他工作继续推下去很多介绍Go并发的材料到了这里会先给一个很快的答案goroutine是轻量线程。这个说法不能算错但只说到了表面。真正难的部分不在“它比线程轻”这件事上而在它为什么能轻这种轻量在阻塞、调度和并行度控制面前为什么还站得住Go运行时要解决的不是简单地把线程包装得更便宜一点而是让大量待执行的工作在有限的线程和有限的并行度上稳定流动。真正撑起这套并发模型的是G、M、P三层分工围绕它们建立起来的队列、让渡、窃取和恢复机制把这几层关系摆正后面再看GOMAXPROCS、netpoll、抢占调度和runtime/proc.go就不会觉得它们像一堆零散技巧。先抓住四个最容易卡人的问题G、M、P三个角色分别是什么为什么偏偏要拆成这三层。线程真的阻塞时为什么整个调度系统没有被一起拖住。调度器平时是按什么顺序找任务的为什么它不靠一个全局大队列解决一切。网络 I/O 明明写得像阻塞代码为什么底层又不需要一连接一线程地死等。把这四层关系先看稳**GMP的主骨架就差不多立住了。**后面的抢占、公平性和版本细节都只是沿着这副骨架继续展开。真正要看清的不是goroutine比线程“轻”多少而是Go运行时到底怎样让大量工作在有限资源上稳定流动。先把G、M、P的角色摆正为什么只把goroutine叫轻量线程还不够把goroutine只理解成轻量线程往往还是不够。真正需要补上的是这种“轻”靠什么维持。Go调度器没有把并发执行这件事直接压到线程身上而是拆成了三层G负责承载待执行的工作M负责把代码真正放到操作系统线程上运行P负责提供执行Go用户代码时所必需的调度上下文也正因为多了P这一层运行时才有办法一边限制同一时刻的并行度一边处理大量goroutine与少量线程之间的映射关系。图里把本地队列、全局队列和工作窃取一起画出来就是为了先把这套分工的骨架立住。图示先看清G、M、P的分工以及本地队列、全局队列和工作窃取如何连在一起。关键不是一对一映射而是三层分工关键不在“一个goroutine配一条线程”而在怎样让大量G在有限的M和P上有序流动。G是工作M是执行者P是执行资格和调度上下文。先把这三层拆开后面再看阻塞、窃取、抢占这些动态过程时就不容易把它们误解成某种“线程池技巧”。G负责工作M负责执行P负责让执行这件事真正成立。角色分工先立住之后下一个问题就是这套结构一旦遇到现实里的阻塞会不会立刻失灵。线程既然是真的线程总会碰到系统调用而只要线程会卡住调度器就必须回答谁继续往前跑。阻塞发生时为什么系统还能继续往前跑系统调用阻塞是理解GMP时最容易卡住的一步。直觉上看只要某条执行路径进入阻塞调用承载它的线程就停住了而线程一旦停住整个并发系统似乎也会跟着受影响。Go调度器真正解决的恰恰是**“线程会阻塞”带来的连锁问题**。关键做法不是让线程永远不阻塞而是当某个M因系统调用暂时卡住时把它手里的P及时让出去让别的线程接手继续执行其他 runnableG。这样阻塞就被限制在局部不会扩散成整片调度停摆。图示某个M因阻塞系统调用停住后P如何让出并由其他线程接手继续推进剩余的 runnableG。这里要抓住的重点不是“系统调用不贵”也不是“线程不会卡住”而是阻塞发生时调度器仍然可以把执行能力保留下来。可以把这件事压缩成两个判断真正被卡住的是那条进入系统调用的执行链不是整个Go程序的并发推进能力P的关键就在这里它把“线程是否阻塞”和“系统还能不能继续调度别的工作”拆成了两件事。被阻塞的是某条执行链不是整个调度系统。不过只知道阻塞时可以让出P还不够。调度器能继续往前跑前提是它知道下一步该去哪里找活。光理解“阻塞时怎么救场”还只是半截接下来还得补上“平时到底怎么调度”。调度器平时是按什么顺序找任务的理解GMP不能只停留在静态分工还得往前追一步当某个线程已经拿到P准备继续执行下一段Go代码时它到底先从哪里找任务。这里没有一个“全局平均分配器”在背后统一发号施令调度器走的是一条层次很明确的优先路径先看runnext再看当前P的本地队列本地没有再去碰全局队列还不够再考虑从别的P那里工作窃取最后再看netpoll有没有已经就绪、可以恢复执行的网络 I/O 任务这个顺序直接决定了调度开销、局部性和全局竞争的强弱。图示调度器为当前P寻找下一个G时的优先路径从runnext到本地队列再到全局队列、窃取和netpoll。这一步的结论可以压缩成一句话调度器优先在本地解决问题只在本地耗尽时才逐层向外找补。这样做不是为了绝对公平而是为了在下面几件事之间保持平衡吞吐响应调度成本Go调度器并不是一个一视同仁的大池子而是一套“本地优先、全局兜底、必要时再平衡”的分层机制。调度器不是平均发活而是优先本地解决缺活时再逐层向外找补。走到这里调度器的日常工作方式已经大致清楚了平时先在本地找任务必要时再向外扩展线程阻塞了也可以通过让渡P把系统继续推下去。剩下最值得单独拎出来的一类场景就是网络 I/O。它在业务代码里看起来最像老老实实堵在那里等结果但运行时内部的处理方式又和直觉差得最远。网络 I/O 为什么能写得像阻塞代码网络 I/O 是另一个很容易被表象带偏的地方。业务代码写起来像顺序阻塞调用读写接口如果数据没来就在那里等等到了再继续往下走。可如果底层真的是“一条连接占住一条线程”地等待Go就很难在高并发网络场景里维持现在这种资源利用方式。运行时真正做的是把下面两件事拆开代码写起来像阻塞底层调度不能被长期占住某个G发起网络读写后如果文件描述符暂时还没就绪运行时会先把这个G挂起把等待事件交给netpoll同时把线程和P释放回调度器继续处理别的 runnableG。等事件真正到来再把对应的G放回队列等待重新调度。图示网络 I/O 尚未就绪时等待中的G先挂起事件到来后再由netpoll唤醒并放回调度队列。真正要抓住的是网络 I/O 的“阻塞感”主要发生在G这一层而不是把整个线程长期绑死在一次等待上。对业务代码来说执行流依然是顺序的对运行时来说这其实是一条挂起等待事件重新入队恢复执行的调度链。正因为有这条链路Go才能同时保留直观的编程模型和较高的并发承载能力。对业务代码来说像阻塞对运行时来说是挂起、等待、唤醒和重新调度。把前面四张图连起来看GMP并不是四个互不相干的小技巧拼在一起而是一套围绕“怎样让大量工作稳定流动”展开的整体设计。G、M、P先把角色拆开P的让渡解决阻塞扩散本地队列和工作窃取控制日常调度netpoll再把网络等待这类高频场景接回主线。走到这里调度器的主骨架已经基本立住了。接下来还需要补上的是另外两件事如果某个goroutine一直占着 CPU 不放运行时怎么把它抢下来而GOMAXPROCS又究竟在限制什么。忙循环为什么最终还是会被抢占抢占要解决的是没有停顿点时怎么办把前面的阻塞、队列和网络等待都看清之后最后一个很自然的问题变成了纯计算场景。假设有一个goroutine什么都不等不做系统调用不碰网络 I/O也不主动调用Gosched()只是一直埋头跑计算。按最粗糙的直觉看只要它一直不让别的任务是不是就只能一直等着如果真是这样前面讲的那些机制就只对“愿意停下来”的任务有效调度器的公平性会非常脆弱。只要有一个忙循环足够长其他 runnablegoroutine就会被压在后面垃圾回收和调度延迟也会一起变差。Go运行时不能把公平性建立在“业务代码最好自觉一点”这件事上所以它必须再补一层能力某个goroutine长时间占着 CPU 不放时运行时要能主动把它拉下来。这就是抢占要解决的问题。它针对的不是“阻塞了怎么办”而是**“根本没有阻塞也没有自愿让出时怎么办”。**前面几节讲的是调度器如何利用停顿点继续往前推这一节讲的则是当停顿点迟迟不出现时运行时如何自己制造一个。早期的Go调度更接近协作式思路运行中的goroutine往往要在函数调用、通道操作、系统调用这类位置自然交回执行权。只要代码路径里有这些点问题并不大可一旦遇到长时间运行的纯计算循环这种做法就会变得被动。后来运行时把抢占能力补强了核心思路也不复杂调度器不会无限期等某个goroutine自己让出 CPU而是会在它占用 CPU 过久时想办法把“该停一下了”这件事递到它当前的执行路径上。抢占不是从任意位置硬切走这里最容易混淆的地方是把抢占理解成“立刻从任何指令位置硬切走”。实际并不是这个意思。运行时仍然要考虑安全边界尤其要避免在不合适的位置把栈、寄存器和执行状态切乱。更贴近事实的说法是某个goroutine运行得太久时运行时会给它打上“应该被抢占”的标记并尽快把执行流引到合适的安全点让调度器重新接管对业务代码来说外部观感是“忙循环最终还是会被切下来”对运行时来说这件事仍然是在可控边界内完成的。把这层机制放回前面的主线里看调度器其实一直在做两件互补的事。只要某个goroutine因阻塞、等待或主动交回执行权而停下来就立刻利用这个空档安排别的工作如果某个goroutine长时间不停那就不能一直等它主动停下来而要由运行时自己推动一次切换。前者解决“有空档时怎么利用”后者解决“迟迟没有空档时怎么办”。看到这里GMP的日常运转逻辑已经比较完整了。再往下就该收束到另一个经常被说错的点了。很多人一提GOMAXPROCS第一反应就是“线程数上限”或者“最多开多少个并发任务”。这两个说法都不准确。真正决定同时能跑多少段Go代码的不是G的数量M的总数而是P的数量。GOMAXPROCS限制的不是goroutine数也不是线程总数它先不是任务总数和线程总数GOMAXPROCS这个名字很容易把人带偏。它看上去像某种“最大线程数”设置再加上平时又常和 CPU 核心数一起出现于是很多人顺手就把它理解成“线程池大小”或者“最多能开多少个并发任务”。可如果把前面的GMP关系图重新拿回来对照这个解释很快就会露出问题。先看最直接的一层GOMAXPROCS并不限制goroutine的数量。程序里完全可以同时存在几十万、几百万个goroutine其中绝大多数都只是处在等待、可运行或者未来某个时刻才会继续推进的状态。Go运行时并不会因为GOMAXPROCS8就只允许存在 8 个goroutine这两件事根本不在一个维度上。可以这样对照理解goroutine的数量对应的是系统里一共有多少份待处理工作GOMAXPROCS管的是同一时刻最多允许多少份Go用户代码并行执行再看第二层GOMAXPROCS也不等于线程总数上限。线程是M这一层的资源而M会因为系统调用阻塞、网络轮询、运行时后台任务等原因出现增减。某个时刻程序里实际存在的线程数可能比GOMAXPROCS大也可能远小于它。只要这些线程里有一部分正在阻塞运行时就可能需要额外的M去接手剩余的P把调度继续往前推。所以拿“线程总数”去理解GOMAXPROCS很容易把系统调用阻塞这类关键场景直接抹平。它真正限制的是可用并行度真正更准确的说法是GOMAXPROCS限制的是可用并行度。也就是同一时刻最多有多少个P可以让普通Go代码并行执行。因为每个P都像一张“执行许可证”M拿到P之后才能真正跑用户态Go代码所以P的数量才是那条决定并行度的硬边界。程序里可以有很多G在排队也可以有额外的M因阻塞和唤醒在系统里流动但真正同时向前推进的Go代码段数最终还是被P的数量卡住。GOMAXPROCS限制的不是任务总数也不是线程总数而是同一时刻能并行执行多少段Go代码。这样再回头看一个很常见的场景很多误解就能自动消掉。假设机器有 8 个逻辑 CPU而GOMAXPROCS也设成 8。即使程序里有一千个 runnablegoroutine同一时刻也只会有 8 份Go代码真正并行执行剩下的都要等待调度反过来即使系统里因为阻塞系统调用存在超过 8 条线程这也不代表有超过 8 段用户态Go代码在一起跑。线程可以多等待中的工作也可以更多但并行执行普通Go代码的“闸门”仍然在P这里。这也是为什么官方后来更明确地把GOMAXPROCS解释成 available parallelism。这个说法比“最大线程数”准确得多因为它直接点出了运行时真正要控制的对象不是线程总量不是任务总量而是并行推进用户态Go代码的能力上限。只要把这个口径放稳很多表面上绕来绕去的现象都会顺下来。goroutine可以很多因为待处理工作可以很多线程可以比GOMAXPROCS更多因为阻塞和后台任务会让运行时需要额外的M但真正同时跑起来的那一层始终要经过P。抢占和GOMAXPROCS要放在一起看把抢占和GOMAXPROCS放在一起看会发现它们补的是同一件事的两面GOMAXPROCS给并行度画出边界保证同一时刻最多能跑多少抢占保证在这条边界之内不会因为某个忙循环长期霸占执行权让别的任务永远挤不进来一个负责把入口收住一个负责让入口内部保持流动。正因为这两层同时存在Go调度器才不是“谁抢到线程谁一直跑”的粗放模型而是一套既限制并行度、又维持公平推进的运行时机制。再回头看开头那个最直观的现象答案其实已经很清楚了。程序里可以挂着成千上万个goroutine却不需要把线程数一起撑大不是因为运行时偷偷做了什么难以名状的优化而是因为它从一开始就没有把下面三件事混成一层工作线程并行执行资格G、M、P把角色拆开队列和窃取让工作流动起来P的让渡把阻塞控制在局部netpoll把等待中的网络 I/O 接回调度主线抢占和GOMAXPROCS则把公平性和并行度边界一起收住。走到这里GMP就不再只是三个缩写而是一套能解释日常Go并发行为的具体运行机制。Go的并发之所以能“看起来简单、跑起来不乱”靠的不是口号式的轻量线程而是G、M、P和整套调度机制一起工作。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2557923.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!