本篇两条主线:异步——怎么让一个线程交错跑很多任务(协程 / 纤程 / 生成器 / 运行时);原子——多核共享数据时怎么保证”不可分割”,以及建在其上的锁与无锁结构。以 Rust / Zig 为主轴,旁及 Go / Python / C++20 / Kotlin / JS,逐点给跨语言写法对照。
1. 四组最容易混的概念
并发的讨论,先把这四组概念分清,后续才不致混淆。
1.1 并发 vs 并行
- 并发(concurrency):一个核轮流跑多个任务,逻辑上”同时推进”,物理上某一刻只有一个在跑。
- 并行(parallelism):多个核物理上真同时跑多个任务。
Rob Pike 的经典区分:Concurrency is about dealing with lots of things at once; parallelism is about doing lots of things at once.——并发是”结构”(怎么组织多个任务),并行是”执行”(真同时跑)。协程 / async 主要解决并发(用极低开销交错大量 I/O 任务),不一定带来并行;要并行还得把任务摊到多个 OS 线程上(tokio 多线程运行时、Go 的 GOMAXPROCS、rayon)。
1.2 同步/异步 × 阻塞/非阻塞(四象限)
这两组维度正交,组合出四象限——很多人把”异步”和”非阻塞”混为一谈,其实是两回事:
| \ | 阻塞(调用方原地等结果) | 非阻塞(立即返回,可能没结果) |
|---|---|---|
| 同步(自己主动取结果) | 普通 read():调用方阻塞挂起,数据就绪后被唤醒 |
read() + O_NONBLOCK:没数据立即返 EAGAIN,得自己轮询 |
| 异步(结果好了来通知你) | 较少见:提交请求后仍挂起等通知 | io_uring / 回调 / await:提交后照常干别的,完成由内核 / 运行时通知 |
- 阻塞 vs 非阻塞:调用方要不要在原地等。
- 同步 vs 异步:结果由谁、怎么交付——同步是你主动取,异步是好了通知你(回调 / Future / 完成队列)。
1.3 抢占式 vs 协作式调度
切换执行流的触发者不同,这是理解协程的关键:
- 抢占式(preemptive):调度器靠时钟中断强行把当前任务切走,任务自己不知情。OS 线程、Go goroutine(带抢占点)属此类。好处:一个任务死循环也饿不死别人;代价:随时可能被打断,共享数据必须加锁。
- 协作式(cooperative):任务自己显式让出(
yield/await/suspend)才切。绝大多数协程(Rust async、Python async、Lua coroutine)属此类。好处:让出点明确、单线程内无需锁;代价:一个任务不让出就卡死整个调度器。
1.4 贯穿全篇的核心判断
协程 / 纤程之所以比线程”轻”,根因只有一句:切换时要保存 / 恢复的东西更少,且不进内核。线程切换要换页表(satp)、刷 TLB、过特权级;协程切换只在用户态存几个寄存器(有栈),甚至什么都不存(无栈,只改状态机的当前状态)。下一节用一张谱系图说明。
2. 执行流载体全景:从进程到生成器
把”能独立推进的执行流”按从重到轻排成一条谱系:
1 | graph TD |
越往下越轻——切换要保存的东西越少、越不碰内核:
| 载体 | 谁来切 | 切换保存什么 | 每个的栈 | 切换开销 | 典型代表 |
|---|---|---|---|---|---|
| 进程 | 内核 | 全寄存器 + 页表(satp) + 信号 + fd 表 |
内核栈 + 用户栈 | ~μs(特权切换 + TLB 刷新) | fork |
| 线程 | 内核 | 全寄存器 + 信号掩码(页表共享) | 独立栈 | ~μs(特权切换,无 TLB 刷新) | pthread / std::thread / std.Thread |
| green thread / goroutine | 语言 runtime | 用户态寄存器 | 动态增长栈(2–8 KB 起) | ~100 ns | Go goroutine / Java 虚拟线程 |
| 纤程 / 有栈协程 | 用户态调度器 | callee-saved 寄存器 + SP |
固定用户栈(需预分配) | ~10–100 ns | corosensei / may / zigcoro / Lua / Erlang |
| 无栈协程(async) | 调用方 poll |
无——编译成状态机 | 无独立栈(帧在堆 / 父栈) | ~ns(函数调用级) | Rust async / Python async / C++20 / Zig 状态机 |
| 生成器(受限协程) | 调用方 next |
无——状态机 | 无 | ~ns | Python yield / Rust Generator / JS function* |
核心洞察:这张表从上到下,”切换”这件事从”内核换页表刷 TLB”一路退化到”改一个状态机枚举的当前值”。有栈协程的切换本质就是用户态的上下文切换——和 OS 切线程的机制几乎一样(存恢复 SP + 寄存器),只是发生在用户态、没有内核参与、没有页表切换。无栈协程更彻底:连栈都不切,编译器把函数拆成一段段状态,
poll一次推进一段。后面会把这两条路各自拆到汇编 / 源码级。
3. 原子操作与内存模型
异步讲的是”一个核怎么交错跑多个任务”;一旦任务摊到多个核真并行,就绕不开”共享数据怎么不打架”——这就是原子与内存模型的地盘,也是后面一切锁与无锁结构的地基。
3.1 为什么要”原子”
多核共享一个变量时,x += 1 这种看似一步的操作,机器层面其实是三步:读 → 改 → 写。两个核同时做,可能都读到旧值、各自加一、各自写回——结果只加了一次(丢更新)。原子操作就是硬件保证这组”读-改-写”不可分割:要么没发生,要么整体完成,中间不会被另一个核插进来。
不只多核——单核多任务同样会出问题:某任务刚执行完”改”、还没”写回”,时间片就结束、切到下一任务;后者完成了自己的”改 + 写回”,切回来时前者又把过期的旧值写回原位,同样丢了更新。因此单核环境下一种常见的原子实现就是关中断——在临界区结束前禁止任务切换。
概括地说:原子操作即硬件级的”事务”——要么整体生效、要么视作未发生,不存在中间态。
3.2 原子类型与操作(Rust + Zig 0.17 对照)
1 | use std::sync::atomic::{ |
Zig 0.17 对位写法(原子类型是 std.atomic.Value(T)):
1 | const std = @import("std"); |
一处易错对照:CAS 的返回反着来——Rust
compare_exchange返回Result(Ok(旧值)=成功);ZigcmpxchgStrong返回?T(null=成功、非 null=当前实际值)。内存序枚举:RustRelaxed= Zig.monotonic,其余.acquire/.release/.acq_rel/.seq_cst同名。
这套语义,各语言只是换了套写法:
| 操作 | Rust | Zig | C++ | Go |
|---|---|---|---|---|
| 类型 | AtomicUsize |
std.atomic.Value(usize) |
std::atomic<size_t> |
atomic.Uint64 |
| 加载 | .load(ord) |
.load(ord) |
.load(ord) |
.Load() |
| 存储 | .store(v, ord) |
.store(v, ord) |
.store(v, ord) |
.Store(v) |
| 原子加 | .fetch_add(1, ord) |
.fetchAdd(1, ord) |
.fetch_add(1, ord) |
.Add(1) |
| CAS | .compare_exchange(…) |
.cmpxchgStrong(…) |
.compare_exchange_strong(…) |
.CompareAndSwap(…) |
Go 的原子默认即最强序(顺序一致),不开放内存序选择;Rust / Zig / C++ 均要求显式指定内存序——这正引出下一节。
3.3 五种内存序(memory ordering)
光”原子”还不够:编译器和 CPU 都会重排指令提速,多核下重排会让别的核看到”不该看到的顺序”。内存序就是你给原子操作附加的排序约束,编译器据此生成对应的内存屏障挡住重排。从弱到强:
1 | // 1. Relaxed —— 只管原子,不管顺序 |
| Ordering | 保证 | 典型用途 | 性能 |
|---|---|---|---|
| Relaxed | 仅原子性 | 简单计数器 | 最快 |
| Release | 之前的写不重排到它之后 | 发布数据 | 快 |
| Acquire | 之后的读不重排到它之前 | 获取数据 | 快 |
| AcqRel | Release + Acquire | read-modify-write | 中 |
| SeqCst | 全局一致顺序 | 通用、最安全 | 最慢 |
各语言内存序名字几乎一一对应:Rust Relaxed = C++ relaxed = Zig .monotonic;Acquire/Release/AcqRel/SeqCst 四个 Zig/C++ 同名(Zig 写 .acquire/.release/.acq_rel/.seq_cst)。
3.4 Release-Acquire 必须配对
内存序的精髓是配对:一个线程 Release 写、另一个线程 Acquire 读同一个原子变量,才建立起 happens-before——发布方在 Release 之前的写,获取方在 Acquire 之后一定看得到。
1 | // 线程1(生产者) |
Zig 0.17 同款(语义一致,只是内存序写 .release/.acquire):
1 | // 线程1(生产者) |
单独一个 Release 或单独一个 Acquire 没意义——必须 Release 配 Acquire(读写同一个原子量)才形成同步。这是无锁编程最容易出错的地方。
3.5 落到硬件:RISC-V A 扩展
原子操作最终要变成 CPU 指令。RISC-V 用 A(Atomic)扩展实现,两条路:
- AMO(Atomic Memory Operation):一条指令完成读-改-写,如
amoadd.d/amoswap.d/amoand.d/amoor.d。 - LR/SC(Load-Reserved / Store-Conditional):两条指令拼出任意原子操作(CAS 就靠它)。
A 扩展(后续模块化拆分)由两个子集组成:
| 子扩展 | 内容 | 指令 |
|---|---|---|
| Zaamo | 原子内存操作(AMO) | amoswap / amoadd / amoand / amoor / amoxor / amomax[u] / amomin[u],各带 .w/.d 宽度 |
| Zalrsc | 加载保留 / 条件存储 | lr.w / lr.d、sc.w / sc.d |
A = Zaamo + Zalrsc。AMO 适合”定操作”(加 / 换 / 与或异或 / 取极值),一条指令完成、最快;LR/SC 灵活,能拼出 AMO 没有的任意 read-modify-write(CAS、带上限的自增、无锁链表摘点等)。
AMO 怎么做到”原子”——以 amoadd.d rd, rs2, (rs1) 为例,硬件在缓存 / 内存控制器层把”读旧值 → 运算 → 写回 → 返回旧值”锁成不可分割的一拍:
1 | // amoadd.d rd, rs2, (rs1) 的硬件语义(伪码) |
实现上,持有该地址 cacheline 的核先将其置为独占(MESI 的 M / E 态),在独占期间完成读-改-写;其它核对该行的访问请求被一致性协议挡住,从而保证整组操作不可分割。
内存序则落成 fence 屏障。同一个 Rust 操作,Relaxed 与 SeqCst 编出的指令不同:
| Rust | RISC-V(Relaxed) | RISC-V(SeqCst) |
|---|---|---|
| load | ld |
fence; ld; fence |
| store | sd |
fence; sd; fence |
| fetch_add | amoadd.d |
fence; amoadd.d; fence |
| swap | amoswap.d |
fence; amoswap.d; fence |
| compare_exchange | lr/sc 循环 |
fence; lr/sc; fence |
| fetch_and | amoand.d |
fence; amoand.d; fence |
| fetch_or | amoor.d |
fence; amoor.d; fence |
实际上 AMO 与 LR/SC 指令本身就带两个内存序 bit——
.aq(acquire:本操作之后的访存不得前移)、.rl(release:本操作之前的访存不得后移)、.aqrl(两者皆有)。内存序直接编码在指令里,比插独立fence更省;上表”裸指令 vs 加fence“是为建立直觉而做的简化对照。
LR/SC 怎么”抢”同一个地址——让多核安全地争抢同一内存地址(比如多个核同时改一个计数器):
- LR(Load-Reserved):先读取数据,同时令硬件标记保留该地址(reservation)。
- SC(Store-Conditional):尝试写回——只有这个坑没被别人动过才写入成功;否则写失败、返回非零,软件跳回 LR 重试。
1 | # CAS 的 LR/SC 实现骨架:把 *a0 从 a1(old) 换成 a2(new) |
其硬件机制是每个 hart 维护一组保留状态(reservation):
1 | // 每个 hart 一组保留寄存器 |
令保留失效(被打破)的情形:① 另一 hart 向 reservation_addr 写入(cache 一致性使该行失效);② 本 hart 又执行了别的 SC、或发生异常 / 中断 / 上下文切换;③ 规范还允许虚假失败——即便无人改动,SC 也可能偶发失败。正因如此,LR/SC 代码必须写成”lr → 运算 → sc,失败即跳回重试”的循环(上面骨架的 bnez t1, retry)。这是 RISC-V 的有意取舍:reservation 只需记一个地址、无需复杂的总线锁,硬件简单、易于多核伸缩。
硬件内存模型决定默认重排有多凶,也就决定要插多少 fence:
| 架构 | 内存模型 | 强度 |
|---|---|---|
| x86 / x86_64 | TSO(Total Store Order) | 强(只允许 store-load 重排) |
| ARMv8 / AArch64 | weak | 弱(多种重排) |
| RISC-V | RVWMO(RISC-V Weak Memory Ordering,2019 批准) | 弱(类 ARM) |
x86 强序,很多 Acquire/Release 几乎不用额外屏障;RISC-V / ARM 弱序,同样代码仍需显式插入 fence——这正是”同一份无锁代码,x86 上跑对了、搬到 ARM/RISC-V 就出错”的根因。
4. 同步原语:都建在原子之上
§3 的原子是最底层的”不可分割”;再往上,所有锁与通道,剥到底都是”原子 + 一套等待 / 唤醒策略“。
1 | graph TD |
4.1 自旋锁:纯原子搭出来的最小锁
最小的锁——一个原子标志位,抢不到就空转重试。适合极短临界区(内核 / RTOS);长持有则空耗 CPU。
1 | // Rust:用 AtomicBool 手写 |
1 | // Zig 0.17:用 std.atomic.Value(bool) 手写 |
4.2 互斥锁 / 读写锁 / 信号量 / 条件变量
这些不再空转,而是抢不到就睡(让出 CPU)、被唤醒再跑——底层 = 原子快路径 + 慢路径 futex(Linux)/ 内核等待队列。
Rust 的这些原语由 std::sync 提供:Mutex 返回 RAII 守卫(离开作用域自动解锁),还带 poison(持锁线程 panic 后锁”中毒”):
1 | use std::sync::{Mutex, RwLock}; |
Zig 0.17 把同步原语挪进了 std.Io(std.Io.Mutex / RwLock / Semaphore / Condition),而且 lock / unlock / wait 都要传入 io——加锁这件事被接进了新的 Io 并发模型:
1 | // Zig 0.17:std.Io.Mutex —— lock/unlock 带 io 参数,且可被取消(Cancelable) |
一眼对照:Rust 的锁是 RAII(守卫 drop 自动解锁、无 io 参数);Zig 0.17 的锁手动
unlock(靠defer配合)、且lock(io)把 io 注入进来——后者是 Zig”无隐藏 + 显式注入调度器”哲学的延续:连”等锁”都不会隐式阻塞线程,而是交给io决定怎么等(这和后面 §5 的”函数染色 / io 注入”是同一套思路)。注意这套std.Io同步原语仍随 Io 模型重构,API 可能继续变。
跨语言一览:
| 原语 | 干什么 | Rust | Zig 0.17 | C++ | Go |
|---|---|---|---|---|---|
| 自旋锁 | 忙等短临界区 | 手写 atomic / spin crate |
手写 atomic+spinLoopHint |
atomic_flag 手写 |
手写 atomic |
| 互斥锁 | 睡等独占 | Mutex(RAII) |
std.Io.Mutex(lock(io)) |
std::mutex |
sync.Mutex |
| 读写锁 | 多读单写 | RwLock |
std.Io.RwLock |
std::shared_mutex |
sync.RWMutex |
| 信号量 | 计数许可 | std 无(用 tokio / 三方) | std.Io.Semaphore |
std::counting_semaphore |
缓冲 channel 模拟 |
| 条件变量 | 等待-通知 | Condvar |
std.Io.Condition(wait(io,&m)) |
std::condition_variable |
sync.Cond |
| 一次性初始化 | 只跑一次 | OnceLock / Once |
std.once |
std::call_once |
sync.Once |
| 屏障 | 所有线程到齐 | Barrier |
手写 atomic | std::barrier |
sync.WaitGroup |
4.3 共享内存 vs 消息传递
并发协作有两条哲学路线:
- 共享内存 + 锁:大家围着同一块数据,靠锁保护(传统、灵活、易错)。
- 消息传递 + channel:谁也不碰谁的内存,值通过管道传(Go / Erlang / Rust mpsc)。
Go 的名言:Don’t communicate by sharing memory; share memory by communicating.——别用共享内存来通信,要用通信来共享内存。
1 | // Rust:std::sync::mpsc(多生产者单消费者) |
1 | // Go:channel + goroutine(语言内建) |
Zig 没有内建 channel——要么手写基于原子的无锁队列(下一节),要么用社区库。
4.4 无锁数据结构(lock-free)
锁之外的另一条路:不加锁,靠原子 CAS 让”至少一个线程总能往前走”。入门经典是 SPSC(单生产者单消费者)无锁环形队列——head / tail 两个原子下标,生产者只动 tail、消费者只动 head,靠 acquire/release 配对保证可见性,全程无锁:
1 | // Zig:SPSC 无锁环形队列(节选) |
强度分层:wait-free(每个操作有界步内完成)> lock-free(至少一个线程有进展)> obstruction-free。工业经典:Michael-Scott 队列、Treiber 栈、Linux 的 RCU(读多写少、读侧零开销)。无锁最难的不是写,而是内存回收——别的线程可能还在读你想
free的节点,要靠 hazard pointer / epoch GC 处理。
4.5 Rust 的独到设计:Send / Sync(把线程安全钉死在编译期)
前面所有原语,C / C++ / Zig / Go 都靠程序员自律 + 运行时工具(ThreadSanitizer、Go race detector)抓数据竞争;Rust 把它提前到了编译期:
Send:类型T可以跨线程移动(所有权转移到别的线程)。Sync:&T可以跨线程共享(多个线程同时持引用)。
1 | use std::rc::Rc; |
把一个线程不安全的类型塞进
thread::spawn,编译期就会被拒绝。这把”会不会数据竞争”从运行时崩溃提前到编译期报错,是 Rust”无数据竞争”承诺的根。代价是类型系统更重;Zig / C / Go 把这份责任留给你和工具。
5. 函数染色:异步为什么”传染”
讲完原子 / 同步(多核共享的地基),转入异步主线。绕不开的第一个概念是函数染色(function coloring)。
5.1 什么是函数染色
Bob Nystrom 2015《What Color is Your Function?》:一门语言引入 async/await 后,函数被隐式分成两色——普通函数(红) 和 async 函数(蓝)。规则:蓝函数只能被蓝函数 await,红函数调不了蓝函数。于是只要调用链最底层有一个 async,整条链都得染成 async:
1 | # Python:染色传染 |
同样的传染在 Rust / JS(ES6) / C# / Kotlin 都有:底层一个 async fn,上面全得 async。
5.2 各语言对染色的态度
| 语言 | 异步方案 | 染色? |
|---|---|---|
| Python / JS(ES6) / Rust / C# / C++20 | async/await 关键字 |
有,传染整条链 |
| Kotlin | suspend 函数 + 协程 |
有(suspend 即颜色),编译器隐式传 Continuation |
| Go | goroutine + 阻塞自动挂起 | 无——任何函数都能在 goroutine 里阻塞 |
| Java 虚拟线程(Loom, JDK 21) | 阻塞调用自动挂起 | 无 |
| Erlang | 进程 + 消息 | 无 |
| 老 Zig(stage1 时代) | async fn 关键字 |
有(同 Rust) |
| 新 Zig(self-hosted 后) | 移除内建 async + io 参数注入 |
设计上无 |
5.3 消除染色的两条路
路一 · 绿色线程 / 虚拟线程(Go / Java Loom / Erlang):函数不分色,遇到阻塞调用,运行时自动把当前任务挂起、切去跑别的。代价:需要一个有栈的运行时(每任务一个可增长栈),运行时较重。这正是 §2 里”green thread / goroutine”那一档——无染色 = 用有栈协程 + runtime 自动调度换来的。
路二 · Zig 的 io 注入(0.15+):把”是否异步”从函数类型里挪出来,改成一个普通参数 io:
1 | // 普通函数,只是多收一个 io 参数;同步还是异步由传进来的 io 决定 |
函数本身中性、不染色——但调用链仍要一路把 io 传下去。还记得 §4.2 的 std.Io.Mutex.lock(io) 吗?连加锁都收 io,正是这套思路的延续。
进一步看:Zig 这招没有真正消灭染色,只是把它从”类型传染”换成”参数传染”、把决定权推迟到调用方——决定用哪种
io后端的那一刻,颜色就定了。区别在于:io是普通参数、函数类型不变,所以不需要异步的中间函数可以不收io、可运行时换后端,比”编译期钉死在类型上”灵活。
5.4 Rust 为何接受染色的代价
Rust 偏偏选了有染色的 async/await + Future,因为它要零成本 + 不强制运行时:async 编译成状态机(见 §6 无栈那半),不需要 Go 那样每任务一个有栈协程 + 重运行时,能跑在 #![no_std] 裸机上(embassy)。染色是 Rust 为”零成本异步”付的代价——配合 §4.5 的 Send/Sync,构成 Rust”显式、可控、但啰嗦”的并发风格。
6. 协程深挖:有栈(纤程)vs 无栈(async)
§2 把协程分了”有栈 / 无栈”两档。这不是风格差异,而是实现机制的根本分叉——决定了切换开销、能在哪挂起、要不要染色。
6.1 核心区分
- 有栈协程 / 纤程(stackful):每个协程有自己的独立栈。切换 = 保存当前 SP + callee-saved 寄存器、恢复另一个的——和 OS 切线程几乎一样,只是在用户态、不换页表。能在调用链任意深度直接
yield(整个栈都保住了)。代表:Go goroutine、Erlang 进程、Lua coroutine、Rust corosensei / may、Zig zigcoro / zap。 - 无栈协程(stackless):没有独立栈。编译器把 async 函数拆成一个状态机,局部变量塞进一个”帧”结构体,
poll一次推进一段。只能在显式标记点(await)挂起。代表:Rust async/await、Python async、C++20 coroutine、JS async、Zig 状态机。
6.2 有栈协程的上下文切换(RISC-V 汇编)
有栈切换需保存 callee-saved 寄存器(sp 与 s0–s11)外加返回地址 ra:
1 | # RISC-V:context_switch(old: *Context, new: *Context) |
对比进程 / 线程切换:有栈协程切换要保存 / 恢复的寄存器量,其实不大(callee-saved 那批加 ra)——省的不是保存量,而是省掉了切页表与刷缓存。进程 / 线程切换还要换
satp(页表基址)、执行sfence.vma刷 TLB、过特权级;有栈协程在同一进程 / 线程内,地址空间不变,无需切页表、无需刷 TLB 与各级 cache——这才是它快一两个数量级的根因。有栈协程的本质,就是”用户态自己做的一次上下文切换”。无栈协程则更彻底:状态全由自身字段保存,切换连上下文的保存 / 重载都省了,改一个变量(状态机的当前值)即完成。
Rust 的 corosensei / may、Zig 的 zigcoro / zap,底层都是这套平台汇编(x86-64 / aarch64 / RISC-V 各一份)的封装:
1 | // Rust:corosensei(有栈协程) |
1 | // Zig:zigcoro(有栈协程,底层汇编 context_switch) |
may(Rust)/zap(Zig,kprotty 的实验性调度器,已停更)更进一层:在上面套了 M:N 调度器(多个 OS 线程跑多个有栈协程,work-stealing),API 像 Go 的go/spawn——这就是”goroutine 风格”的库实现。
6.3 无栈协程:状态机 + poll
无栈不切栈——编译器把 async 函数翻译成一个状态机,每个 await 点是一个状态。Rust 的核心抽象是 Future:
1 | pub trait Future { |
async fn 编译后大致等价于一个状态机:
1 | // async fn f() { let a = step1().await; step2(a).await; } |
跨 await 存活的局部变量被搬进这个帧里——所以无栈协程”没有独立栈”,帧放堆上或父任务栈上。(Future::poll 的 self: Pin<&mut Self> 正因如此:帧可能自引用,Pin 钉住其地址、禁止移动——这是 Rust async 最著名的一道坎。)Zig 没有内建 async 语法(self-hosted 编译器起已移除),无栈就靠手工状态机或 0.17 落地中的 io.async;Python / JS / C++20 各有 async/await 语法糖,编译 / 解释器同样生成状态机。
6.4 全维度对比 + 跨语言归属
| 维度 | 有栈协程 / 纤程 | 无栈协程 / async |
|---|---|---|
| 独立栈 | 有(预分配 / 可增长) | 无(帧在堆 / 父栈) |
| 切换保存 | SP + callee-saved 寄存器 | 无——只改状态机当前值 |
| 切换开销 | ~10–100 ns | ~ns(函数调用级) |
| 能在哪挂起 | 调用链任意深度直接 yield | 只能在 await 标记点 |
| 染色 | 无(普通函数即可 yield) | 有(async 传染) |
| 栈溢出 | 要手动设栈大小 | 不存在(无栈) |
| 调试回溯 | 栈回溯正常 | 状态机,回溯可能割裂 |
| 每任务内存 | 一个栈(KB 级) | 一个帧(按需,字节级) |
| 有栈 | 无栈 | |
|---|---|---|
| 语言内建 | Go goroutine · Erlang 进程 · Lua coroutine | Rust · Python · C++20 · JS(ES6) · Kotlin(suspend) |
| 库 | Rust corosensei / may · Zig zigcoro / zap | Rust futures · Zig 手工状态机 / libxev |
总纲:有栈用空间(每协程一个栈)换”任意处可挂起 + 无染色”;无栈用”染色 + 只能定点挂起”换极致省内存 + 零运行时。Go 选有栈(开发省心、运行时重);Rust 选无栈(零成本、能上裸机,但有染色、写法更繁)。
7. 异步运行时(执行器)
无栈 async 只是语法 / 状态机,本身不会跑——必须有个运行时(执行器)反复 poll 它、在 I/O 就绪时唤醒。这一层是 Rust async 生态最厚的地方。
7.1 为什么 async 需要运行时
async fn 返回的 Future 是个”还没跑的状态机”。谁来 poll?运行时。它干三件事:① 拿顶层 Future 循环 poll;② Future 返回 Pending 就挂起它、记下”它在等什么”;③ I/O / 定时器就绪时通过 Waker 唤醒对应 Future,再 poll。
1 | let fut = async { 42 }; // 只是造了个 Future,什么也没发生 |
Rust 不自带运行时(std 只给 Future trait),这是”零成本 + 不强制运行时”的代价——你得自己选 tokio / smol / …。Go / Python / JS 则内建运行时(goroutine 调度器 / asyncio 事件循环 / 浏览器事件循环),开箱即用。
7.2 就绪式 vs 完成式 I/O
运行时怎么向内核要 I/O,分两大流派:
- 就绪式(readiness,epoll / kqueue):内核说”fd 可读了”→ 你再
read()→ 内核把数据从内核缓冲拷到你的 buf。tokio 走这条。 - 完成式(completion,io_uring / IOCP):你提交 read 请求 + buf → 内核异步执行 → 完成时通知你”buf 已填好”(少一次拷贝)。monoio 走这条。
完成式代价是所有权模型不同:buf 在内核处理期间不能碰,得交出所有权:
1 | file.read(&mut buf).await?; // tokio(就绪式):&mut buf 借用即可 |
monoio 源码里这套所有权的真实长相:
fn read<T: IoBufMut>(&mut self, buf: T) -> impl Future<Output = BufResult<usize, T>>(BufResult<T, B> = (io::Result<T>, B))——buffer 按值 move 进函数,飞行期间由内部Op结构体在堆上持有(连同对SharedFd的强引用,防止 fd 在传输途中被关闭),io_uring 完成后连同结果一并还回。
7.3 Rust 运行时横向
| 运行时 | 线程模型 | I/O 后端 | 适合 |
|---|---|---|---|
| tokio | 多线程 work-stealing | epoll / kqueue / IOCP | 服务端通用,生态最大 |
| monoio | 单线程 | io_uring(Linux) | 极低延迟、零拷贝 |
| async-std | 多线程 | 同 tokio | API 镜像 std,迁移友好 |
| smol | 多线程 | async-io(poll/epoll) | ~1500 行,最适合读源码学原理 |
1 | // tokio:spawn(类 goroutine) + join!(并发等) + select!(等第一个) + mpsc channel |
1 | // smol:最小执行器,吃透它就懂"执行器怎么驱动 Future" |
7.4 Zig 的并发:没有”运行时生态”,只有 io 后端
Zig 没有 tokio 那样的第三方运行时生态,并发由 std.Io 后端 + 几条路拼出来:
| 路 | 适用 | 代表 |
|---|---|---|
| 手工状态机 | 裸机 / no_std,永远有效 | §6.3 风格 |
std.Thread |
有 OS、CPU 密集 | 线程 + §4 锁 |
libxev 事件循环 |
高并发 I/O,回调式 | mitchellh/libxev |
io.async / io.await |
0.17 落地中的官方协程 | std.Io |
1 | // libxev:跨平台事件循环(Linux=io_uring / macOS=kqueue / Windows=IOCP),回调式无栈异步 |
Zig 的并发单位是 Completion 回调,不是帧 / 栈——本质是工程化的回调式事件驱动,适合高并发 I/O,但书写体验不如 Rust 的
async/await。0.17 的io.async意在补足这一环,仍在落地中。
7.5 嵌入式:embassy(Rust async-on-bare-metal)
embassy 把 Rust async/await 搬上裸机 MCU(#![no_std]、无 OS):一个极小执行器跑 Future,靠中断当 Waker 唤醒任务——无栈协程不需要每任务一个栈,恰好契合 KB 级 RAM 的 MCU。这正是无栈路线相对有栈(Go 风格每任务一栈)在嵌入式的关键优势:同样的并发,async 内存占用小一个数量级。
8. 生成器:受限的协程
生成器(generator)是协程的”受限版”——只能单向把值 yield 给调用方,不能任意挂起去等别的任务。本质还是个无栈状态机(每个 yield 是一个状态),归在协程家族里,但更窄:只解决”惰性产出一串值”。
8.1 其他语言一瞥(仅示例用法)
1 | # Python:yield 即生成器 |
1 | // JS(ES6):function* + yield |
C# 的 yield return、Kotlin 的 sequence { yield(x) } 同理——都是”形如普通循环、实则惰性产出”的语法糖。
8.2 Rust:从 nightly Generator 到 stable genawaiter
Rust 的真·生成器(Coroutine trait + yield)还在 nightly:
1 |
|
stable 上用 genawaiter——它没有真生成器,而是借 async/await 模拟:把 async 块当协程体,靠一个共享单元 Airlock 在”生成端”和”驱动端”之间传值。看它的真实源码:
1 | // genawaiter/src/rc/engine.rs:Airlock = 一个共享的 Cell |
其机制是:
yield_把值写入Rc<Cell<Next>>、返回一个”挂起一次”的Barrier;驱动方poll这个 async 块,执行到yield_即暂停、从 airlock 取出值——借无栈 async 的”暂停”能力,实现了生成器的”产出一个值”。这是”语言未提供 X,便以 Y 组合实现”的一种做法。
惰性产出还有 itertools——迭代器的工具集合,纯 pull 式惰性求值,思想和生成器一致:
1 | use itertools::Itertools; |
8.3 Zig:没有内建生成器,用手工状态机 next()
Zig 没有 yield 语法,生成器就是一个带 next() 的 struct——状态全在字段里,每次 next 推进一步:
1 | const FibGen = struct { |
这其实就是 §6.3 无栈协程”状态机”的最朴素形态——Zig 把它留给你手写,而不给
yield关键字。比起 Python 的yield,Zig 更啰嗦但所见即所得(状态就是那几个字段,无编译器隐藏的处理)。
8.4 生成器 / 迭代器 / 协程的关系
| 方向 | 能力 | |
|---|---|---|
| 迭代器 Iterator | 拉(调用方 next 取) |
只产出值 |
| 生成器 Generator | 推(yield 主动产出) |
产出值(受限协程) |
| 协程 Coroutine | 双向(yield 送出 + resume 送入) |
产出 + 接收,可对称协作 |
Rust 的 Iterator trait 是 pull 式;生成器是”写迭代器的语法糖”(手写 Iterator::next 很烦,yield 让你像写循环一样写);完整协程(§6 有栈那种)还能双向传值(corosensei 的 suspend(x) 返回 resume 时的输入)。三者一脉相承、能力递增。
9. 实验:oscamp 对照验证 + 代码走读
前 8 节的概念,在 oscamp-base-experiment 仓里都有对应练习。三组练习正好把本篇内容落到三个实验:
| 练习 | 对应本篇 | 验证什么 |
|---|---|---|
01_concurrency_sync |
§3 / §4 | 线程 / Arc<Mutex> / mpsc / 进程管道 |
04_context_switch |
§6 有栈协程 | 真·RISC-V 上下文切换 + 绿色线程调度器 |
05_async_programming |
§6.3 / §7 | 手写 Future / poll / Waker + tokio |
9.1 上下文切换:真·RISC-V naked 汇编(对应 §6.2)
§6.2 讲的有栈切换,这里是能跑的真版 switch_context(即 §6.2 那个 context_switch,riscv64 only)。TaskContext 用 #[repr(C)] 锁死布局,汇编偏移与字段严格对应:
1 | // sp@0 ra@8 s0..s11@16..104 |
印证 §6.2 那句”有栈协程 = 用户态自己做的一次上下文切换”:换出 13 个 callee-saved(
sp/s0–s11)外加作为恢复点的ra(共 14 个)、ret跳走,没有satp、没有sfence.vma。init把ra设成入口——第一次切到这个任务时,那条ret直接跳进任务函数——这就是协程首次启动的关键。在它之上加一层 Ready/Running/Finished 状态 + 一个调度循环,就是02_green_threads的协作式绿色线程。
9.2 手写 Future:异步运行时的最小内核(对应 §6.3 / §7.1)
§7.1 说”Future 不自己跑,要运行时反复 poll + Waker 唤醒”。这个练习把它剥到最小——手写一个 Future:
1 | use std::future::Future; |
这就是 §6.3 状态机的实例:
count是状态,每次poll推进一步。Poll::Pending对应有栈协程的yield、Poll::Ready对应return。最关键的是cx.waker().wake_by_ref()——没有它,运行时 poll 一次拿到 Pending 就将任务挂起、不再轮询;调用 wake 后,运行时才知道”该任务可再次 poll”。Waker 正是 §7.1 中”I/O 就绪时唤醒任务”的那一机制。最小的”挂起一次”版是YieldOnce:首次 poll 置标志 + wake + Pending,二次 poll 直接 Ready。
9.3 原子 / 锁 / channel / 进程(对应 §3 / §4)
01_concurrency_sync 把 §3 / §4 的同步原语全跑一遍:
1 | // Arc<Mutex> + thread::scope(作用域线程,借栈数据无需 move) |
进程那一题揭示了”用户态 API = 底层 syscall”:Command::new 内部就是 fork()+execve()、Stdio::piped() = pipe()+dup2()、drop(stdin) = close() 写端给子进程发 EOF——高层 API 都是对 syscall 的包装。
9.4 思考题(附参考答案)
Q1. 有栈协程的 switch_context 只换出 14 个寄存器,为什么不用存 a0–a7、t0–t6?
因为
switch_context是个普通函数调用:按 RISC-V ABI,caller-saved 寄存器(a*/t*)的保存责任在调用方——调用前编译器已把还需用的 caller-saved 值压栈。切换只需随上下文换出 callee-saved(sp与s0–s11,共 13 个),再加上ra——注意ra按 ABI 其实是 caller-saved,这里单独换出是因为它充当协程的恢复地址(首次ret即跳入入口),与”是否 callee-saved”无关。其余 caller-saved 寄存器交给正常的函数调用约定。这也是有栈切换轻的原因之一。
Q2. CountDown 每次 poll 都调 wake_by_ref();删掉这行会怎样?
任务永远卡住。运行时逻辑是”poll 拿到 Pending → 挂起,等 Waker 通知再 poll”。不调 wake,就没人通知”可以再 poll 了”,任务再也不被推进。真实场景里 wake 由 I/O 就绪 / 定时器到点触发;这个练习里 count 没到 0 就自我 wake,相当于”立刻安排下一次 poll”(忙轮询)。
Q3. 同一段无锁 SPSC 队列(§4.4),x86 上用 Relaxed 跑对了,搬到 RISC-V 就出错——为什么?
x86 是 TSO 强模型,几乎不重排(只允许 store-load),
Relaxed碰巧不暴露问题;RISC-V 是 RVWMO 弱模型,读写可大幅重排,head/tail 与 buf 之间若不用 acquire/release 配对,消费者可能”看到 tail 更新了、但 buf 数据还没写进来”。正确性要靠内存序、而非依赖运气——§3.4 的 acquire/release 配对、§3.5 的弱内存模型,讲的都是这个道理。
Q4. Zig 0.17 的 std.Io.Mutex.lock(io) 要传 io,Rust 的 Mutex::lock() 不用——本质差异是什么?
本质是两种并发哲学(§5 染色)。Rust 的锁阻塞的是 OS 线程(陷入内核 futex 睡眠),锁本身不关心运行时。Zig 0.17 把”等锁”接进了
io:传哪种io,就决定等锁时是自旋、是 futex 挂起线程、还是让出当前异步任务——锁与调度器解耦、由调用方注入。代价是io一路传下去(参数传染),好处是同一把锁能适配线程 / 协程 / 异步多种后端。
10. 其他相关补充:把异步推进到内核与硬件
前面的异步都在用户态(协程、运行时、生成器)。本地资源里还有一批,把”异步”这套思路推进到内核结构乃至硬件中断——这是异步的前沿,也是本篇标题里”其他相关补充”所指。
10.1 异步内核:把 future / 协程搬进内核
长期以来,Linux 等主流内核仍是同步线程模型,异步只存在于用户态。近年的研究尝试把 future / 协程作为内核自身的执行单位,按”异步如何进入内核”可分四派:
| 流派 | 代表 | 异步如何进入内核 |
|---|---|---|
| 异步 I/O 接口 | Linux io_uring | 用户态 ring buffer + 内核 worker;内核本体仍是同步线程 |
| async fn syscall | NoAxiomOS | syscall handler 写成 async fn,编译期生成 future |
| 共享调度器外置 | TornadoOS | 调度器单独编译成裸机库、固定物理地址、跨地址空间共享 raw table |
| 运行时可选 | arceos | runtime 是编译期 feature,由上层决定是否启用异步 |
以 TornadoOS 为例:它把调度器编译成独立的 shared-scheduler.bin、加载到固定物理地址,通过一张 SHARED_RAW_TABLE(若干函数指针 + 调度器静态变量地址)让内核态与用户态共享同一个协程调度器——future 在内核或用户态注册后,跨地址空间统一调度。这是把 §6 / §7 的”无栈 Future + 执行器”模型从用户态上移到内核的一次尝试。
10.2 用户态中断:让异步通知绕过内核
异步的另一半是”完成时如何通知”。传统通知(信号 / epoll 唤醒)都要陷入内核。用户态中断(RISC-V N 扩展 / Intel uintr)允许一个用户态任务直接向另一个用户态任务发中断,全程不经内核——uintr(ISA 扩展)、TAIC(任务感知中断控制器硬件)即此方向;rCore-N 则把调度器做成 vDSO(用户态共享代码),配合用户态中断实现异步调度。
典型应用是异步 IPC:client 向 server 申请服务,内核开辟一块 IPC buffer 供双方共享;同步时 client 轮询等待结果,异步时 client 注册一个用户态中断回调,server 处理完毕后直接发用户态中断通知——async read / write 由此实现,零拷贝、无内核介入。这把 §7 的”I/O 就绪 → Waker 唤醒任务”链路,从”内核唤醒”压缩到了”硬件用户态中断直达”。
10.3 给 async 加抢占:embassy_preempt
§1.3 区分过协作式与抢占式,也指出绝大多数协程是协作式(任务不让出即卡死调度器)。embassy_preempt 在 embassy(§7.5 的裸机 async 运行时)之上引入 uC/OS-II 式抢占,让 async 任务也能被时钟中断抢占——在 async 的省内存与抢占式的实时性之间取得折中。
回望全篇:从”四组概念 + 执行流载体”起步,向下抵达”原子 + 内存模型 + 同步原语”的硬件地基,向上延伸到”协程(有栈 / 无栈)+ 染色 + 运行时 + 生成器”的异步上层,最后以 oscamp 练习对照。一条主线贯穿始终:并发的种种机制,要么在解决”如何高效切换执行流”(协程谱系),要么在解决”如何安全共享内存”(原子谱系)——Rust 以类型系统将安全约束固化在编译期,Zig 以显式与 io 注入将控制权交还给开发者,各有取舍。