首页>后端>正文

IO异步与CPU密集任务的分野及底层机制

Feng 54 阅读 后端

先分清谁在真正出力

讨论异步之前,得先回答一个更朴素的问题:这段代码跑起来的时候,CPU 是在亲自搬砖,还是在等别人搬砖。

答案不同,异步的价值就天差地别。读文件、查数据库、调远端接口,这些活的主体是磁盘、网卡、另一台机器。CPU 发完指令就没事干了,剩下的时间纯属空转。反过来,视频转码、加解密、矩阵运算,活就是 CPU 自己干的,它从头到尾都在满负荷运转。

异步的本质是调度手段,不是加速手段。它能改变的是”CPU 空闲时去干点别的”,改变不了”CPU 算力总量”。

微波炉与土豆丝:两个场景的直觉

后厨热饭这个比喻很贴切。你按下微波炉开关,饭要转三分钟。同步的做法是站在炉门前盯着,三分钟里你什么都干不了。异步的做法是设个提醒转身去切菜,叮一声再回来取。

这里的关键不是”你变勤快了”,而是”微波炉本来就不需要你盯着”。热饭这件事的执行主体是微波炉,你只是发起者。

切土豆丝就完全是另一回事。刀在谁手里,活就在谁身上。你喊同事来替你切,自己跑去擦桌子,切完再交接回来——总刀数一刀没少,还多了两次交接的成本:说清楚要求、确认切完、把案板挪回来。这些交接在操作系统里叫上下文切换,保存寄存器、换栈、刷新 TLB,每一步都要花 CPU 周期。

更微妙的一点:你喊人的时候,那位同事本来也是闲着的。让他直接去擦桌子、你自己切完顺手起锅,才是总耗时最短的排法。异步在这里不但没赚,还倒贴。

DMA 让 CPU 从 IO 里脱身

IO 之所以能异步,硬件层面靠的是 DMA。代码调用 fs.readFile() 时,CPU 并不亲自搬运字节,它只是往磁盘控制器写一条描述符:源地址、目标内存、长度、完成后中断。写完就返回了。

真正的数据搬运由 DMA 控制器完成,磁盘到内存这条通路不经过 CPU 寄存器。搬完触发中断,内核把结果交给回调。整条链路可以概括成:CPU 外包 + 中断回收。

这里有个容易被忽略的细节。以 Node.js 为例,fs.readFile 走的是 libuv 线程池,默认大小 4,由环境变量 UV_THREADPOOL_SIZE 控制。也就是说文件 IO 在用户态并非完全无阻塞,线程池打满时后续请求会排队。而网络 IO 走 epoll/kqueue 这类事件通知机制,不占线程池。把这两类混为一谈,是很多性能问题排查跑偏的起点。

想验证线程池是否是瓶颈,可以压测时观察 pidstat -t -p <pid> 1 里各线程的 CPU 占用,若多个池线程长期满载而主线程空闲,基本可以确认。调大 UV_THREADPOOL_SIZE 前先确认磁盘 IOPS 余量,否则只是把排队从用户态挪到内核态。

CPU 密集任务被”异步”后的真实代价

单线程里跑一个长计算,整个进程假死。事件循环被占住,网络回调、定时器全部推迟。这不是异步能救的,只能靠拆分或换线程。

多线程环境里问题换了个形式。假设 8 核机器上跑 64 个计算线程,操作系统得不停轮转。每次切换要保存当前线程的寄存器现场、程序计数器,加载下一个线程的状态,还要处理缓存失效——新线程的数据大概率不在 L1/L2 里,得重新从内存拉。

这部分开销不是理论值。用 vmstat 1 看 cs 列(每秒上下文切换次数),配合 pidstat -w -p <pid> 1 看目标进程的 cswch/s(自愿切换)和 nvcswch/s(非自愿切换)。判定标准很直接:如果非自愿切换频繁、us 高但吞吐上不去,说明线程数超过了核数承载能力,该减线程而不是加。

经验上,计算密集型任务的线程数控制在核数附近(或核数+1)最稳。再往上加,切换开销会吃掉并行收益。

什么时候 CPU 任务也该”异步”

有一种场景异步确实必要,但目的不是吞吐,是响应性。

浏览器主线程跑一段大计算,页面直接卡死,滚动、点击全部无响应。这时用 Web Worker 把计算挪走,或者用 setTimeout 把大任务切成小片,总计算时间不会减少,甚至因为调度变长,但界面始终能响应。

服务端也有类似诉求。一个请求里要做重计算又要维持健康检查探针可用,就得把计算放到独立 worker 或子进程,主线程只负责收发。判断标准是:你的 SLA 里有没有”响应延迟”这一项,而不是”总吞吐”。有,就值得拆;没有,老老实实算完更划算。

异步不是免费的

异步模型在低并发场景下反而更贵。请求量小的时候,事件循环、回调管理、状态机的维护成本摊不开,代码复杂度却实打实上去了。一个日请求几千的内部服务,同步阻塞写法更易读也更好调。

强顺序依赖的链路同样不适合异步。A 的结果是 B 的输入,B 又是 C 的输入,硬拆成回调只会让错误处理和事务边界变得难以维护。这类场景用同步写法配合连接池,往往比异步链路更稳。

还有一个常被低估的风险:回调或 Promise 堆积。上游慢、下游猛发请求,未完成的任务对象会在内存里越攒越多。压测时盯住 process.memoryUsage().heapUsed(Node)或堆 dump 的增长曲线,如果随并发线性上涨且不回落,说明缺少背压控制。异步模型本身不提供背压,得自己加信号量或队列限流。

选型落到一句话

瓶颈在外部设备、CPU 大量空转,就用异步非阻塞把空窗期填满;瓶颈在算力本身,就用多进程或多线程做真并行,线程数贴着核数走。

异步解决的是”等待期间的浪费”,不是”计算本身的速度”。把这两件事分清,技术选型就不会走偏。

ID:6615

Feng
这位作者很神秘,还没有填写简介。