异步
异步的概念与实现机制
一、API 异步 ≠ 底层 I/O 异步
一个常见的误解:如果一个函数是"异步接口"(返回 future,或接收回调),就以为底层一定用了某种"异步 I/O"机制。
事实上,接口层面的异步语义,与底层 I/O 的实现方式是两个不同的维度。异步接口完全可以建立在阻塞 I/O 之上。
一个典型例子是:异步接口 + 线程池,线程池内部执行阻塞 I/O。
Future<Response> async_request(const Request& req) {
auto promise = std::make_shared<Promise<Response>>();
thread_pool.submit([req, promise] {
Socket sock = connect(req.endpoint); // 阻塞
write_all(sock, req.data()); // 阻塞
promise->set_value(read_response(sock));
});
return promise->get_future();
}
调用者视角:调用后立刻拿到一个 Future,没有等待,符合异步的定义。但线程池内部每个工作线程,执行的仍是阻塞 I/O:connect/write_all/read_response 都会让线程本身挂起。
即使通过封装一层异步接口,使调用者看到的是异步 API,但底层的线程数量、线程栈开销以及上下文切换成本并不会因此消失。
与之不同,io_uring 提供了面向提交与完成的异步 I/O 接口。用户态可以提交 I/O 请求,之后再通过完成队列获取结果,而不需要让某个用户线程阻塞在每一个具体的 I/O 操作上(通过完成队列获取结果可能也会阻塞,但不是阻塞在某个具体的 I/O 操作上)。
因此,讨论“是否异步”时需要区分两个层面:
- API 层面:调用方发起操作后是否必须等待结果;
- 底层实现层面:实际 I/O 是否需要某个线程阻塞等待,还是能够由内核、设备等机制异步推进,并在就绪或完成后通知调用方。
二、异步
抛开具体实现,异步描述的是一种控制流语义:
发起一个操作后,当前执行流不必等待该操作完成,可以继续处理其他工作,并在操作完成后再处理结果。
对应地,同步意味着:当前执行流需要等待该操作完成,才能继续执行依赖其结果的后续逻辑。
这个定义本身并不涉及线程、内核、回调或协程,因为这些都是实现异步语义的具体机制。
回调、协程、future/promise 解决的主要问题是:
异步操作完成以后,如何表达并执行它的后续逻辑。
例如:
- 回调:操作完成后调用指定函数;
- 协程:操作完成后恢复之前挂起的协程;
- future/promise:操作完成后设置结果,使等待方能够取得结果。
它们是组织异步控制流的方式,而不是异步本身。
三、事件循环
事件循环并不是异步的必要条件。
例如,前面的“线程池 + 阻塞 I/O + Future”已经构成异步接口,但它并不一定需要传统意义上的事件循环。
事件循环主要用于另一类场景:使用少量线程复用大量并发的 I/O 操作。
在这种模型下,一个线程可以同时管理大量尚未完成的操作。例如:
- 连接 A 正在等待数据可读;
- 连接 B 正在等待 socket 可写;
- 连接 C 正在等待新的连接建立。
线程不能阻塞在其中某一个连接上,否则就无法处理其他连接。因此需要一种机制回答:
当前这些等待中的操作,哪些已经可以继续执行?
事件循环(Event Loop)承担的就是这一调度职责:等待事件发生,并把已经就绪或完成的事件分发给对应的后续处理逻辑。
以 Linux 的 epoll 为例,内核中的一个 epoll 实例包含两个重要集合:
- 关注列表(interest list):记录应用程序要求监视哪些 fd,以及关注哪些事件,例如可读、可写;
- 就绪列表(ready list):记录当前已经满足相应 I/O 条件的 fd。
应用程序通常还会维护 fd 与处理逻辑之间的映射关系,以及定时器等其他调度信息。
当某个异步操作需要等待 fd 可读时,程序会确保该 fd 的可读事件已经注册到 epoll 中,并保存事件发生后需要执行的处理逻辑。
一个简化的事件循环可以表示为:
void event_loop_run() {
while (!stopped) {
// 1. 根据最近的定时器计算等待时间
auto timeout = next_timer_deadline();
// 2. 等待关注的 fd 中出现就绪事件
auto n = epoll_wait(
epoll_fd,
events,
MAX_EVENTS,
timeout
);
// 3. 分发就绪事件
for (int i = 0; i < n; ++i) {
auto handler = lookup_handler(events[i]);
handler();
}
// 4. 处理到期的定时器
run_expired_timers();
}
}
其中 epoll_wait 是阻塞的,它阻塞等待的是 interest list 中任意一个事件就绪,而不是等待某一个特定 I/O 操作完成,因此,一次 epoll_wait 可以同时覆盖很多个连接。线程并没有被某一个连接独占,而是在等待“这些任务中,下一个可以推进的是谁”。
事件循环得到事件后,会进一步触发对应的后续逻辑,具体形式取决于上层采用的异步编程方式。
- 回调方式:事件对应的 completion handler 最终调用用户提供的回调。回调中如果再次发起异步操作,就继续注册新的等待条件。
- 协程方式:事件完成后恢复对应的协程,例如调用
coroutine_handle::resume();,协程从上一次co_await挂起的位置继续执行,直到再次挂起或者执行结束。 - future/promise 方式:完成处理逻辑设置对应的 promise:
promise.set_value(result);,如果有线程正在future.get()上等待,该线程此时可以被唤醒。
因此,回调、协程、future/promise 与事件循环解决的是不同层次的问题:
- 事件循环负责发现哪些操作可以继续推进,并进行调度;
- 回调、协程、future/promise负责表达“操作完成后继续做什么”。
二者可以独立组合。
四、轮询与通知
事件驱动系统还需要解决一个问题:
如何知道某个等待中的操作已经就绪或完成?
常见方式可以分为两类。
1. 主动轮询
程序持续检查某个状态是否发生变化:
while (!completed()) {
// keep polling
}
这种方式会持续消耗 CPU,但可以避免线程睡眠与唤醒带来的延迟,因此并非没有实际用途。在部分对延迟极其敏感的系统中,busy polling 仍然是一种常见技术。
2. 阻塞等待 / 事件通知
更常见的通用方案是:没有事件时让线程休眠,在状态发生变化后由内核或运行时将等待方唤醒。
不同操作系统接口提供的语义并不完全相同:
epoll提供的是 readiness(就绪)通知:告诉应用程序某个 fd 当前可以进行读写;IOCP提供的是 completion(完成)通知:应用程序收到的是已经完成的 I/O 结果;io_uring提供异步 I/O 的提交队列与完成队列:应用提交请求,内核在操作完成后产生 CQE。
因此,不能简单地把 epoll、IOCP 和 io_uring 都概括成同一种“通知机制”。它们都可以帮助异步运行时发现 I/O 状态变化,但抽象层次和语义不同。
以 Asio 为例,可以进一步观察“底层事件机制”和“上层异步编程方式”之间的关系。
Asio 的异步操作通常接受一个 CompletionToken。一个简化的示意实现如下:
template <typename CompletionToken>
auto my_async_read_some(
Socket& s,
Buffer buf,
CompletionToken&& token
) {
return asio::async_initiate<
CompletionToken,
void(error_code, size_t)
>(
[](auto handler, Socket& s, Buffer buf) {
s.async_read_some(buf, std::move(handler));
},
token,
s,
buf
);
}
最后一个参数 token 可以有多种形式:
// 1. 回调
my_async_read_some(
sock,
buf,
[](error_code ec, size_t n) {
// ...
}
);
// 2. future
std::future<size_t> fut =
my_async_read_some(sock, buf, asio::use_future);
// 3. 协程
size_t n =
co_await my_async_read_some(
sock,
buf,
asio::use_awaitable
);
async_initiate 会根据 CompletionToken 的具体类型,通过 async_result 等机制将其适配为相应的 completion handler,同时确定异步操作对调用者呈现的返回类型和启动方式。
因此,不同的 CompletionToken 虽然对应不同的编程形式,但最终都会转换为异步操作能够使用的 completion handler:
- 普通回调本身可以直接作为 completion handler;
use_future会生成相应的 completion handler,并在操作完成时将结果写入与future关联的共享状态;use_awaitable会将操作适配为 awaitable,其 completion handler 与协程状态关联,使协程能够在操作完成后继续执行。
经过 CompletionToken 的适配后,真正启动底层异步操作时,最终都会得到一个具体的 completion handler:
s.async_read_some(buf, std::move(handler));
区别主要在于这个 handler 在操作完成后如何把结果交还给上层:直接调用用户回调、设置 future 的结果,或者恢复对应的协程。
需要注意的是,CompletionToken 不仅决定结果如何呈现,也可能影响操作何时启动。例如使用 use_awaitable 时,异步操作可以先被封装为 awaitable,直到实际执行 co_await 时才开始相应的异步操作。
当异步操作真正启动之后,Asio 的底层 I/O 机制只需要保存和推进这个操作,并在其完成时调用对应的 completion handler。至于这个 handler 最终对应普通回调、future 还是协程,对底层 I/O 处理机制而言并没有本质区别。
Asio 对用户呈现的是 风格的完成语义:用户收到的是“异步操作已经完成”的结果,而不是底层资源“已经可以读写”的通知。
以 Linux 的 epoll 后端为例,epoll 本身提供的是 Reactor 风格的 readiness(就绪)通知。当某个 fd 变为可读时,Asio 内部收到这一事件,并进一步执行实际的非阻塞 read;当相应的异步读操作真正完成后,再调用之前保存的 completion handler:
epoll
│
│ fd 可读
▼
Asio 内部
│
│ 执行非阻塞 read
▼
异步操作完成
│
▼
completion handler
│
├─ 调用用户回调
├─ 设置 future
└─ 恢复协程
这里是 Asio 在内部执行非阻塞 read 将 Epoll 模拟了 Proactor 语义。
关于 Asio 的内容,可以见:
五、异步不等于多线程
"投递给某个执行器"容易让人误以为背后一定有另一个线程在跑。实际上,执行器(executor)不等于另一个线程,只是"把任务交给某个调度单元运行",这个单元可以是当前线程本身:
asio::io_context ioc;
ioc.run(); // 单线程事件循环
这里没有任务被交给别的线程,只是当前线程在发起 I/O 后主动放弃等待,转去处理其他就绪的事件。
区分依据是任务类型:
- I/O 密集型:线程的大量时间花在等待网络、磁盘等外部事件上。通过非阻塞或异步 I/O,一个线程可以同时管理大量处于等待状态的操作,而不需要为每个操作分配一个线程;
- CPU 密集型:线程等待的是计算本身,计算必须有线程执行,这时才需要额外的线程/进程并行处理。
总结
- API 层面的同步/异步,与底层 I/O 的实现方式是不同的维度。 异步 API 完全可以通过线程池执行阻塞 I/O 来实现,因此不能仅根据接口形式判断系统是否节省了线程资源。
- 异步描述的是一种控制流语义。 发起操作后,当前执行流不必等待其完成,可以继续处理其他工作;回调、协程、future/promise 是组织异步完成逻辑的不同方式。
- 事件循环不是异步的必要条件,而是少量线程复用大量并发事件时常用的调度机制。 它负责等待事件,并把已经就绪或完成的操作分发给相应的处理逻辑。
- 不同 I/O 接口提供的事件语义不同。
epoll主要提供 readiness 通知,IOCP 提供 completion 通知,io_uring提供异步请求提交与完成队列,不能简单地将三者视为完全相同的机制。 - 事件机制与异步编程方式属于不同层次。 底层可以使用
epoll、IOCP 或io_uring,上层则可以使用回调、future 或协程组织完成逻辑。 - 异步不等于多线程。 单线程事件循环同样可以实现大量 I/O 并发;只有需要利用多个 CPU 核心并行执行计算,或者单线程处理能力不足时,才需要进一步引入多个线程或进程。
评论