深入理解异步

异步到底是什么

异步不是让代码天然更快,也不等于多线程。异步真正解决的是:当一个操作暂时无法继续时,不让执行它的线程原地等待,而是把当前逻辑暂停,等条件满足后再恢复。

同步代码中,调用栈、线程和逻辑流程基本重合:

1
2
auto data = socket.read();
process(data);

线程会阻塞在 read(),直到数据到达。异步代码则把“等待”拆出来:

1
2
auto data = co_await socket.async_read();
process(data);

co_await 处协程暂停,线程可以去执行别的任务;数据到达后,调度器再恢复这个协程。逻辑流程没有消失,只是暂时和具体线程分离了。

并发、并行、异步的区别

概念 含义
并发 多个任务的生命周期发生重叠
并行 多个任务真的在多个 CPU 核上同时执行
异步 等待期间是否释放执行线程

一个线程用事件循环管理上万个 socket,是异步、并发,但未必并行。多个线程同时做 CPU 计算,是并发、并行,但可以完全同步。异步最适合处理网络、磁盘、数据库、定时器、GUI 事件这类等待外部事件的工作。

异步的本质是状态机

同步函数依靠调用栈保存执行位置和局部变量:

1
2
3
auto a = step1();
auto b = step2(a);
return step3(b);

callback 风格需要手工拆状态机:

1
2
3
4
5
step1_async([](auto a) {
step2_async(a, [](auto b) {
step3(b);
});
});

协程让编译器生成状态机:

1
2
3
auto a = co_await step1_async();
auto b = co_await step2_async(a);
co_return step3(b);

所以协程不是消灭 callback,而是让编译器替我们管理 continuation、局部变量和恢复点。

为什么 callback 容易破坏结构

同步代码中,子函数一定在父函数返回前结束:

1
2
3
4
5
void handle_request() {
State state;
auto data = read();
process(state, data);
} // state 在这里销毁

state 的生命周期清晰,因为 read() 不返回,handle_request() 就不会结束。

传统异步 callback 则不同:

1
2
3
4
5
6
7
void handle_request() {
State state;

read_async([&] {
process(state);
});
} // handle_request() 很快返回,state 已经销毁

read_async() 通常只是登记一次读取和回调,然后立即返回。真正的 callback 可能很久以后才执行。此时 state 已经被销毁,callback 中的引用就悬空了。

因此很多异步代码会写成:

1
2
3
4
5
6
7
void Session::handle_request() {
auto self = shared_from_this();

read_async([self] {
self->process();
});
}

捕获 shared_ptr 可以让 Session 至少活到 callback 执行完,避免访问已销毁对象。但代价是:对象生命周期不再由调用栈和作用域表达,而是由“还有哪些 callback 捕获了 shared_ptr”决定。代码能跑,但难推理。

shared_ptr 在多线程异步中的问题

shared_ptr 的引用计数是线程安全的。多个线程各自持有一份 shared_ptr 副本,同时复制和销毁通常没问题。但这只保证对象不会太早析构,不保证对象内容线程安全。

1
2
std::thread t1([p] { ++p->value; });
std::thread t2([p] { ++p->value; });

如果 value 是普通整数,这仍然是数据竞争。

shared_ptr 还有性能和设计问题:

  • 每次复制和析构都要修改原子引用计数。
  • 多核频繁复制同一个控制块会造成 cache line ping-pong。
  • 最后一份引用在哪个线程释放,析构就在哪个线程发生,尾延迟不可控。
  • 容易形成引用环,需要 weak_ptr 打断。
  • 容易掩盖真正的生命周期设计问题。

如果使用 shared_ptr 只是为了让后台任务“先活着再说”,通常应该检查任务生命周期能否结构化。

结构化并发的核心思想

结构化并发要求:子任务必须在父任务结束前完成。它把异步生命周期重新约束到清晰的父子结构中。

同步函数天然满足这个性质:被调用函数一定先于调用者返回。结构化并发希望异步任务也满足类似关系。

协程写法中:

1
2
3
4
task<void> Session::handle_request() {
auto data = co_await read_async();
process(data);
}

如果 read_async() 是被立即等待的子操作,那么父协程恢复或退出前,子操作已经完成。局部变量、异常、RAII 和资源析构都重新变得容易推理。

executor 与协程的关系

C++20 协程负责表达“如何暂停和恢复”,但它本身没有标准线程池、事件循环、I/O reactor 或 task<T> 类型。executor 或 scheduler 负责回答:恢复后的工作在哪里执行。

一个调度 awaitable 可以理解为:

1
2
3
4
5
6
7
8
9
10
11
struct ScheduleAwaitable {
Executor& ex;

bool await_ready() const noexcept { return false; }

void await_suspend(std::coroutine_handle<> h) {
ex.post([h] { h.resume(); });
}

void await_resume() const noexcept {}
};

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
2
3
4
5
spawn(task_a)      count = 1
spawn(task_b) count = 2
task_a 完成 count = 1
task_b 完成 count = 0
join() 完成

close() 表示不再接收新任务,join() 表示等待已经接收的任务全部完成,request_stop() 表示向任务族发出协作式取消请求。

这不是为了复杂而复杂,而是为了替代旧式 fire-and-forget:

1
2
3
executor.submit([self = shared_from_this()] {
self->do_work();
});

旧写法能避免对象过早销毁,却没有回答后台任务什么时候结束、系统如何关闭、取消如何传播。scope 把这些隐含约定变成显式生命周期边界。

异步是否更通用、更模块化

异步并不天然更模块化。好的异步抽象会增强模块化,差的异步抽象会让代码更难推理。

实践中更好的分层是:

1
2
3
4
5
6
7
8
业务纯函数层
同步、无共享状态、容易测试

异步编排层
管理 I/O、超时、取消、重试

调度与系统层
event loop、scheduler、线程池、操作系统 I/O

计算逻辑尽量保持同步和局部化,等待边界使用异步表达。不要为了“通用”把所有函数都做成异步。

对 tracing 的启发

同步 tracing 可以依赖 RAII 和线程本地栈:

1
2
3
thread
└── outer
└── inner

但异步任务会跨线程恢复,不能只依赖 thread_local 推断父子关系。更好的模型是:

概念 含义
Track 实际在哪个线程或执行队列上运行
Span 属于哪个逻辑任务
Flow 任务如何从一个线程迁移到另一个线程
SpanContext 跨线程、跨协程传播的逻辑上下文

因此,多线程异步 tracing 应该记录两棵结构:一棵是物理执行时间线,一棵是逻辑任务父子关系。

对于低延迟场景,hot path 中应避免锁、分配、字符串格式化和 shared_ptr。可以使用每线程固定容量 buffer 记录紧凑事件,后台线程再导出为 Perfetto、OpenTelemetry 或自定义二进制格式。

总结

异步的本质是:等待时释放执行线程,用状态机保存逻辑进度,事件就绪后再恢复。它最适合等待密集型系统,不应滥用于纯计算逻辑。

传统 callback 的问题在于,它让后续代码脱离当前函数作用域执行,破坏了调用栈原本提供的生命周期保证。shared_ptr 可以补救对象存活问题,但会带来所有权模糊、引用计数开销和关闭困难。

结构化并发的目标,是让异步任务重新拥有像同步调用一样清楚的父子关系:子任务必须在父任务结束前完成。C20 协程让异步流程重新像顺序代码一样书写,C26 std::execution 和 scope 工具则进一步把调度、组合、取消和动态任务族管理标准化。