异步

异步的概念与实现机制

一、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/Oconnect/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 实例包含两个重要集合:

  1. 关注列表(interest list):记录应用程序要求监视哪些 fd,以及关注哪些事件,例如可读、可写;
  2. 就绪列表(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。

因此,不能简单地把 epollIOCPio_uring 都概括成同一种“通知机制”。它们都可以帮助异步运行时发现 I/O 状态变化,但抽象层次和语义不同

ChatGPT Image 2026年8月25日 11_21_00

以 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 的内容,可以见:

  1. 为什么C++20是最awesome的网络编程语言 [中国翻訳] - Caturra的文章 - 知乎
  2. Completion Tokens
  3. 从 C++20 协程,到 Asio 的协程适配 | Caturra's Blog

五、异步不等于多线程

"投递给某个执行器"容易让人误以为背后一定有另一个线程在跑。实际上,执行器(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 核心并行执行计算,或者单线程处理能力不足时,才需要进一步引入多个线程或进程。

评论