3. 协程与轻量级线程

聊到并发,大家第一反应往往是线程。但线程这东西,说实话,有点「重」。我早年做C++服务端时,一个线程默认栈空间1MB,开个几千个线程,内存先扛不住了。后来接触了协程,才真正体会到什么叫「轻量」。这一章,我们就来把协程的底裤扒干净。

3.1 协程的基本原理

协程,本质上是一个用户态的、可暂停、可恢复的执行流。它不像线程那样由操作系统内核调度,而是由应用程序自己控制。

你可以把协程想象成一个「可以随时暂停的函数」。调用它,执行到一半,你让它停,它就停;你让它继续,它就从刚才停下的地方接着跑。这个暂停和恢复的过程,就是协程的核心操作——yield 和 resume。

核心要点:协程的切换不涉及系统调用,没有用户态到内核态的切换开销。说白了,就是纯用户态下的「函数调用」级别的切换,代价极低。

我记得第一次看Lua的协程实现时,被它的简洁震惊了。一个协程就是一个独立的栈,切换时只需要保存和恢复几个寄存器。对比线程的上下文切换,那简直是自行车和赛车的区别。

3.2 协程与线程的对比

这里我直接给一张对比表,大家一目了然:

对比维度 线程 协程
调度者 操作系统内核 应用程序(用户态)
切换开销 高(系统调用+上下文切换) 极低(函数调用级别)
栈空间 默认1MB~8MB 可小至几KB,动态增长
并发数量 几千个就吃力 轻松数十万甚至百万
数据竞争 共享内存,需加锁 天然避免(单线程内串行)
编程模型 抢占式 协作式

你想想看,线程切换时,内核要保存一大堆寄存器、TLB、缓存状态,还要处理调度队列。而协程切换呢?说白了就是保存几个寄存器和栈指针。我在项目中做过压测,同样是10万个并发任务,用线程直接OOM,用协程跑得稳稳的。

注意:协程是协作式调度,这意味着如果某个协程不主动让出CPU(比如死循环),其他协程就永远没机会执行。我曾经在线上遇到过这种坑——一个协程里写了while(true)没加yield,整个服务卡死。嗯,血的教训。

3.3 goroutine与纤程

说到协程,就绕不开Go语言的goroutine。goroutine是Go对协程的一种实现,但它比传统协程更进一步。

传统协程(比如Lua、Python的协程)是显式的——你需要手动写yield来让出控制权。而goroutine是隐式的——Go运行时会在合适的时机自动暂停和恢复goroutine。你不需要关心yield,写起来就像写普通函数一样自然。

我个人习惯把goroutine称为「协程的Pro版」。它有几个关键设计:

  • 动态栈:初始栈只有2KB,按需增长,最大可达1GB。这比线程的固定大栈灵活得多。
  • GMP模型:G(goroutine)、M(machine,即系统线程)、P(processor,逻辑处理器)。这是Go调度器的核心。
  • 工作窃取:空闲的P会从其他P的队列中偷goroutine来执行,实现负载均衡。

至于纤程(Fiber),它其实是Windows下的概念,本质上也是用户态线程。我记得在Windows上用过Fiber API,那API设计得真叫一个反人类。相比之下,goroutine的体验就好太多了。

我的建议:如果你在选型,优先考虑语言原生支持的协程方案。Go的goroutine、Kotlin的协程、Java的虚拟线程(Project Loom),都比自己造轮子靠谱。我曾经在C++里用Boost.Coroutine,那调试体验,唉,不提了。

3.4 协程的调度模型

协程的调度模型,说白了就是「谁来决定哪个协程什么时候跑」。主要有三种:

3.4.1 对称模型

所有协程地位平等,任意协程都可以把控制权交给任意其他协程。这种模型灵活,但容易写出意大利面条式的代码。Lua的协程就是这种。

3.4.2 非对称模型

协程之间有明确的调用关系。一个协程只能把控制权交还给它的调用者。这种模型更结构化,Python的协程就是这种。我个人更喜欢这种,代码逻辑清晰,不容易乱。

3.4.3 混合模型(GMP)

Go的调度模型就是典型的混合模型。我画了一张图,帮大家理解:

Go GMP调度模型 M1(系统线程) M2(系统线程) M3(系统线程) P1(逻辑处理器) P2(逻辑处理器) P3(逻辑处理器) 本地队列 [G1, G2, G3] 本地队列 [G4, G5] 本地队列 [G6] 全局队列 [G7, G8, G9, G10] 工作窃取 M(系统线程) P(逻辑处理器) G(协程) 全局队列

这张图展示了Go调度器的核心流程:

  • 每个M(系统线程)绑定一个P(逻辑处理器)
  • 每个P有自己的本地运行队列,存放待执行的G(goroutine)
  • 当某个P的本地队列空了,它会从全局队列或其他P的队列中「偷」G来执行
  • 系统调用阻塞时,M和P会解绑,P带着队列交给其他空闲的M

这个模型最妙的地方在于,它把M:N的映射关系(M个系统线程调度N个协程)做得非常优雅。你想想看,如果让你自己实现一个用户态线程调度器,光处理系统调用阻塞就够你喝一壶的。Go通过P这个中间层,把问题拆解了。

避坑指南:我曾经在项目中遇到过一个诡异的问题——goroutine数量上到10万后,GC停顿时间暴涨。后来发现是因为goroutine太多,GC的标记阶段需要遍历所有goroutine的栈。解决方案是控制goroutine数量,或者使用goroutine池。嗯,任何技术都有边界,协程也不例外。

最后说一句,协程不是银弹。它适合I/O密集型的场景,比如网络服务、消息处理。如果是CPU密集型的计算任务,协程的优势就不明显了,因为计算本身不涉及等待,协程的切换反而成了额外开销。选型时,一定要想清楚你的场景。

总结一下:协程是用户态的轻量级执行流,切换开销极低,适合高并发I/O场景。Go的goroutine通过GMP模型实现了高效的M:N调度。理解这些,你就掌握了现代并发编程的基石。