深入理解异步
异步到底是什么
异步不是让代码天然更快,也不等于多线程。异步真正解决的是:当一个操作暂时无法继续时,不让执行它的线程原地等待,而是把当前逻辑暂停,等条件满足后再恢复。
同步代码中,调用栈、线程和逻辑流程基本重合:
1 | auto data = socket.read(); |
线程会阻塞在 read(),直到数据到达。异步代码则把“等待”拆出来:
1 | auto data = co_await socket.async_read(); |
co_await 处协程暂停,线程可以去执行别的任务;数据到达后,调度器再恢复这个协程。逻辑流程没有消失,只是暂时和具体线程分离了。
并发、并行、异步的区别
| 概念 | 含义 |
|---|---|
| 并发 | 多个任务的生命周期发生重叠 |
| 并行 | 多个任务真的在多个 CPU 核上同时执行 |
| 异步 | 等待期间是否释放执行线程 |
一个线程用事件循环管理上万个 socket,是异步、并发,但未必并行。多个线程同时做 CPU 计算,是并发、并行,但可以完全同步。异步最适合处理网络、磁盘、数据库、定时器、GUI 事件这类等待外部事件的工作。
异步的本质是状态机
同步函数依靠调用栈保存执行位置和局部变量:
1 | auto a = step1(); |
callback 风格需要手工拆状态机:
1 | step1_async([](auto a) { |
协程让编译器生成状态机:
1 | auto a = co_await step1_async(); |
所以协程不是消灭 callback,而是让编译器替我们管理 continuation、局部变量和恢复点。
为什么 callback 容易破坏结构
同步代码中,子函数一定在父函数返回前结束:
1 | void handle_request() { |
state 的生命周期清晰,因为 read() 不返回,handle_request() 就不会结束。
传统异步 callback 则不同:
1 | void handle_request() { |
read_async() 通常只是登记一次读取和回调,然后立即返回。真正的 callback 可能很久以后才执行。此时 state 已经被销毁,callback 中的引用就悬空了。
因此很多异步代码会写成:
1 | void Session::handle_request() { |
捕获 shared_ptr 可以让 Session 至少活到 callback 执行完,避免访问已销毁对象。但代价是:对象生命周期不再由调用栈和作用域表达,而是由“还有哪些 callback 捕获了 shared_ptr”决定。代码能跑,但难推理。
shared_ptr 在多线程异步中的问题
shared_ptr 的引用计数是线程安全的。多个线程各自持有一份 shared_ptr 副本,同时复制和销毁通常没问题。但这只保证对象不会太早析构,不保证对象内容线程安全。
1 | std::thread t1([p] { ++p->value; }); |
如果 value 是普通整数,这仍然是数据竞争。
shared_ptr 还有性能和设计问题:
- 每次复制和析构都要修改原子引用计数。
- 多核频繁复制同一个控制块会造成 cache line ping-pong。
- 最后一份引用在哪个线程释放,析构就在哪个线程发生,尾延迟不可控。
- 容易形成引用环,需要
weak_ptr打断。 - 容易掩盖真正的生命周期设计问题。
如果使用 shared_ptr 只是为了让后台任务“先活着再说”,通常应该检查任务生命周期能否结构化。
结构化并发的核心思想
结构化并发要求:子任务必须在父任务结束前完成。它把异步生命周期重新约束到清晰的父子结构中。
同步函数天然满足这个性质:被调用函数一定先于调用者返回。结构化并发希望异步任务也满足类似关系。
协程写法中:
1 | task<void> Session::handle_request() { |
如果 read_async() 是被立即等待的子操作,那么父协程恢复或退出前,子操作已经完成。局部变量、异常、RAII 和资源析构都重新变得容易推理。
executor 与协程的关系
C++20 协程负责表达“如何暂停和恢复”,但它本身没有标准线程池、事件循环、I/O reactor 或 task<T> 类型。executor 或 scheduler 负责回答:恢复后的工作在哪里执行。
一个调度 awaitable 可以理解为:
1 | struct ScheduleAwaitable { |
co_await ex.schedule() 时,协程挂起,executor 将 coroutine_handle 放入队列,某个工作线程稍后调用 resume()。
Eric Niebler 批评的不是 executor 本身,而是直接把 executor 当业务控制流来写。到处 post() callback,会让程序像在时间和线程之间手写 goto:状态分散、错误传递困难、取消规则不清晰、生命周期需要 shared_ptr 兜底。
std::execution 与 async scope
C++26 的 std::execution 提供 sender/receiver 模型,用来描述异步操作、调度、完成状态和取消。
async_scope 或更具体的 counting_scope,则用于管理动态派生的并发任务。它解决的问题是:任务数量事先未知,子任务还可能继续派生孙任务,系统关闭时如何知道所有任务都已经结束。
它需要计数,因为每个被 scope 接纳的任务都代表一个尚未完成的工作:
1 | spawn(task_a) count = 1 |
close() 表示不再接收新任务,join() 表示等待已经接收的任务全部完成,request_stop() 表示向任务族发出协作式取消请求。
这不是为了复杂而复杂,而是为了替代旧式 fire-and-forget:
1 | executor.submit([self = shared_from_this()] { |
旧写法能避免对象过早销毁,却没有回答后台任务什么时候结束、系统如何关闭、取消如何传播。scope 把这些隐含约定变成显式生命周期边界。
异步是否更通用、更模块化
异步并不天然更模块化。好的异步抽象会增强模块化,差的异步抽象会让代码更难推理。
实践中更好的分层是:
1 | 业务纯函数层 |
计算逻辑尽量保持同步和局部化,等待边界使用异步表达。不要为了“通用”把所有函数都做成异步。
对 tracing 的启发
同步 tracing 可以依赖 RAII 和线程本地栈:
1 | thread |
但异步任务会跨线程恢复,不能只依赖 thread_local 推断父子关系。更好的模型是:
| 概念 | 含义 |
|---|---|
| Track | 实际在哪个线程或执行队列上运行 |
| Span | 属于哪个逻辑任务 |
| Flow | 任务如何从一个线程迁移到另一个线程 |
| SpanContext | 跨线程、跨协程传播的逻辑上下文 |
因此,多线程异步 tracing 应该记录两棵结构:一棵是物理执行时间线,一棵是逻辑任务父子关系。
对于低延迟场景,hot path 中应避免锁、分配、字符串格式化和 shared_ptr。可以使用每线程固定容量 buffer 记录紧凑事件,后台线程再导出为 Perfetto、OpenTelemetry 或自定义二进制格式。
总结
异步的本质是:等待时释放执行线程,用状态机保存逻辑进度,事件就绪后再恢复。它最适合等待密集型系统,不应滥用于纯计算逻辑。
传统 callback 的问题在于,它让后续代码脱离当前函数作用域执行,破坏了调用栈原本提供的生命周期保证。shared_ptr 可以补救对象存活问题,但会带来所有权模糊、引用计数开销和关闭困难。
结构化并发的目标,是让异步任务重新拥有像同步调用一样清楚的父子关系:子任务必须在父任务结束前完成。C20 协程让异步流程重新像顺序代码一样书写,C26 std::execution 和 scope 工具则进一步把调度、组合、取消和动态任务族管理标准化。