一句话总结
异步不是为了"更快地算",而是为了在等待(网络,磁盘,定时器)时不浪费资源.
当需要同时处理成千上万个大部分时间都在"等"的连接时,"一连接一线程"的模型会被内存和调度开销压垮--异步正是这个问题的答案.
从熟悉的线程模型出发
多线程章节里我们已经用线程写过并发.一个经典的网络服务器长这样:主线程接受连接,每来一个连接就开一个线程去处理.
|
这套写法直观,好懂,在连接数不多时工作得很好.问题出在规模.
一个线程的真实成本
线程不是免费的.每个操作系统线程都要付出两类成本:
| 成本项 | 量级 | 说明 |
|---|---|---|
| 栈内存 | 默认约 2 MB / 线程 | 仅栈空间,1 万线程就要约 20 GB 虚拟内存 |
| 创建/销毁 | 微秒级系统调用 | 高频短连接下累积可观 |
| 上下文切换 | 约 1–2 微秒 / 次 | 线程越多,内核调度器切换越频繁,有效算力被吃掉 |
当目标是同时维持 1 万,10 万个连接(业界称为 C10K,C100K 问题),"一连接一线程"直接撞墙:内存不够,调度器忙于切换而非干活.
一个聊天连接,一个长轮询请求,一个慢速下载--它们绝大多数时间不是在计算,而是在等待数据到达.
线程一旦发起阻塞读,就被挂起,什么也不做,却仍占着 2 MB 栈和一个调度槽位.资源被"等待"白白锁住,这才是浪费的根源.
两类任务:CPU 密集 vs IO 密集
要理解异步的适用边界,先分清两类工作负载:
| 类型 | 瓶颈在 | 典型场景 | 该用什么 |
|---|---|---|---|
| CPU 密集 | 计算本身 | 图像编码,加密,数值计算 | 多线程 / 多核并行 |
| IO 密集 | 等待外部 | 网络服务,数据库代理,爬虫 | 异步 |
CPU 密集像厨房里切菜:活儿实打实压在刀工上,多请几个厨师(线程/核)才更快.
IO 密集像餐厅服务员:大部分时间在等后厨出菜,一个服务员完全可以同时照看十几桌--关键不是多请人,而是别让他傻站着干等一桌.
异步要优化的,正是后者.
记住这条边界
异步擅长的是"大量并发的等待",不是"加速计算".纯 CPU 密集任务用异步不会更快,反而可能更慢(多了状态机开销).
本系列聚焦 IO 密集场景--这也是绝大多数后端服务的形态.
异步的核心思路:等待时把线程让出去
既然问题是"线程在等待时被白白占用",解法就呼之欲出:当一个任务开始等待 IO 时,不要阻塞线程,而是把线程让出来去执行其他已就绪的任务.
于是并发单元从"线程"下沉为更轻的"任务(task)":
|
少数几个线程(通常等于 CPU 核数),通过"谁就绪就跑谁,谁等待就切走"的调度,承载成千上万个任务.内存只花在任务状态上(每个任务可能只需几百字节),而非每个都背一个 2 MB 的栈.
这正是 Go 替我们做的事
写过 Go 的话,会觉得上面的描述很眼熟--因为 goroutine 就是这么干的.go f() 起的不是 OS 线程,而是轻量的 goroutine,由 Go runtime 调度到少量 OS 线程上;goroutine 一旦阻塞在 IO,runtime 自动把它挪走,让该线程去跑别的 goroutine.
Go 的 goroutine 和 Rust 的 async task,解决的是同一个问题,采用的是同一个思路:用少量线程承载海量"大部分时间在等"的并发任务.
区别只在于--Go 把这套机制焊死在语言运行时里,对外隐藏;Rust 把它做成可见,可替换的库.
Rust 的定位是系统级语言,要能运行在没有操作系统的嵌入式环境,也要让用户能为不同场景挑选最合适的调度器.把运行时硬塞进语言会违背"零成本,不为不用的东西付费"原则.
代价是:我们得理解这些零件--而这正是本系列要做的.
异步不是要取代线程
异步不是"线程的升级版",二者是互补关系.一个成熟的异步程序,内部仍然跑在多个线程上(异步运行时通常用一个线程池),CPU 密集的活也仍然该交给专门的线程去做(后面第 14 篇会讲 spawn_blocking).
- 异步解决"海量并发等待"--用任务调度避免线程空耗.
- 多线程解决"利用多核算力"--异步运行时自己也靠它.
- 真实服务往往两者叠加:多线程运行时 + 海量异步任务.
快速回顾
- 问题根源:阻塞线程在等待 IO 时仍占着内存(约 2 MB 栈)和调度槽,海量连接下被压垮(C10K).
- 任务分类:CPU 密集靠多核并行;IO 密集(大量并发等待)才是异步的主场.
- 核心思路:任务等待 IO 时让出线程,用少量线程调度海量轻量任务.
- 与 Go 同源:goroutine 与 async task 解决同一问题,同一思路;Go 隐藏机制,Rust 暴露机制.
- 互补关系:异步不取代线程,真实服务常是"多线程运行时 + 海量异步任务".
动手练习
- 估算内存:若每线程栈 2 MB,维持 5 万个阻塞连接需要多少内存?这在单机上现实吗?
- 分类判断:列举三个后端服务,判断各自属于 CPU 密集还是 IO 密集.
- 一句话解释:向同事解释"异步为什么能用 4 个线程处理 1 万个连接?"
- 边界思考:为什么对一个纯做大矩阵乘法的程序,改成异步几乎没有收益?
- Go 对照:回忆 Go 的 goroutine 阻塞在
conn.Read时,runtime 做了什么?把它和本篇的"让出线程"对应起来.