协程:一场精妙的用户态中断游戏,我们为什么抛弃了线程?

上周线上事故复盘——一个订单服务突然 OOM,堆 dump 一看,飘着十几万个挂起的协程。说实话,我当时的第一反应不是懊恼,而是一种诡异的兴奋:看,这就是不搞懂调度器本质的下场。那帮小孩儿把 launch 当线程用,在循环里无脑开协程,调度器队列越积越长,最终内存被协程上下文对象撑爆。他们不理解,协程根本不是线程的平替,而是一种需要被精确管理的**逻辑执行单元**。

协程内存泄漏事故堆栈图
协程内存泄漏事故堆栈图


协程这个东西,本质是在玩一个古老的把戏:**用户态协作式多任务**。早在上个世纪的操作系统里就出现了,后来因为抢占式调度统一天下,协作式被扫进了历史的故纸堆。直到高并发时代,人们发现内核线程太他妈重了——创建一个线程,内核栈就要占掉 1~2 MB 内存,上下文切换从用户态到内核态再到用户态,耗时在微秒到十几微秒不等。协程呢?一颗颗在堆上分配的、几 KB 大小的对象,切换只涉及寄存器状态的保存和恢复,纯用户态操作,快了两个数量级。这不算什么新鲜事,但很多人忽略了一个关键点:**协程把上下文切换从抢占变成了显式的挂起点**。这意味着调度权完全交到了程序员手里——你做不好,就等于埋下了一颗定时炸弹。

调度器:你的协程运行在哪个赌场?

最经典的协程调度器模型其实是个线程池加任务队列。你调用 launch,创建了一个协程上下文——里面装着状态机、挂起函数链、续体(Continuation)对象——然后把它往调度器的队列里一扔。调度器背后的若干工作线程(Worker)不断从队列里取任务执行。当协程遇到挂起(比如 delay、网络请求),状态机记录当前执行点,把当前线程让出来,去取下一个任务。这里面的工程美学在于:**我们用栈式协程(Stackful)还是无栈协程(Stackless)?** Kotlin 选了无栈协程,意味着挂起点必须在编译期标注,编译器把挂起函数(suspend function)编译成一个大大的状态机,每个挂起点是一个状态。这种方案几乎零开销,因为不需要单独申请一块运行时栈,所有局部变量在编译时就被放在状态机的字段里。

Kotlin协程状态机编译前后对比
Kotlin协程状态机编译前后对比


不过话说回来,这种编译期魔法带来的问题也很直接:如果你在一个普通的 for 循环里调用一个挂起函数,编译器会把循环展开成状态机的一个个步骤——一旦循环次数很多,生成的类会膨胀到吓人。我曾调试过一个 lambda 里循环 1000 次调用 delay(1) 的 case,结果字节码体积暴增,类加载时间都变长了。解决方案?用 yield() 把循环打断,或者,干脆不要傻傻地在协程里做密集计算——协程本来就不是为了 CPU 密集型任务设计的。

百万并发:一场精心策划的骗局

百万并发:一场精心策划的骗局
百万并发:一场精心策划的骗局
圈子里总爱拿协程的“百万并发”说事儿。确实,我在自己的开发机上做过压测:用 Kotlin 协程开 100 万个协程,每个协程 delay 1 秒,内存占用大约 600 MB——同一台机器开 100 万个线程?我的 Mac 直接死给你看。但这能说明协程可以无脑扛百万并发吗?扯淡。这个测试本质上是开了 100 万个定时任务,大部分时间在挂起,调度器线程池只有 4 个线程(Dispatchers.Default)。业务代码呢?你如果真的在 100 万个协程里处理数据库查询,后台连接池直接被打爆,线程池饥饿,调度器队列塞满,CPU 飙升,最终雪崩。所以更真实的对比数据应该是:在 4 核 8 GB 的容器里,传统 Spring Boot 线程池(200 线程)处理中等 Web 请求(含 50 ms DB 查询),QPS 大约在 1200 左右,P99 延迟 300 ms;改用协程实现全异步化,同样资源,QPS 可以拉到 4500+,P99 降到 80 ms。关键在于**非阻塞 IO 的深度整合**。协程的价值不是替换线程,而是让异步代码写起来像同步,同时让出线程等待 IO 完成,线程资源只用在真正需要 CPU 的地方。

三个坑,踩进去就是血

三个坑,踩进去就是血
三个坑,踩进去就是血
第一个坑:协程作用域泄露。 我最怕看到在 ViewModel 里开协程不绑定 lifecycle,或者用 GlobalScope.launch 随手搞个后台任务。协程的 structured concurrency(结构化并发)是最好用的保命符。规则很简单:创建一个 SupervisorJob 作为根,所有子协程挂在这个 job 下;当需要取消时,只要 cancel 父 job,子树全部清理干净。在我现在的项目中,所有业务级协程统一用 Application 级别的 CoroutineScope,并注入 error handler。别嫌麻烦,线上一次泄漏排查半天,真不值得。
第二个坑:挂起函数滥用。 很多人觉得 suspend 函数很酷,恨不得所有函数都加上。但 suspend 函数调用的开销比普通函数高得多——你要分配 Continuation 对象,要调用 resumeWith,要经过状态机跳转。如果这个函数根本没有真正的异步操作,那纯粹是浪费。我的经验:只在你需要调用其他挂起函数,或者需要切线程的时候,才声明为 suspend。对于纯计算函数,用 inline + crossinline 可能会更好。
第三个坑:调度器死锁。 你绝对不想在占有某个锁的时候调用挂起函数,然后又在同一个调度器的线程里等待这个锁被释放。比如在 runBlocking(Dispatchers.IO) 里持有一个 synchronized 锁,又去调用一个挂起函数,这个挂起函数尝试切到另一个线程但却用到了同一个 IO 调度器的线程池,极有可能造成线程饥饿死锁。解决方案:避免在协程中使用重量级锁,改用协程友好的方案——Mutex 或 Semaphore,它们会在锁不可获取时挂起协程而不是阻塞线程。

协程,说到底是一项需要带着敬畏去使用的技术。它不是魔法,只是一套设计精密的用户态调度方案。理解了它的本质——状态机、调度器、续体——那些花里胡哨的用法自然豁然开朗。而那些还在无脑开线程池的同事们,真该看看凌晨 3 点的协程泄漏 dump 文件,比任何教程都来得提神醒脑。
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:协程:一场精妙的用户态中断游戏,我们为什么抛弃了线程?
文章链接:https://m.lfdjt.com/info_23_7873.html