阶段一 · 理解异步

为什么需要异步

一句话总结

异步不是为了"更快地算",而是为了在等待(网络,磁盘,定时器)时不浪费资源.
当需要同时处理成千上万个大部分时间都在"等"的连接时,"一连接一线程"的模型会被内存和调度开销压垮--异步正是这个问题的答案.

从熟悉的线程模型出发

多线程章节里我们已经用线程写过并发.一个经典的网络服务器长这样:主线程接受连接,每来一个连接就开一个线程去处理.

use std::net::TcpListener;
use std::thread;

fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(move || {
handle_connection(stream); // 内部会阻塞读写
});
}
}

这套写法直观,好懂,在连接数不多时工作得很好.问题出在规模.

一个线程的真实成本

线程不是免费的.每个操作系统线程都要付出两类成本:

成本项 量级 说明
栈内存 默认约 2 MB / 线程 仅栈空间,1 万线程就要约 20 GB 虚拟内存
创建/销毁 微秒级系统调用 高频短连接下累积可观
上下文切换 约 1–2 微秒 / 次 线程越多,内核调度器切换越频繁,有效算力被吃掉

当目标是同时维持 1 万,10 万个连接(业界称为 C10K,C100K 问题),"一连接一线程"直接撞墙:内存不够,调度器忙于切换而非干活.

一个聊天连接,一个长轮询请求,一个慢速下载--它们绝大多数时间不是在计算,而是在等待数据到达.

线程一旦发起阻塞读,就被挂起,什么也不做,却仍占着 2 MB 栈和一个调度槽位.资源被"等待"白白锁住,这才是浪费的根源.

两类任务:CPU 密集 vs IO 密集

要理解异步的适用边界,先分清两类工作负载:

类型 瓶颈在 典型场景 该用什么
CPU 密集 计算本身 图像编码,加密,数值计算 多线程 / 多核并行
IO 密集 等待外部 网络服务,数据库代理,爬虫 异步

CPU 密集像厨房里切菜:活儿实打实压在刀工上,多请几个厨师(线程/核)才更快.
IO 密集像餐厅服务员:大部分时间在等后厨出菜,一个服务员完全可以同时照看十几桌--关键不是多请人,而是别让他傻站着干等一桌.
异步要优化的,正是后者.

记住这条边界

异步擅长的是"大量并发的等待",不是"加速计算".纯 CPU 密集任务用异步不会更快,反而可能更慢(多了状态机开销).

本系列聚焦 IO 密集场景--这也是绝大多数后端服务的形态.

异步的核心思路:等待时把线程让出去

既然问题是"线程在等待时被白白占用",解法就呼之欲出:当一个任务开始等待 IO 时,不要阻塞线程,而是把线程让出来去执行其他已就绪的任务.

于是并发单元从"线程"下沉为更轻的"任务(task)":

线程模型(阻塞)                   异步模型(非阻塞)
────────────── ──────────────
任务 A ── 读网络 ─[线程在此干等] 任务 A ── 读网络 ─[未就绪]→ 让出
任务 B ── 等待线程空闲... 任务 B ── 立刻在同一线程上跑
任务 A 数据到了 → 被唤醒,继续
N 个任务 = N 个线程 少量线程 承载 上万个任务

少数几个线程(通常等于 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 暴露机制.
  • 互补关系:异步不取代线程,真实服务常是"多线程运行时 + 海量异步任务".

动手练习

  1. 估算内存:若每线程栈 2 MB,维持 5 万个阻塞连接需要多少内存?这在单机上现实吗?
  2. 分类判断:列举三个后端服务,判断各自属于 CPU 密集还是 IO 密集.
  3. 一句话解释:向同事解释"异步为什么能用 4 个线程处理 1 万个连接?"
  4. 边界思考:为什么对一个纯做大矩阵乘法的程序,改成异步几乎没有收益?
  5. Go 对照:回忆 Go 的 goroutine 阻塞在 conn.Read 时,runtime 做了什么?把它和本篇的"让出线程"对应起来.