并行, 异步和并发

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,00010.1 MB
10,00084.6 MB
18,000+—pthread_create 开始失败

线程切换的代价,用两个线程 ping-pong 测一下(mutex + condvar,含 futex 系统调用):

switches=200000 per_switch=1960.3 ns

差不多 2µs 一次。这个数字里包含了系统调用和调度器,不是纯粹的寄存器保存,但它就是「阻塞式等待」的真实单价。

于是需求很明确:能不能在用户态自己决定「让出」,而不是交给内核? 这就是协程。


手写有栈协程

这一步要回答的问题:不经过内核,一个程序怎么「自己让出」?要让出,就得先知道「让出」在机器层面意味着什么。

保存什么

「暂停一个函数,之后再从原地继续」——这句话翻译成机器层面,就是保存三样东西:

  1. 栈:局部变量、返回地址都在里面;
  2. rsp:指向栈顶,恢复它等于恢复整个调用链;
  3. 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 ns12 条指令,不出用户态
ucontext162 ns上述 + 2 次系统调用 + 浮点环境
线程 ping-pong1960 ns陷入内核、调度器选下一个、可能换页表

这张表是理解 async 的起点:「并发」的瓶颈从来不是切换,而是「每个并发单元要占多少资源」。线程贵在栈和内核调度实体,协程贵在……也要一块栈。

栈的问题

手写版跑得飞快,但它有三个绕不过去的问题:

  1. 每个协程一块栈。64KB × 100 万 = 64GB 虚拟内存。栈大小还得提前猜:猜小了爆栈,猜大了浪费。
  2. 栈不能跨暂停点保存。这一点在 Rust 里是致命的:async fn 返回 Pending 时栈帧就销毁了,函数已经返回,哪来的栈给你保存?
  3. 跨暂停点的借用无法静态检查。编译器看到的是一个会「中途返回、之后再进来」的函数,它没法证明你手里的 &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,000133.7 MB~3.7 KB(被进程基线主导)
10,000137.0 MB~700 B
100,0001341.6 MB~416 B
1,000,00013386 MB~386 B

(13 = 12 个 worker + 1 个阻塞在 runtime 上的主线程。)

对照上面那张线程表:线程数从 1,000 涨到 1,000,000,内核里的线程数一个都没变。省下来的就是每任务一块栈。但注意 386 字节不是免费的——任务对象、状态位、引用计数、队列节点都在里面,100 万个依然要 386MB。

所以「async 能开一百万个任务」是真的,「async 免费」是假的。它只是把成本从「8MB 虚拟地址空间」压到了「几百字节堆内存」。


自己走一遍

上面的每个结论都能用一个很小的程序验证,顺序比结论重要:

  1. 先量线程。空线程的栈开销(看 VmRSS / VmSize),两个线程 ping-pong 的一次切换。有了这两个数字,「线程重」才不是一句空话。
  2. 再写最小协程。ucontext 版本先跑通,然后用 6 条 push / 6 条 pop 手写一遍。工具就两个:strace -c 看多出来的系统调用,objdump -d 看编译器到底替你做了什么。
  3. 自己写一个 Future。返回一次 Pending、再返回 Ready 的结构体,加一个忙等的 block_on。这一步会逼你回答:Waker 到底该做什么?
  4. 加上 epoll。一个 fd,epoll_ctl 注册,epoll_wait 等,事件回来时调用登记好的 waker。做完这一步,你手上就有一个不到 100 行的 runtime 了。
  5. 最后再回来看 tokio 的文档。这时候你关心的问题会从「它是什么」变成「它怎么处理这些边界」——超时、取消、多 worker、阻塞任务。

卡住的地方通常就是理解的缺口,比读十篇综述管用。


怎么选

场景选择理由
上万连接、IO 为主(网关、代理、IM)async每连接几百字节,等的是网络不是 CPU
CPU 密集(编解码、计算)线程池 / rayonasync 不加核,还会堵住 executor
少量长任务、逻辑简单线程一个 join 就完事,不用引入 runtime
需要超时、取消、组合asyncselect! / timeout / drop 即取消
需要清晰调用栈调试线程async 的 backtrace 是一堆状态机帧
库作者尽量同步async 会「传染」调用方,把 runtime 选择强加给别人

几条容易踩的坑,按严重程度排:

  1. 在 async 里做阻塞操作。一次 std::fs::read 或 sleep 就能卡死整个 worker 上的所有任务。要么用 spawn_blocking,要么别在 async 里做。
  2. CPU 密集任务塞进 tokio::spawn。它不会自动分到多核,只会让别的任务饿着。用 spawn_blocking 或专门的线程池。
  3. 以为 tokio::spawn 是并行的。它是并发的;并行度取决于 worker 数量和任务是否让出。
  4. 忘了 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」这套结构变得多余——毕竟它连「通知」都能省掉。


参考资料

recent-work

并行, 异步和并发

TL;DR 这三个词经常被混着用,但它们回答的是三个不同的问题:并发问的是「同时有几个任务在飞」,并行问的是「有几个核真的在同时执行」,异步问的是「等一个慢操作的时候,要不要把线程也一起停住」。 本文先用手写一个有栈协程把「让出」这件事拆到底层(切换到 rsp 和几个 …

Read more →

ELF格式解析

TL;DR 一个 ELF 文件同时被四个角色读写:编译器产出它、链接器改造它、内核加载器消费它、工具与攻击者审视它。这四个读者看的是同一坨字节,但需求完全不同。ELF 的解法是把文件切成两套正交的索引表: Program Header Table(段视图)——给加载器看,描述“运 …

Read more →