并行, 异步和并发
TL;DR
这三个词经常被混着用,但它们回答的是三个不同的问题:并发问的是「同时有几个任务在飞」,并行问的是「有几个核真的在同时执行」,异步问的是「等一个慢操作的时候,要不要把线程也一起停住」。
本文先用手写一个有栈协程把「让出」这件事拆到底层(切换到 rsp 和几个 callee-saved 寄存器,实测 7.8ns 一次),解释为什么 ucontext 慢到 162ns,再引出 tokio 的思路:既然编译器知道哪里可能暂停,就不需要保存栈——async fn 被编译成一个状态机,executor 反复 poll 它,reactor 用 epoll 把上万个 fd 的等待合并成一次。最后给出实测数据和一份取舍清单。
整篇文章其实是一条链,每一步都在回答上一步留下的问题:
线程太贵 → 能不能自己让出? → 让出时保存什么? → 那就写个协程试试
→ 能跑,但每个协程要一块栈 → 栈能不能不要? → 编译器把状态搬进结构体
→ 谁来反复调用这个结构体? → executor
→ 它怎么知道可以继续了? → waker
→ 谁负责触发 waker? → reactor(epoll)
→ 一个核不够怎么办? → 多 worker + 本地队列
后面每一节的开头都会标出「这一步要回答的问题」,卡住的时候可以先跳回去看上一节留下了什么。
三个词,三件事
这一步要回答的问题:为什么「同时做多件事」需要三个词?因为它们描述的是三件不同的事,而成本模型完全不同。概念没分清的时候,你以为遇到的是性能问题,其实一开始就选错了模型。
先说结论:
| 概念 | 回答的问题 | 依赖什么 |
|---|---|---|
| 并发 (concurrency) | 同时在推进几个任务 | 程序结构 |
| 并行 (parallelism) | 有几个任务在同一时刻执行 | 核的数量 |
| 异步 (asynchrony) | 等待时线程要不要阻塞 | 编程模型 + 运行时 |
三个词互相独立,可以任意组合:
- 单线程事件循环(Redis):并发 + 异步,不并行。一个核,几万个连接轮流推进。
- 四个线程各算各的矩阵分块:并行,不必异步。谁都不用等谁。
- 四个线程各自
read()一个 socket:并发 + 并行,但同步。每个线程都实打实地睡在内核里。 tokio默认配置:三个都有。多核跑多个 worker,每个 worker 上多个任务交替推进,等待全交给epoll。
一个常被忽略的点:异步不是「更快」。它只是把「等」的成本从「一个线程」降到「一条队列记录」。如果每件事都是 CPU 计算,异步只会多出状态机开销,一点便宜都占不到。
阻塞的成本
这一步要回答的问题:线程明明能解决问题,为什么还要协程?先别急着接受「线程重」这个说法,把它称一下。
最朴素的服务器是这样写的:
while (1) {
int fd = accept(listen_fd, NULL, NULL);
pthread_create(&t, NULL, handle, (void *)(intptr_t)fd); // 一个连接一个线程
}
能跑,但撑不住一万个连接。原因不在 accept,而在「等待」这件事被绑定在线程上:
- 每个线程要一块栈。glibc 默认 8MB 虚拟地址空间,就算改成 64KB,一万个也是 640MB 起步(真正驻留的没这么多,见下表);
- 每个线程是内核的调度实体,切换要进内核、换页表、刷 TLB;
- 大部分时间这些线程什么都没干,只是在
read()里睡着。
实测(本机,12 核):
| 规模 | 64KB 栈的线程 RSS | 备注 |
|---|---|---|
| 1,000 | 10.1 MB | |
| 10,000 | 84.6 MB | |
| 18,000+ | — | pthread_create 开始失败 |
线程切换的代价,用两个线程 ping-pong 测一下(mutex + condvar,含 futex 系统调用):
switches=200000 per_switch=1960.3 ns
差不多 2µs 一次。这个数字里包含了系统调用和调度器,不是纯粹的寄存器保存,但它就是「阻塞式等待」的真实单价。
于是需求很明确:能不能在用户态自己决定「让出」,而不是交给内核? 这就是协程。
手写有栈协程
这一步要回答的问题:不经过内核,一个程序怎么「自己让出」?要让出,就得先知道「让出」在机器层面意味着什么。
保存什么
「暂停一个函数,之后再从原地继续」——这句话翻译成机器层面,就是保存三样东西:
- 栈:局部变量、返回地址都在里面;
rsp:指向栈顶,恢复它等于恢复整个调用链;- callee-saved 寄存器:
rbx、rbp、r12~r15。caller-saved 的不用管,因为切换发生在一次函数调用里,调用者本来就不指望它们活着。
有栈协程的做法因此非常直白:给每个协程一块自己的栈,切换时把寄存器压到自己栈上、把 rsp 存进一个变量,然后把 rsp 换成对方的、把对方的寄存器弹出来。
ucontext 版
glibc 提供了现成的接口:
#define N 5000000
static ucontext_t main_ctx, co_ctx;
static char stack[64 * 1024];
static void co_entry(void) {
for (;;) {
swapcontext(&co_ctx, &main_ctx); // 让出
}
}
int main(void) {
getcontext(&co_ctx);
co_ctx.uc_stack.ss_sp = stack;
co_ctx.uc_stack.ss_size = sizeof(stack);
co_ctx.uc_link = &main_ctx;
makecontext(&co_ctx, co_entry, 0);
for (long i = 0; i < N; i++)
swapcontext(&main_ctx, &co_ctx); // 恢复
}
跑 500 万次来回:
per_switch=161.70 ns
能用,但比预期慢一个数量级。为什么?
思路:看到慢数字,第一反应不应该是「换个库」,而是问「这 162ns 花在哪了」。手上正好有两个工具:
strace看系统调用,objdump看指令。
162ns 花在哪
先看系统调用:
$ strace -c -e trace=rt_sigprocmask ./ctx2 # 1000 次 swapcontext
% time seconds usecs/call calls errors syscall
100.00 0.001037 0 2001 rt_sigprocmask
1000 次切换,2001 次 rt_sigprocmask——每个 swapcontext 两次。再看反汇编就明白了:
0000000000052b00 <swapcontext@@GLIBC_2.2.5>:
...
mov %rax,0xa8(%rdi) # 保存返回地址
lea 0x8(%rsp),%rcx
mov %rcx,0xa0(%rdi) # 保存 rsp
...
fnstenv (%rcx) # 连 x87 状态都存
stmxcsr 0x1c0(%rdi) # 还有 MXCSR
mov $0x2,%edi # SIG_SETMASK
mov $0x8,%r10d
mov $0xe,%eax # rt_sigprocmask
syscall
...
fldenv (%rcx) # 恢复 x87 / SSE 状态
ldmxcsr 0x1c0(%rdx)
mov 0xa0(%rdx),%rsp
...
ret
除了保存通用寄存器,它还要:
- 存取信号掩码(两次系统调用,因为要先
SIG_BLOCK, NULL查询当前掩码再设置对方的); - 存取 x87 / SSE 的控制状态(
fnstenv、stmxcsr); - 走一遍完整的
ucontext_t结构(近 1KB 的字段)。
这些在 1970 年代的设计里是必要的——协程之间确实该有独立的信号掩码和浮点环境。但如果你只是想要「切一下栈」,全是额外开销。
手写 7 行汇编
把上面那些全去掉,只保留最小集合:
__asm__(
"co_switch:\n"
" push %rbp\n"
" push %rbx\n"
" push %r12\n"
" push %r13\n"
" push %r14\n"
" push %r15\n"
" mov %rsp, (%rdi)\n" /* *save_sp = rsp */
" mov %rsi, %rsp\n" /* rsp = new_sp */
" pop %r15\n"
" pop %r14\n"
" pop %r13\n"
" pop %r12\n"
" pop %rbx\n"
" pop %rbp\n"
" ret\n"
);
新协程的启动用一个假栈帧搞定:在栈顶压一个返回地址占位、压入口函数地址、再补 6 个 0 当作「已保存的寄存器」,然后让 co_switch 的 ret 直接跳进入口函数。
per_switch=7.75 ns
7.8ns vs 162ns,差 20 倍,差别全在上面那堆「顺便做的事」上。
顺便说明一件事:协程切换本身便宜到几乎可以忽略——6 次 push、6 次 pop、一次 ret,比一次函数调用贵不了多少。真正贵的东西(系统调用、调度器、缓存失效)在这段代码里一个都没有。
三者对比
| 方式 | 一次切换 | 切换时发生了什么 |
|---|---|---|
| 手写汇编协程 | 7.8 ns | 12 条指令,不出用户态 |
ucontext | 162 ns | 上述 + 2 次系统调用 + 浮点环境 |
| 线程 ping-pong | 1960 ns | 陷入内核、调度器选下一个、可能换页表 |
这张表是理解 async 的起点:「并发」的瓶颈从来不是切换,而是「每个并发单元要占多少资源」。线程贵在栈和内核调度实体,协程贵在……也要一块栈。
栈的问题
手写版跑得飞快,但它有三个绕不过去的问题:
- 每个协程一块栈。64KB × 100 万 = 64GB 虚拟内存。栈大小还得提前猜:猜小了爆栈,猜大了浪费。
- 栈不能跨暂停点保存。这一点在 Rust 里是致命的:
async fn返回Pending时栈帧就销毁了,函数已经返回,哪来的栈给你保存? - 跨暂停点的借用无法静态检查。编译器看到的是一个会「中途返回、之后再进来」的函数,它没法证明你手里的
&mut在暂停期间没被别人动过。
这一步要回答的问题:上面三个硬伤里,前两个都指向同一个东西——栈。如果干脆不要栈,状态还能存在哪?
所以在 Rust 里,这条路走不通。要换一种「暂停」的定义。
tokio:不要栈
这一步要回答的问题:栈的作用是「保存函数执行到哪了」。如果编译器能在编译期就知道「哪几处可能暂停」,那么「执行到哪」能不能用一个整数表示?
关键转向
「暂停」不一定要保存栈。如果编译器知道哪里可能暂停,它可以在编译期就把函数切成若干段,每一段的状态(局部变量)搬进一个结构体,再用一个整数记住「现在走到第几段」。
于是协程从「一块栈 + 一个 rsp」变成了「一个小结构体 + 一个 match」。这就是无栈协程。
// 你写的
async fn fetch(id: u32) -> String {
let a = step_one(id).await; // 暂停点 1
let b = step_two(a).await; // 暂停点 2
b
}
// 编译器大致生成的(示意)
enum Fetch {
Start(u32),
WaitOne { id: u32, fut: StepOne },
WaitTwo { a: String, fut: StepTwo },
Done,
}
调用一次 poll,就是「从当前变体往下跑,直到下一个暂停点,返回 Pending,并把自己改成下一个变体」。函数返回了,栈帧没了,但状态还活着——因为它不在栈上,在堆上的那个结构体里。
大小 = 最大那个状态。实测:
// yield_now:构造一个「第一次 poll 返回 Pending」的 future
async fn small() { let _x = 1u64; yield_now().await; }
async fn big() { let buf = [0u8; 4096]; yield_now().await; println!("{}", buf[0]); }
small=2 bytes, big=4098 bytes
big 里那个 4096 字节的数组必须活过 await,所以它被搬进了状态机,future 就变成 4KB。反过来,如果不跨 await,它仍然待在栈上,future 还是很小——await 的位置决定了内存布局,这是写 async Rust 时最该有的直觉。
Future:问一次
标准库只给了一个 trait:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Poll 只有两种结果:Ready(T) 或 Pending。
思路:注意这个签名里没有任何「等待」「调度」「线程」的字眼。它只描述了一件事:问一次,要么好了,要么没好的。那么问题来了——一个已经返回的函数,凭什么还能被再次调用,并且从上次停下的地方继续?
Pending 背后有一条契约,比签名重要得多:
返回
Pending之前,必须保证「将来某个时刻 waker 会被调用」;否则这个任务就永远睡着了。
这条契约是 async 能成立的全部基础。它把「等待」拆成了两半:
- 任务侧:我跑不动了,把 waker 交给别人,然后返回;
- 事件侧:条件满足时,调用这个 waker。
至于中间发生了什么、谁在等、等了多久,Future 一概不管。
40 行 executor
把上面的契约实现出来,就是一个能跑的 runtime 骨架:
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Noop;
impl Wake for Noop {
fn wake(self: Arc<Self>) {} // 真的 runtime 里:把任务塞回队列 + 叫醒 worker
}
struct YieldNow(bool);
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.0 { return Poll::Ready(()); }
self.0 = true;
cx.waker().wake_by_ref(); // 「我准备好了」,请再 poll 我一次
Poll::Pending
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
let waker = Waker::from(Arc::new(Noop));
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut); // 钉在堆上,地址不能变
loop {
if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
return v;
}
}
}
fn main() {
let n = 10_000_000;
block_on(async move {
for _ in 0..n {
YieldNow(false).await; // 每次都返回 Pending,被重新 poll
}
});
}
测一下空转成本:
polls=10000000 per_poll=8.98 ns
9ns 一次 poll,和手写协程的 7.8ns 几乎一样。这并不奇怪:两者本质都是「保存少量状态 + 跳转」,只不过一个保存的是寄存器和 rsp,另一个保存的是状态机变体和几个字段。
注意这个 block_on 是忙等的:waker 什么都不做,只能靠 loop 反复问。真实 runtime 不会这么干——它需要 Waker 真的能把任务叫醒,而这就需要一个能挂起线程的机制。
Waker:唤醒回调
这一步要回答的问题:任务让出之后就消失了,谁有资格、又靠什么把它叫回来?注意
poll的签名里给了Context——它就是答案的入口。
Waker 长得像一个胖指针:一个数据指针 + 一张虚表(clone / wake / wake_by_ref / drop)。用 C 的思维看,它就是「回调函数 + 上下文」:
struct waker {
void *data; // 通常是任务对象的指针
void (*wake)(void *data); // 唤醒:把任务塞回队列
struct waker (*clone)(void *data); // 引用计数 +1
void (*drop)(void *data); // 引用计数 -1
};
为什么必须是这种手写虚表的形式?因为 Future 是泛型的,poll 的调用方不知道具体类型,但 reactor 拿到 Waker 时必须能凭空唤醒一个任务。Waker 就是这个「类型擦除过的回调」。
一次完整的往返是这样的:
任务 A: poll() 发现 socket 没数据
→ 把自己的 waker 登记到 fd 对应的槽位
→ 返回 Pending,任务从队列里消失
epoll: epoll_wait 醒来,发现 fd 可读
→ 取出该 fd 上登记的 waker
→ waker.wake():把任务 A 塞回运行队列,unpark 一个 worker
worker: 从队列取出 A,poll() 继续跑,这次拿到 Ready
「异步」的物理实现就是这条链路:不是不等,而是把「等」从线程身上挪到 epoll 的等待队列里,一个线程可以替上万个 fd 等。
Reactor:合并等待
同步模型下,一万个连接就是内核里一万个等待队列项,每个都绑着一个线程。epoll 把这堆东西收进一个实例:
epoll_ctl注册 fd,告诉内核「这个 fd 的可读/可写事件我要」;- 一个线程
epoll_wait,一次返回一批就绪事件; - 事件回来之后,runtime 按 fd 找到登记的 waker,逐个
wake。
同样的思路也用在定时器上:所有 sleep 的 deadline 放进一个按时间排序的结构(tokio 用的是哈希时间轮),epoll_wait 的超时取最近的那个 deadline,醒来后把到期的任务批量唤醒。所以 tokio::time::sleep 不占线程,只占一个槽位。
有一类 IO 是这套机制覆盖不了的:普通文件。Linux 上文件的 read 永远返回 ready(因为页缓存要么命中要么缺页,没有「等数据包」这个过程),epoll 对它没用。所以 tokio 的文件 IO 实际上是扔给一个阻塞线程池去做的——「异步文件 IO」在 Linux 上本质还是线程,只是被藏起来了。
多线程:本地队列与偷取
这一步要回答的问题:要加核,最直接的做法是共享一个队列,但那样每个任务都要抢一次原子变量。能不能既加核,又不抢?
单线程 executor 跑满一个核就到头了。要多核,直觉做法是「一个全局任务队列,所有 worker 抢」——但每次任务切换都要碰一次全局原子变量,核一多就是瓶颈。
tokio 的思路是分散:
- 每个 worker 有自己的本地双端队列:自己产出的任务压本地,取也从本地取;
- 本地空了,就去别的 worker 队列的另一端偷,一次偷一半(减少偷的频率,也减少冲突);
- 本地队列满了,倒一半到全局队列,让空闲的 worker 有活干;
- 每个 worker 还留一个最近唤醒任务的快捷位置:A 唤醒 B 时,B 直接插到 A 所在 worker 的前面先跑。这对「请求-响应」型负载很关键——B 要用的数据大概率还在 A 的 L1/L2 里。
- 没活干的 worker 睡在条件变量(底层是 futex)上,有新任务时
unpark一个。
默认线程数 = CPU 核数。这个选择值得琢磨:IO 密集时靠的不是加线程,而是每个任务尽快让出。线程数超过核数只会增加切换,不会增加吞吐。
100 万个任务
思路:这里的数字要和前面的推理对得上。如果 100 万个任务需要 100 万个线程,那说明某一层还没想通。
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let n: usize = std::env::args().nth(1).unwrap().parse().unwrap();
for i in 0..n {
tokio::spawn(async move { sleep(Duration::from_millis(500 + i % 100)).await; });
}
sleep(Duration::from_millis(50)).await; // 等任务都注册完
println!("tasks={n} threads={} rss={}KB", threads(), rss_kb());
}
本机 12 核:
| 任务数 | 线程数 | RSS | 每任务 |
|---|---|---|---|
| 1,000 | 13 | 3.7 MB | ~3.7 KB(被进程基线主导) |
| 10,000 | 13 | 7.0 MB | ~700 B |
| 100,000 | 13 | 41.6 MB | ~416 B |
| 1,000,000 | 13 | 386 MB | ~386 B |
(13 = 12 个 worker + 1 个阻塞在 runtime 上的主线程。)
对照上面那张线程表:线程数从 1,000 涨到 1,000,000,内核里的线程数一个都没变。省下来的就是每任务一块栈。但注意 386 字节不是免费的——任务对象、状态位、引用计数、队列节点都在里面,100 万个依然要 386MB。
所以「async 能开一百万个任务」是真的,「async 免费」是假的。它只是把成本从「8MB 虚拟地址空间」压到了「几百字节堆内存」。
自己走一遍
上面的每个结论都能用一个很小的程序验证,顺序比结论重要:
- 先量线程。空线程的栈开销(看
VmRSS/VmSize),两个线程 ping-pong 的一次切换。有了这两个数字,「线程重」才不是一句空话。 - 再写最小协程。
ucontext版本先跑通,然后用 6 条push/ 6 条pop手写一遍。工具就两个:strace -c看多出来的系统调用,objdump -d看编译器到底替你做了什么。 - 自己写一个
Future。返回一次Pending、再返回Ready的结构体,加一个忙等的block_on。这一步会逼你回答:Waker到底该做什么? - 加上
epoll。一个 fd,epoll_ctl注册,epoll_wait等,事件回来时调用登记好的 waker。做完这一步,你手上就有一个不到 100 行的 runtime 了。 - 最后再回来看 tokio 的文档。这时候你关心的问题会从「它是什么」变成「它怎么处理这些边界」——超时、取消、多 worker、阻塞任务。
卡住的地方通常就是理解的缺口,比读十篇综述管用。
怎么选
| 场景 | 选择 | 理由 |
|---|---|---|
| 上万连接、IO 为主(网关、代理、IM) | async | 每连接几百字节,等的是网络不是 CPU |
| CPU 密集(编解码、计算) | 线程池 / rayon | async 不加核,还会堵住 executor |
| 少量长任务、逻辑简单 | 线程 | 一个 join 就完事,不用引入 runtime |
| 需要超时、取消、组合 | async | select! / timeout / drop 即取消 |
| 需要清晰调用栈调试 | 线程 | async 的 backtrace 是一堆状态机帧 |
| 库作者 | 尽量同步 | async 会「传染」调用方,把 runtime 选择强加给别人 |
几条容易踩的坑,按严重程度排:
- 在 async 里做阻塞操作。一次
std::fs::read或sleep就能卡死整个 worker 上的所有任务。要么用spawn_blocking,要么别在 async 里做。 - CPU 密集任务塞进
tokio::spawn。它不会自动分到多核,只会让别的任务饿着。用spawn_blocking或专门的线程池。 - 以为
tokio::spawn是并行的。它是并发的;并行度取决于 worker 数量和任务是否让出。 - 忘了
Pending契约。手写Future时返回Pending却不注册 waker,任务就永久挂起——没有报错,只是不动了。
End
写这篇的起因是发现自己在用 tokio::spawn 的时候,其实说不清它和线程到底差在哪。翻文档只会得到「async 更轻量」这种结论,但「轻」在哪、代价是什么,得自己量一遍才知道。
真正让我把这几个概念串起来的是那段 7 行汇编。协程切换只有 7.8ns,说明「暂停一个函数」本身几乎不要钱;162ns 的 ucontext 贵在两次 rt_sigprocmask;1960ns 的线程贵在内核调度。而 tokio 走的是第四条路——干脆不保存栈,让编译器把函数切成状态机,于是切换退化成一次 poll(9ns),真正的开销只剩下「数据没准备好时那次 epoll_wait」。
想清楚这一层之后,async fn 就不再是魔法了:它只是一个被编译器改写过的、会在中途返回的结构体。剩下的问题都变成了工程问题——谁来 poll、谁来 wake、几个线程一起 poll。
后面想接着挖两个方向:一是 epoll 在超高并发下的实际瓶颈(是 epoll_wait 本身,还是事件分发那一层的缓存失效),二是 io_uring 会不会让「reactor + waker」这套结构变得多余——毕竟它连「通知」都能省掉。
参考资料
ucontext(3)—— 有栈协程的经典接口,也是本文反汇编的对象- Tokio Tutorial: Async in depth ——
Future/Waker/ executor 的关系讲得很清楚 - The C10K problem —— 一万个连接这个问题本身的历史
