3、协程与轻量级线程:协程的基本原理、协程与线程的对比、goroutine与纤程、协程的调度模型
聊到并发,大家第一反应往往是线程。但线程这东西,说实话,有点「重」。我早年做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调度器的核心流程:
- 每个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调度。理解这些,你就掌握了现代并发编程的基石。
想要更多资料,联系可可