1. 先搞清楚 Boost.Asio 是干什么的

Boost.Asio 是 Boost 这个大家族里专门用来处理异步输入输出(I/O)的一个库。这里说的 I/O,指的是那些最终要靠操作系统来完成的任务,比如:往网络上发一个包、从文件里读一段数据、等一个定时器到期。
如果不用任何辅助工具,自己去写这些异步逻辑会很痛苦:你得自己管理"这个操作有没有完成"“完成了应该通知谁”“好几个操作同时在等的时候怎么调度”。Boost.Asio 把这一整套麻烦事都封装好了,让我们可以用比较自然的 C++ 代码风格,去写"发起一个操作、操作完成后自动回调我"这种异步逻辑,而不用自己去和操作系统的底层接口(比如 Linux 的 epoll、Windows 的 IOCP)打交道。

2. 三个最基本的概念:OS服务、I/O对象、I/O执行上下文

Boost.Asio 把整个体系拆成了三层,理解这三层是看懂后面所有内容的基础:

概念怎么理解Boost.Asio里的例子
OS服务操作系统本身提供、由操作系统管理的能力,比如网络协议栈、文件系统、内核里的定时器TCP/UDP协议栈、磁盘文件读写、内核时钟
I/O对象给某个OS服务套上一层C++接口,让我们能用面向对象的方式去操作它tcp::socket(网络套接字)、steady_timer(定时器)
I/O执行上下文所有I/O对象共用的"总调度台",负责登记未完成的任务、驱动事件循环、把已经完成的操作分发给对应的回调函数io_context

用一张图看这三者之间的关系会更直观:

应用程序代码

I/O对象
steady_timer,tcp::socket等

I/O执行上下文
io_context

操作系统服务
网络协议栈,文件系统,内核定时器等

也就是说,我们平时写代码基本只会直接接触"I/O对象"和"I/O执行上下文"这两层,操作系统那一层的细节完全被 Boost.Asio 挡在了后面。

3. 先睹为快:一个最小的定时器例子

在深入讲 Reactor/Proactor 这些设计模式之前,先看一个最简单的完整例子会更容易建立直觉——用 steady_timer 做一个"过几秒钟就执行一次回调"的异步定时器。

#include <boost/asio.hpp>
#include <iostream>
// 这个函数就是"定时器到期后要执行的回调函数",
// 参数ec是错误码:如果定时器正常到期,ec不带错误;
// 如果定时器中途被取消或者出了别的问题,ec里会带上具体的错误信息。
void on_timeout(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "异步定时器到期,回调函数被执行\n";
    } else {
        std::cout << "定时器出错或被取消: " << ec.message() << "\n";
    }
}
int main() {
    // io_context就是前面说的"I/O执行上下文",
    // 所有的I/O对象(比如下面的timer)创建时都要依附于某一个io_context
    boost::asio::io_context io;
    // steady_timer是一个"I/O对象",构造时传入io_context和"2秒后到期"这个设置
    boost::asio::steady_timer timer(io, boost::asio::chrono::seconds(2));
    std::cout << "注册异步等待,main函数会立刻继续往下走,不会被阻塞\n";
    // async_wait只是"把这个等待任务登记给io_context",
    // 它会立刻返回,并不会真的等2秒钟
    timer.async_wait(&on_timeout);
    std::cout << "在事件循环真正开始跑之前,这里可以先做别的事情...\n";
    // io_context本身不会自己去处理事件,需要显式调用run()来驱动它,
    // run()会阻塞在这里,直到所有登记过的异步任务都处理完毕
    io.run();
    std::cout << "io.run()返回了,说明所有异步任务都已经处理完,程序即将结束\n";
    return 0;
}

https://godbolt.org/z/7oPK5qG8o

3.1 编译和运行

g++ -std=c++17 timer_example.cpp -o timer_example -lboost_system -lpthread
./timer_example

如果系统还没装 Boost,Ubuntu/Debian 上可以用 sudo apt install libboost-all-dev 安装,macOS 上可以用 brew install boost
运行后大致会看到:

注册异步等待,main函数会立刻继续往下走,不会被阻塞
在事件循环真正开始跑之前,这里可以先做别的事情...
异步定时器到期,回调函数被执行
io.run()返回了,说明所有异步任务都已经处理完,程序即将结束

注意前两行是立刻打印出来的,中间会停顿大约2秒,然后才打印第三行——这正好说明 async_wait 并没有阻塞 main 函数,真正的等待是交给 io_contextrun() 里去处理的。

3.2 这个例子的完整执行时序

操作系统 I/O执行上下文(io_context) I/O对象(steady_timer) 用户代码(main) 操作系统 I/O执行上下文(io_context) I/O对象(steady_timer) 用户代码(main) 大约2秒过去 创建io_context 创建timer(依附于io_context),设置2秒后到期 async_wait(&on_timeout) 向io_context登记这个定时任务 请求操作系统在2秒后通知自己 立刻返回,不阻塞 继续执行后面的代码 io.run()进入事件循环,开始等待 通知"定时器到期"这个事件已完成 在run()内部调用回调函数on_timeout(ec) 打印"异步定时器到期..." 没有更多待处理的任务了,run()返回

4. Proactor 和 Reactor:两种不同的异步设计思路

上面例子里 async_wait 用起来的那种感觉——“提交一个完整的任务,等它彻底做完了再通知我”——其实是有名字的,叫Proactor 模式。它和另一种更早、更常见的Reactor 模式正好是相对的。

  • Reactor 模式:应用程序只是问操作系统"现在有没有活干"(比如"这个socket现在可不可以读了"),操作系统一旦说"可以了",真正读数据这个动作还是应用程序自己去做。经典的 select/poll/epoll 就是这种思路。
  • Proactor 模式:应用程序把整个操作(比如"从这个socket里读满这么多字节")连同一个回调函数一起交出去,操作系统(或者库)负责把这个操作从头到尾做完,做完之后才通知应用程序,直接把结果给它。
操作系统 Reactor(事件多路复用器) 应用程序 操作系统 Reactor(事件多路复用器) 应用程序 loop [事件循环] 注册"关心某个socket的可读事件" 询问有没有已经就绪的事件 告诉它"这个socket已经可读了" 通知"可以读了" 应用程序自己发起真正的read操作 把读到的数据返回给应用程序
操作系统 Proactor(io_context) 应用程序 操作系统 Proactor(io_context) 应用程序 操作系统负责把数据读完,应用程序完全不用参与 提交完整的异步操作(如async_read)加上回调函数 把这个操作真正交给操作系统去执行 操作已经彻底完成,数据已经准备好 直接调用回调函数,把结果交给应用程序

模式真正执行I/O操作的是谁应用程序被通知的时机
Reactor应用程序自己动手执行操作“现在可以开始操作了”(操作发生之前)
Proactor操作系统/库负责执行完整的操作“操作已经彻底完成了”(操作发生之后)

Boost.Asio 对外暴露给我们用的编程模型统一都是 Proactor 风格(提交操作+回调,等完成通知),但它内部在不同操作系统上的具体实现其实并不完全一样:在 Linux 上它是用类似 Reactor 的 epoll 机制"模拟"出 Proactor 的效果,而在 Windows 上则是直接用天生就是 Proactor 设计的 IOCP。好在这些差异全部被库封装掉了,我们写代码的时候完全不用关心底层用的到底是哪一种。

5. 线程安全与 strand:多线程下怎么避免"打架"

一个常见的提高吞吐量的做法是:开好几个线程,每个线程都去调用同一个 io_contextrun(),这样多个异步任务的回调就可能被不同的线程并发执行,充分利用多核 CPU。
但这样一来就有一个新问题:如果两个回调函数几乎同时被不同线程执行,而它们又访问了同一份共享数据,就会出现竞态条件。Boost.Asio 提供的 strand(可以理解成"顺序化执行器")解决的正是这个问题——把一组回调函数都绑定到同一个 strand 上之后,Boost.Asio 就保证:不管这些回调最终被投到哪个线程上执行,任意时刻最多只有一个在跑,其他的必须排队。这样我们就不用为了保护这份共享数据而手动加锁了。

线程A ----\
           >--- 同一个strand ---> 回调1 -> 回调2 -> 回调3   (前后顺序执行,绝不会同时跑)
线程B ----/

6. 缓冲区:怎么把数据高效地传给异步操作

异步的读写操作(比如 async_readasync_write)都需要知道"数据到底应该往哪块内存里读/从哪块内存里发"。Boost.Asio 用一个叫 buffer 的轻量级描述符来表示"一段连续内存的起始地址加长度",比如把一个 std::vector<char> 或者 std::array<char, N> 包装成 boost::asio::buffer(data)。这个描述符本身不拥有数据,也不会复制数据,只是单纯地告诉 Boost.Asio “数据在哪儿、有多长”,这样就避免了不必要的内存拷贝,对高吞吐量的网络程序来说很关键。

7. 取消正在进行的异步操作

任何一个 I/O 对象(比如 steady_timertcp::socket)都提供一个 cancel() 方法,调用它之后,所有挂在这个对象上、还没完成的异步操作都会被提前结束,对应的回调函数依然会被调用,只是这次传给回调的错误码会是"操作被取消"(operation_aborted),而不是正常完成。这在很多场景下很有用,比如给一个网络请求设置超时:一旦定时器先到期,就调用 socket.cancel() 把还没完成的读写操作直接打断。

8. 这一章接下来会讲什么

把上面这些基础概念打好之后,这一章接下来会依次展开讲:

  • I/O 对象和 I/O 执行上下文具体怎么互相配合、怎么和操作系统服务打交道;
  • Proactor 和 Reactor 这两种模式再深入对比,以及 Boost.Asio 具体是怎么运用它们的;
  • 怎么用 strand 保证多线程环境下的线程安全;
  • 怎么用缓冲区高效地给异步任务传递数据;
  • 怎么取消一个正在进行中的异步操作;
  • 结合定时器和网络编程的实际例子,把前面这些概念串起来变成能跑的完整程序。

Boost.Asio 是什么

一、一句话理解 Boost.Asio

Boost.Asio 是 Chris Kohlhoff 写的一个跨平台 C++ 库,专门用来处理网络编程底层 I/O 编程——比如 socket 通信、定时器、域名解析、串口、文件描述符,甚至 Windows 下的 HANDLE。它把这些五花八门的东西全部统一到了同一套异步编程模型下,让你不管是在写 TCP 服务器还是在等一个定时器超时,用的都是同一套思路。
它最大的价值在于:让你不用手写线程和锁,就能管理那些"要等很久才有结果"的操作(比如等网络数据到达、等一个定时器触发)。Boost.Asio 在操作系统提供的底层机制(比如 Linux 的 epoll、Windows 的 IOCP)上面又包了一层,这样你写的代码可以:

  • 跨平台:同一份代码在 Linux、Windows、macOS 上都能编译运行,底层自动选最合适的系统机制。
  • 高效:比如用 scatter-gather I/O(一次系统调用读写多块不连续的内存)来减少不必要的数据拷贝。
  • 易用:你只需要关心"这个操作完成之后要做什么",不需要自己管理线程池和事件循环的底层细节。
    在 C++20 里协程已经成为语言的一部分,所以 Boost.Asio 自带的协程支持在这里只会简单提一下,重点还是先把它最基础的两个概念搞清楚:I/O 对象I/O 执行上下文

二、两个最核心的积木:I/O 对象 与 I/O 执行上下文

Boost.Asio 的所有功能,说到底都是围绕这两个东西转的:

  • I/O 执行上下文(I/O execution context):可以理解成一个"事件调度中心",最常见的实现是 boost::asio::io_context。它内部维护着一个操作队列,负责去问操作系统"有没有哪个异步操作完成了",一旦完成就把对应的回调函数拿出来执行。你可以把它想象成一个死循环,不停地问操作系统"好了没、好了没",一旦好了就去调用你注册的函数。
  • I/O 对象(I/O object):代表一个具体的"可以做 I/O 操作的东西",比如一个 TCP socket、一个定时器(steady_timer)、一个域名解析器(resolver)。每一个 I/O 对象在创建的时候都要绑定一个 I/O 执行上下文,因为它需要通过这个上下文去向操作系统提交异步请求、接收完成通知。
    这两者的关系可以用下面这张图来理解:
                    +---------------------------+
                    |   io_context(执行上下文)  |
                    |   负责问操作系统"好了没"     |
                    +---------------------------+
                       ^          ^          ^
                       |          |          |
            绑定同一个io_context   |          |
                       |          |          |
              +---------------+  |  +-------------------+
              | steady_timer  |  |  |  tcp::socket        |
              | (定时器对象) |  |  |  (网络套接字对象)  |
              +---------------+  |  +-------------------+
                                  |
                          +----------------+
                          | tcp::resolver  |
                          | (域名解析对象) |
                          +----------------+

也就是说,io_context 是"总调度台",各种 I/O 对象(定时器、socket、解析器)都是挂在这个调度台下面的"具体设备",它们各自向调度台报告"我在等一个事件",调度台负责统一轮询、统一分发。

三、同步模型 vs 异步模型

在深入代码之前,先搞清楚 Boost.Asio 里"同步操作"和"异步操作"的根本区别。

对比项同步操作(sync)异步操作(async)
调用方式timer.wait()socket.read_some()timer.async_wait(handler)socket.async_read_some(handler)
调用后发生什么当前线程原地阻塞,直到操作完成才往下走函数立刻返回,操作在后台"登记",完成后由 io_context 负责调用你传入的回调函数(handler)
谁来驱动完成通知操作系统直接唤醒当前线程必须显式调用 io_context.run(),让它进入事件循环去轮询、分发完成事件
典型用途逻辑简单、不在乎阻塞的小工具、脚本需要同时处理很多个长时间操作(比如成百上千个网络连接)而不想开等量线程

同步操作很好理解,跟平时写的阻塞式代码没什么两样。异步操作的关键在于:调用 async_xxx 之后函数立刻返回,真正的完成通知要等你调用 io_context.run() 进入事件循环之后才会触发。这也是初学者最容易搞混的地方——如果忘了调用 run(),你注册的回调函数永远不会被执行。

四、完整代码示例一:同步定时器

先从最简单的场景开始:用一个 steady_timer 同步等待 2 秒。

// 编译命令(Boost 1.69 及以上,io_context 相关的错误处理已经是 header-only):
// g++ -std=c++17 sync_timer.cpp -o sync_timer -lpthread
// 如果链接报错缺符号,再加上 -lboost_system
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
int main() {
    // io_context 是整个 Boost.Asio 程序的"调度中心"
    // 所有 I/O 对象(这里是定时器)都要跟它绑定
    boost::asio::io_context io_context;
    // steady_timer 是基于单调时钟(不会因为系统时间被人为调整而跳变)的定时器
    // 构造函数第二个参数是"从现在开始,多久之后触发",这里设置为 2 秒
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(2));
    std::cout << "开始同步等待 2 秒...\n";
    // wait() 是同步(阻塞)调用:当前线程会原地停住,
    // 直到定时器真正到期才会往下继续执行
    timer.wait();
    std::cout << "2 秒到了,同步等待结束\n";
    return 0;
}

关键点说明:

  • io_context 在这个例子里其实没有真正"跑起来"(没有调用 run()),因为同步操作根本不需要事件循环,wait() 直接由操作系统的定时器机制阻塞当前线程。
  • steady_timer 用的是单调时钟 std::chrono::steady_clock 的语义,适合"从现在起过多久"这种相对时间的场景,不受系统时间被手动调整的影响。

五、完整代码示例二:异步定时器

接下来把上面的例子改成异步版本,体会一下"注册回调 + 事件循环"这套思路。

// 编译命令:
// g++ -std=c++17 async_timer.cpp -o async_timer -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
int main() {
    // 创建执行上下文,之后所有异步操作的完成通知都要靠它来分发
    boost::asio::io_context io_context;
    // 创建一个 2 秒后触发的定时器,绑定到 io_context 上
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(2));
    std::cout << "注册异步等待回调,此时函数会立刻返回,不会阻塞\n";
    // async_wait 不会阻塞当前线程,它只是把"2 秒后要调用的函数"
    // 登记到 io_context 内部的任务队列里,然后立刻返回
    timer.async_wait([](const boost::system::error_code& ec) {
        // 这个 lambda 就是所谓的 handler(完成处理函数)
        // ec 用来表示这次异步操作是否出错(比如定时器被提前取消)
        if (!ec) {
            std::cout << "[回调] 2 秒到了,异步等待结束\n";
        } else {
            std::cout << "[回调] 定时器出错了: " << ec.message() << "\n";
        }
    });
    std::cout << "调用 io_context.run() 之前,回调函数还不会被执行\n";
    // run() 会让 io_context 进入事件循环:
    // 不停地检查有没有已完成的异步操作,一旦定时器到期,
    // 就在这里(当前线程)调用之前注册的 lambda
    // 当队列里所有异步操作都处理完之后,run() 才会返回
    io_context.run();
    std::cout << "io_context.run() 返回,说明所有异步任务都处理完了\n";
    return 0;
}

关键点说明:

  • async_wait 传入的 lambda 就是"完成处理函数(handler)",它不会立刻执行,而是被 io_context 记录下来,等真正的完成事件发生时才会被调用。
  • io_context.run() 是驱动整个异步流程的"引擎"。如果你把 io_context.run() 这一行删掉,程序会直接跑完 main() 退出,你注册的回调函数永远不会被执行——这是初学者最容易踩的坑。
  • run() 会一直阻塞,直到内部没有更多待处理的异步任务为止,这也是为什么这个例子里主线程不需要手动 sleep

六、完整代码示例三:多个异步操作与事件循环

再进一步,同时注册两个不同延迟的定时器,观察 io_context 是怎么按照"谁先完成就先调用谁"的顺序来分发回调的。

// 编译命令:
// g++ -std=c++17 multi_timer.cpp -o multi_timer -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
int main() {
    boost::asio::io_context io_context;
    // 两个定时器共享同一个 io_context,
    // 说明一个执行上下文可以同时驱动多个 I/O 对象
    boost::asio::steady_timer timer_short(io_context, std::chrono::seconds(1));
    boost::asio::steady_timer timer_long(io_context, std::chrono::seconds(3));
    // 先注册"较长"的那个定时器
    timer_long.async_wait([](const boost::system::error_code& ec) {
        if (!ec) {
            std::cout << "[回调] 3 秒定时器触发\n";
        }
    });
    // 再注册"较短"的那个定时器
    // 注意:注册顺序和实际触发顺序没有必然关系,
    // io_context 是根据"谁先真正完成"来决定调用顺序的
    timer_short.async_wait([](const boost::system::error_code& ec) {
        if (!ec) {
            std::cout << "[回调] 1 秒定时器触发\n";
        }
    });
    std::cout << "两个定时器都已注册,进入事件循环\n";
    // run() 会持续处理事件,直到两个定时器的回调都执行完毕才返回
    io_context.run();
    std::cout << "所有定时器都已触发,程序结束\n";
    return 0;
}

预期输出(顺序由实际到期时间决定,而不是注册顺序):

两个定时器都已注册,进入事件循环
[回调] 1 秒定时器触发
[回调] 3 秒定时器触发
所有定时器都已触发,程序结束

七、时序图:异步定时器的完整执行流程

用下面这张时序图把"情况二(单个异步定时器)"的执行过程完整地画出来,可以清楚看到 main 线程、io_context 和操作系统三者之间的分工。

操作系统定时器机制 io_context steady_timer 对象 主线程 main() 操作系统定时器机制 io_context steady_timer 对象 主线程 main() 2 秒过去... 构造 timer(io_context, 2秒) timer.async_wait(handler) 登记"2秒后调用 handler"这个任务 async_wait 立刻返回,不阻塞 io_context.run() 向操作系统请求"2秒后通知我" 进入事件循环,等待完成通知 通知:定时器已到期 调用之前注册的 handler(ec) 在当前线程里执行 lambda 函数体 打印"2秒到了,异步等待结束" 队列已空,run() 返回

八、Boost.Asio 支持的 I/O 对象一览


I/O 对象类型用途
ip::tcp::socketTCP 网络套接字,用于流式网络通信
ip::udp::socketUDP 网络套接字,用于数据报网络通信
steady_timer / system_timer定时器,分别基于单调时钟和系统时钟
ip::tcp::resolver域名解析器,把域名解析成 IP 地址
posix::stream_descriptor对 Linux/Unix 下的文件描述符做异步读写
windows::stream_handle对 Windows 下的 HANDLE 做异步读写
serial_port串口通信

这些 I/O 对象虽然功能各异,但用法上高度一致:都需要绑定一个 io_context,都提供同步版本(阻塞调用)和异步版本(async_ 前缀 + handler 回调 + 依赖 run() 驱动),这正是 Boost.Asio "统一异步模型"这句话的真正含义。

九、小结

  • Boost.Asio 的核心是两个角色:I/O 执行上下文io_context,负责调度和分发完成事件)和 I/O 对象(定时器、socket 等,负责发起具体的操作请求)。
  • 同步调用会阻塞当前线程直到操作完成;异步调用立刻返回,真正的回调执行要等到显式调用 io_context.run() 进入事件循环之后才会发生——这是最容易被新手忽略的一点。
  • 不管是定时器、TCP socket 还是域名解析器,用的都是同一套"同步接口 + async_ 异步接口 + handler 回调"的模式,这也是它能同时支撑网络编程、串口、文件描述符等各种场景的原因。
  • Boost.Asio 在操作系统层面自动选择最合适的机制(比如 Linux 的 epoll),这样你写的异步代码不需要关心底层细节,就能获得跨平台的高效表现。

Boost.Asio 的 I/O 对象体系 —— 从零理解异步 I/O 是怎么组织的

一、先搞清楚:"I/O 对象"到底是什么

写程序时经常需要用到操作系统提供的能力,比如:等一个定时器到期、从网络上收数据、监听一个信号。这些能力本身是操作系统提供的,C++ 程序需要一个"中间人"去跟操作系统打交道,把结果(或者错误)拿回来交给我们的代码用。
Boost.Asio 把这件事拆成了两类角色:

  • I/O 对象(I/O objects):代表一个具体的任务,比如"一个定时器"“一个 TCP 连接”。它是我们直接在代码里创建和使用的东西。
  • I/O 执行上下文对象(I/O execution context):真正负责跟操作系统沟通、把任务派发下去、把结果收回来的"调度中心",最常见的就是 io_context
    一句话理解两者关系:I/O 对象自己不会直接找操作系统办事,它必须通过 I/O 执行上下文对象才能把任务真正提交给操作系统去做。

二、图 9.1 讲的是什么:Boost.Asio 里所有 I/O 对象的分类

在这里插入图片描述

原图把 Boost.Asio 提供的所有 I/O 对象分成了三大类,下面用图表把这套分类关系重新画一遍,方便梳理:

Core Objects 核心对象

Async Model
异步模型

Buffers
缓冲区

Algorithms
算法

Streams

Coroutines
协程

Platform-Specific Objects 平台相关对象

POSIX

Descriptors
文件描述符

Local Sockets
本地套接字

Windows

HANDLEs

Overlapped I/O

Portable Objects 跨平台对象

Networking 网络相关

Sockets
套接字

IPv4 and IPv6

Name Resolution
域名解析

TCP, UDP and ICMP

Timers
定时器

Signal Handling
信号处理

SSL and TLS

Serial Ports
串口

结构上可以这样理解,从下往上看:

  • 最底层:Core Objects(核心对象)。这是整个库的地基,提供异步编程的基础模型(Async Model)、通用的缓冲区(Buffers)、通用算法(Algorithms)、流式读写抽象(Streams),以及协程支持(Coroutines)。不管上层用什么具体功能,底层都靠这些核心机制来运转。
  • 上层左边:Portable Objects(跨平台对象)。这些类在 Windows、Linux、macOS 上代码写法完全一样,包括网络相关的一整套(SocketsIPv4/IPv6、域名解析、TCP/UDP/ICMP),以及定时器、信号处理、SSL/TLS 加密、串口通信。
  • 上层右边:Platform-Specific Objects(平台相关对象)。这部分承认了一个现实:有些操作系统底层机制没法跨平台统一,比如 Windows 用 HANDLE 和重叠 I/O(Overlapped I/O),而类 Unix 系统(POSIX)用文件描述符(Descriptors)和本地套接字(Local Sockets)。用到这部分类,代码就和具体操作系统绑定了。

类别典型代表特点
Core ObjectsAsync Model、Buffers、Streams、Coroutines所有功能的地基,不直接对应某个具体的 I/O 任务
Portable ObjectsSockets、Timers、SSL and TLS一次编写,各平台通用
Platform-Specific ObjectsWindows 的 HANDLE、POSIX 的 Descriptors直接对接某个操作系统的底层机制,不跨平台

三、I/O 对象和执行上下文对象是怎么配合工作的

规则很简单:创建任何一个 I/O 对象时,构造函数的第一个参数几乎都是一个执行上下文对象(比如 io_context)。 这样这个 I/O 对象从出生那一刻起,就知道该找谁去真正执行任务。
书里给的最小例子就是创建一个 3 秒后到期的定时器:

#include <boost/asio.hpp>
#include <chrono>
using namespace std::chrono_literals;
boost::asio::io_context io_context;              // 执行上下文对象:负责真正和操作系统打交道
boost::asio::steady_timer timer(io_context, 3s); // I/O对象:一个定时器,绑定到上面的io_context

逐行看这两句在做什么:

  • boost::asio::io_context io_context;:创建一个"事件调度中心"。它内部维护了一个待处理任务队列,之后所有异步操作的完成通知都会经过它来分发。此时它还没有开始工作,只是准备好了。
  • boost::asio::steady_timer timer(io_context, 3s);:创建一个 I/O 对象——steady_timer(基于单调时钟的定时器,不受系统时间被人为调整的影响)。构造时传入 io_context 作为第一个参数,意味着"这个定时器以后有任务要委托给它去执行";第二个参数 3s 表示这个定时器 3 秒后到期(3sstd::chrono_literals 提供的字面量写法,等价于 std::chrono::seconds(3))。
    单靠这两行代码,定时器只是被创建出来了,还没有真正开始"计时并在到期后通知我们",因为我们还没告诉它到期之后要干什么。

四、I/O 对象的两种用法:异步方法 vs 阻塞方法

大部分 I/O 对象都同时提供两套方法:

方法类型命名规律调用后是否立刻返回需不需要传入回调函数
异步方法方法名以 async_ 开头,例如 async_wait立刻返回,不阻塞当前线程需要,传入一个"完成处理函数"(completion handler)
阻塞方法方法名不带 async_ 前缀,例如 wait会一直卡住直到任务完成才返回不需要,直接用返回值或者往下走

异步方法的工作方式是:调用之后立刻返回,代码可以继续往下执行做别的事情;等到操作系统那边真正完成了这个任务(比如定时器真的到期了),io_context 才会去调用我们提前准备好的"完成处理函数",把结果或者错误交给它处理。

五、完整可运行的代码:用异步方式等待一个定时器

下面这份代码把上面提到的三样东西都串起来了:创建 io_context、创建 steady_timer、用 async_wait 注册一个完成处理函数,最后启动事件循环。

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
int main() {
    // (1) 创建执行上下文对象,它是整个异步任务的"调度中心"
    boost::asio::io_context io_context;
    // (2) 创建一个I/O对象:3秒后到期的定时器,绑定到上面创建的io_context
    boost::asio::steady_timer timer(io_context, 3s);
    // (3) 用异步方法async_wait注册一个"完成处理函数"(completion handler)
    //     这里传入的lambda表达式就是完成处理函数,
    //     参数ec是错误码,用来判断定时器是正常到期还是出错/被取消了
    timer.async_wait([](const boost::system::error_code& ec) {
        if (!ec) {
            std::cout << "定时器到期,3秒已经过去了!" << std::endl;
        } else {
            std::cout << "定时器出错或被取消: " << ec.message() << std::endl;
        }
    });
    // (4) async_wait调用后会立刻返回,不会等3秒,所以这一行会马上被打印出来
    std::cout << "已经注册好定时器,程序没有被阻塞,继续往下跑" << std::endl;
    // (5) 必须调用run(),io_context才会真正开始工作:
    //     它会一直运行,直到所有异步任务都完成、没有任务可做为止
    io_context.run();
    // (6) 只有当io_context.run()返回后(意味着定时器已经到期并且回调执行完了),
    //     才会走到这一行
    std::cout << "程序结束" << std::endl;
    return 0;
}

逐段说明:

  1. io_context 是这段程序里唯一的"调度中心",后面所有异步任务都要挂靠在它身上。
  2. steady_timer 是一个 I/O 对象,构造时就绑定了 io_context,并设置了 3 秒的到期时间,但此时定时器只是"存在",还没开始真正被监控。
  3. async_wait 是异步方法,调用它相当于告诉 io_context:“帮我盯着这个定时器,到期了就执行我给你的这个函数”。这行代码执行完立刻返回,并不会真的等 3 秒。
  4. 因为第 3 步不阻塞,所以这行打印语句几乎是瞬间执行的,早于定时器真正到期。
  5. io_context.run() 才是真正让程序"动起来"的地方:它会阻塞在这里,但不是傻等,而是持续处理事件(这里就是等待底层操作系统告诉它"3 秒到了"),一旦所有异步任务都处理完毕,run() 才会返回。
  6. 走到这里说明定时器已经到期,完成处理函数(打印"定时器到期"那一段)也已经执行完了。
    编译方式(假设已安装 Boost 库,且使用的是非 header-only 的独立 Asio 或系统 Boost):
g++ -std=c++17 timer_example.cpp -o timer_example -lboost_system -lpthread

预期输出(注意两行打印之间大约相隔 3 秒):

已经注册好定时器,程序没有被阻塞,继续往下跑
定时器到期,3秒已经过去了!
程序结束

六、时序图:这几行代码运行时到底发生了什么

操作系统 io_context main() 操作系统 io_context main() 只是登记了任务和回调 立刻返回,不阻塞 run()内部阻塞等待事件 但不是空转浪费CPU 创建 io_context 创建 steady_timer(io_context, 3s) timer.async_wait(回调函数) async_wait() 立刻返回 打印"没有被阻塞,继续往下跑" io_context.run() 请求在3秒后通知自己 3秒后,定时器到期事件到达 调用之前登记的回调函数 回调函数内打印"定时器到期" 没有其他待处理任务了,run()返回 打印"程序结束"

从这张图能看出两个关键点:

  • async_wait() 只负责"登记任务",真正的等待和唤醒都发生在 io_context.run() 内部。
  • 完成处理函数(回调)不是在调用 async_wait 的那一刻执行的,而是在 run() 运行期间、任务真正完成时才会被调用。这也是为什么"注册回调"和"回调被执行"这两件事在时间上是分开的。

七、小结


概念作用例子
I/O 执行上下文对象真正和操作系统沟通、调度所有异步任务io_context
I/O 对象代表某个具体任务,构造时必须绑定一个执行上下文对象steady_timer
异步方法async_ 开头,立刻返回,完成后触发回调async_wait
阻塞方法没有 async_ 前缀,调用后一直卡住直到完成wait
完成处理函数(handler)任务完成后被调用的可调用对象,一般是lambda[](const error_code& ec){...}

理解这套体系最重要的一点是:I/O 对象只是"任务描述",真正的执行和调度全部交给了执行上下文对象,而只有调用了 run()(或者它的变体)之后,整个异步机制才会真正转起来。这也是为什么几乎所有 Boost.Asio 程序的结构都长得差不多:先创建 io_context,再创建若干 I/O 对象并注册回调,最后调用一次 io_context.run() 收尾。

I/O 执行上下文对象(io_context)详解

1. 整体架构:应用程序、I/O对象、执行上下文、操作系统服务是怎么串起来的

要使用操作系统提供的 I/O 能力,程序至少需要一个 I/O 执行上下文对象,它扮演的角色就是通往操作系统 I/O 服务的"大门"。在 Boost.Asio 里,这个角色由 boost::asio::io_context 这个类来实现,它把操作系统 I/O 服务的核心功能包装好,提供给各个 I/O 对象去使用。
把这几层关系画出来大概是这样:

操作系统 Operating System

应用程序 Application

I/O对象 I/O Object

I/O执行上下文对象
I/O Execution Context Object

I/O服务 I/O Service

从这张图能看出来几个关键点:

  • 应用程序既可以直接和 I/O 对象打交道(比如调用 timer.async_wait(...)),也可以直接和执行上下文对象打交道(比如调用 io_context.run());
  • I/O 对象自己并不直接找操作系统要资源,而是通过执行上下文对象去转达;
  • 真正干活的"I/O 服务"藏在操作系统内部,应用程序完全不需要关心它具体是怎么实现的。
    而这个"具体怎么实现",在不同操作系统上其实是不一样的:

操作系统io_context底层依赖的机制
WindowsI/O完成端口(IOCP)
Linuxepoll
FreeBSD/macOSkqueue

这些差异全部被 io_context 封装起来了,我们写的业务代码在三个平台上不需要做任何改动。

2. io_context 在类体系里的位置

boost::asio::io_contextboost::asio::execution_context 的子类,而 execution_context 是"可以执行函数对象"的这一类上下文对象的公共基类,除了 io_context,还有别的执行上下文也继承自它:

类名说明
boost::asio::execution_context所有"可以执行函数对象"的上下文对象的基类
boost::asio::io_context本章主要使用的执行上下文,基于操作系统的I/O机制实现
boost::asio::thread_pool另一种执行上下文,内部自己维护一个线程池
boost::asio::system_context另一种执行上下文,使用系统级别的线程池

还有一点值得说一下:io_context 从 Boost 1.66.0 版本开始,是用来替代老的 io_service 类的,写法上拥抱了更多现代 C++ 的特性和习惯。io_service 这个类目前仍然保留着,纯粹是为了兼容老代码,新代码建议都用 io_context

3. 关键方法:run() 和事件循环

Boost.Asio 的对象可以用名字以 async_ 开头的方法去调度异步操作。等所有想要执行的异步任务都安排好之后,程序需要调用 io_context::run() 来真正启动一个"事件处理循环":这个循环会让操作系统去处理这些任务,任务做完之后,把结果交回程序,并触发对应的回调函数(也叫完成处理函数,completion handler)。
run() 是一个阻塞调用,它的存在就是为了让这个事件循环能一直转下去,让异步操作真正得以执行,同时防止程序过早退出。当然,也完全可以把 run() 放到一个新线程里去跑,让主线程可以继续做别的事情。
还有一点很重要:当没有任何未完成的异步任务时,run() 会自动返回。这既方便(任务做完程序就能顺利结束),也可能带来麻烦(如果我们还打算过一会儿再往里塞新任务,run() 可能已经提前退出了)。后面第 5 节会讲怎么解决这个问题。

4. 例1:最基础的定时器例子

先看一个只用一个定时器的最简单例子:

#include <boost/asio.hpp>
#include <iostream>
// 定时器到期后会被调用的"完成处理函数"(completion handler)。
// 参数ec是错误码:如果定时器正常到期,ec不带错误(此时!ec为true);
// 如果定时器出错或者中途被取消,ec里会带上具体的错误信息。
void on_timeout(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired.\n" << std::endl;
    } else {
        std::cerr << "Error: " << ec.message() << '\n';
    }
}
int main() {
    // io_context:I/O执行上下文对象,所有I/O对象都要依附于它才能工作
    boost::asio::io_context io_context;
    // steady_timer:一个I/O对象,构造时传入io_context和"3秒后到期"
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(3));
    // 把on_timeout注册为这次等待的完成处理函数,
    // 这一行会立刻返回,不会真的等3秒
    timer.async_wait(&on_timeout);
    // run()启动事件循环,阻塞在这里,直到所有异步任务都处理完毕
    io_context.run();
    return 0;
}

编译运行:

g++ -std=c++17 example1.cpp -o example1 -lboost_system -lpthread
./example1

大约3秒后,控制台会打印出 Timer expired.;如果这次异步调用出于某种原因失败了,则会打印错误信息。
整个过程的时序如下:

操作系统 io_context(I/O执行上下文) steady_timer(I/O对象) main函数 操作系统 io_context(I/O执行上下文) steady_timer(I/O对象) main函数 大约3秒过去 创建io_context 创建timer,绑定io_context,设置3秒后到期 async_wait(&on_timeout) 登记这个等待任务 请求操作系统在3秒后通知自己 立刻返回,不阻塞 io_context.run(),进入事件循环并阻塞 通知定时器已到期 调用on_timeout(ec) 打印"Timer expired." 没有更多任务了,run()自然返回

5. 为什么需要 work_guard:让 io_context 别急着退出

刚才说过,run() 一旦发现没有待处理的任务就会自动返回。但假设我们的场景是:程序刚启动的时候还没有任何任务,但过一会儿(比如另一个线程算完东西之后)会往 io_context 里塞新的工作——这种情况下,如果 run() 因为"暂时没任务"就先退出了,那后面塞进来的任务就没人处理了。
Boost.Asio 为此提供了一个模板类 boost::asio::executor_work_guard:只要这个"工作守卫"对象还活着,io_context 就会认为"还有活要干",run() 就不会因为暂时没有任务而提前退出,一直等到我们主动放弃这个守卫(调用 reset())为止。

6. 例2:用 work_guard 配合多线程投递任务

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
// 这是一个"后台任务":先睡2秒,然后往io_context里"投递"(post)一份新工作。
// post()的作用是把一个普通的函数对象直接扔给io_context,
// 让它在事件循环里找机会执行,效果和async_wait()类似,
// 都是给io_context增加了一项"待处理任务"。
void background_task(boost::asio::io_context& io_context) {
    std::this_thread::sleep_for(2s);
    std::cout << "Posting a background task.\n";
    io_context.post([]() {
        std::cout << "Background task completed!\n";
    });
}
int main() {
    boost::asio::io_context io_context;
    // work_guard:只要这个对象存在,就会让io_context.run()
    // 认为"还有事情要做",不会因为暂时没有待处理任务而提前退出。
    auto work_guard = boost::asio::make_work_guard(io_context);
    // io_thread专门负责跑io_context的事件循环
    std::thread io_thread([&io_context]() {
        std::cout << "Running io_context.\n";
        io_context.run(); // 因为有work_guard,这里不会立刻返回
        std::cout << "io_context stopped.\n";
    });
    // worker线程运行background_task,2秒后往io_context里投递一份工作
    std::thread worker(background_task, std::ref(io_context));
    // 主线程先做"自己的事情",这里用睡5秒来模拟
    std::this_thread::sleep_for(5s);
    std::cout << "Removing work_guard." << std::endl;
    // 放弃work_guard之后,io_context发现没有活儿要干了(且没有别的work_guard存在),
    // 就会让run()自然返回
    work_guard.reset();
    worker.join();
    io_thread.join();
    return 0;
}

编译运行方式和例1一样,运行后大致会看到:

Running io_context.
Posting a background task.
Background task completed!
Removing work_guard.
io_context stopped.

可以看到,后台线程正确地投递了任务并且顺利完成,全都发生在主线程移除 work_guard、io_context 真正停止运行之前。
三个"角色"(主线程、io_thread、worker 线程)之间的配合关系如下:

io_context worker线程 io_thread 主线程(main) io_context worker线程 io_thread 主线程(main) 5秒后主线程醒来 创建io_context和work_guard 启动io_thread io_context.run()(有work_guard在,不会立刻返回) 启动worker线程,运行background_task 主线程sleep_for(5s) sleep_for(2s) io_context.post(打印完成信息的lambda) 在run()内部执行这个被post进来的任务 打印"Background task completed!" work_guard.reset() 告知io_context已经没有work_guard了 run()发现没有待处理任务,自然返回 打印"io_context stopped." worker.join() io_thread.join()

7. 例3:处理函数里不断重新调度自己

还有一种常见的做法是:不用 work_guard,而是在完成处理函数里面继续安排新的异步任务,这样只要处理函数一直在重新调度自己,io_context 就会一直"有活干"。这个模式在读写 socket、做周期性定时器时特别常见。

#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <iostream>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    // 定时器初始设置为3秒后到期
    boost::asio::steady_timer timer(io_context, 3s);
    // 说明: 原始的写法是让这个定时器无限重复下去(每隔1秒触发一次,永不停止)。
    // 为了让这份演示代码能自动跑完并退出,这里额外加了一个计数器,
    // 重复满3次之后就不再重新安排新的等待,这样io_context.run()才会自然返回。
    int remaining_ticks = 3;
    // timer_handler用std::function包装,是为了让lambda内部能捕获"自己",
    // 从而实现"处理函数执行完之后,把自己重新注册一遍"这种递归式的调度。
    std::function<void(const boost::system::error_code&)> timer_handler;
    timer_handler = [&timer, &timer_handler, &remaining_ticks](
                        const boost::system::error_code& ec) {
        if (!ec) {
            std::cout << "Handler: Timer expired.\n";
            --remaining_ticks;
            if (remaining_ticks > 0) {
                // 在当前到期时间的基础上,重新设置1秒之后到期
                timer.expires_after(1s);
                // 再次把自己注册为下一次到期时的处理函数,
                // 由此形成"每隔1秒触发一次"的效果
                timer.async_wait(timer_handler);
            } else {
                std::cout << "达到重复次数上限,不再重新调度。\n";
            }
        } else {
            std::cerr << "Handler error: " << ec.message() << std::endl;
        }
    };
    // 第一次注册,3秒后触发
    timer.async_wait(timer_handler);
    io_context.run();
    return 0;
}

编译运行方式同上,运行后会看到 Handler: Timer expired. 先在第3秒打印一次,然后每隔1秒再打印一次,一共打印3次,最后打印"达到重复次数上限,不再重新调度。"然后程序自动退出。
这个"自己重新调度自己"的过程画成时序图是这样的:

timer_handler io_context steady_timer main函数 timer_handler io_context steady_timer main函数 3秒后 1秒后 创建timer,3秒后到期 async_wait(timer_handler) 第一次注册 io_context.run() 调用timer_handler(ec) 打印Timer expired,remaining_ticks减1 expires_after(1s) async_wait(timer_handler),再次注册自己 再次调用timer_handler(ec) 重复以上过程,直到remaining_ticks减到0 不再调用async_wait,自我调度到此结束 没有待处理任务了,run()返回

8. 三种"让 io_context 保持忙碌"的方式小结

把上面三个例子归纳一下,可以看出让 io_context.run() 不提前退出的三种典型做法:

保持io_context存活的方式适用场景
还有尚未完成的异步操作(例1里的async_wait)最基础的情形,操作一旦做完,run()理所应当地退出
使用work_guard(例2)暂时不确定什么时候会有新任务,但又不希望run()提前退出
在完成处理函数里不断重新调度新的异步任务(例3)读写socket、周期性定时器等需要"持续运转"的场景

9. 小结与下一步

到这里我们已经搞清楚了 io_context 的核心作用:它是应用程序和操作系统 I/O 服务之间的桥梁,靠 run() 驱动一个事件循环,把异步操作的完成情况分发给对应的处理函数。默认情况下,io_context 本身是线程安全的,可以从任意线程调用它的 run()。不过在某些追求极致性能的场景下,我们可能反而想主动放弃这份"默认的线程安全",这个在构造 io_context 的时候是可以调整的——这也是接下来要讲的内容。

并发提示(Concurrency Hints)

一、io_context 构造函数里的这个参数到底是干什么的

io_context 的构造函数可以接收一个叫做"并发提示"(concurrency hint)的参数。这个参数不是用来限制你到底能用几个线程调用 run(),它更像是你提前告诉 io_context 内部实现:“嘿,我大概会用几个线程来跑你,你可以根据这个预期去做一些内部优化”。
换句话说,这个参数的作用范围仅限于"帮助内部实现做优化决策",并不会真的强制你只能用某个数量的线程。

二、默认值:BOOST_ASIO_CONCURRENCY_HINT_SAFE

如果你什么都不指定,直接写:

boost::asio::io_context io_context;

那么它内部使用的默认并发提示是 BOOST_ASIO_CONCURRENCY_HINT_SAFE,对应的数值是 1。这个值告诉内部实现:“预期只会有一个线程在跑 run()”,于是实现可以据此做一些针对单线程场景的优化(比如减少一些不必要的锁开销)。
但这里有个容易理解错的地方:这个默认值并不意味着 io_context 只能被一个线程使用。它依然是线程安全的,你依然可以:

  • 从多个不同的线程里,各自持有并操作不同的 I/O 对象(比如线程 A 用某个 socket,线程 B 用另一个定时器);
  • 甚至可以从多个线程里同时调用同一个 io_contextrun()(这是构建线程池处理网络请求的经典模式)。
    只不过因为默认按"单线程"做了优化,如果你真的从多线程调用 run(),内部该加的锁还是会加,只是说"默认假设"是单线程场景而已。

三、三种并发提示的取值对比

除了默认的 SAFE,Boost.Asio 还提供了另外两个更激进的选项,它们都是以牺牲一部分线程安全换取更高性能为代价的:

取值含义使用限制
BOOST_ASIO_CONCURRENCY_HINT_SAFE(默认值,数值 1)假设只有一个线程调用 run(),据此做内部优化,但仍然保持完整的线程安全依然可以从多线程使用 I/O 对象,也可以从多线程调用 run(),只是没有针对多线程场景专门优化
BOOST_ASIO_CONCURRENCY_HINT_UNSAFE彻底关闭内部加锁所有涉及 io_context 或其 I/O 对象的操作,必须全部发生在同一个线程里,不能跨线程使用
BOOST_ASIO_CONCURRENCY_HINT_UNSAFE_IO只关闭"反应器(reactor)"内部的加锁,"调度器(scheduler)"部分依然保留加锁run() 以及其他跟事件处理循环相关的函数必须在同一个线程里调用,但除此之外,io_context 的其他操作可以从不同线程使用

这里提到的"调度器(scheduler)"和"反应器(reactor)"是 Boost.Asio 内部的设计概念,会在后面讲解这个库整体设计原理的时候详细展开,这里只需要知道:反应器负责监听底层的 I/O 事件(比如 socket 可读了),调度器负责把这些事件对应的回调函数分发出去执行UNSAFE_IO 就是允许你跳过反应器那部分的加锁开销,但调度、分发这部分的加锁还是保留的。
用一张图来直观感受一下三者的区别:

SAFE(默认)
    run() 线程 A   run() 线程 B   run() 线程 C
         \              |              /
          \             |             /
           +--- io_context(内部有完整加锁保护)---+
                          |
              可以安全地被多个线程同时调用
UNSAFE
    run() 只能在唯一的一个线程里调用
    所有 I/O 对象的操作也只能在这同一个线程里进行
    (内部完全没有加锁,跨线程使用就是未定义行为)
UNSAFE_IO
    run() 必须只在一个线程里调用(调度器仍加锁保护这部分)
              |
    但 I/O 对象本身的其他操作(不涉及事件循环)
    仍然可以从不同线程安全调用(反应器部分不加锁)

四、完整代码示例一:默认并发提示下的多线程处理

下面这个例子演示了默认的 SAFE 提示下最常见的用法:开一个线程池,让多个线程一起调用同一个 io_contextrun(),这样多个异步任务的回调函数就能被并发地执行,而不需要你自己手写锁。

// 编译命令:
// g++ -std=c++17 concurrency_hint_safe.cpp -o concurrency_hint_safe -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
// 用一个互斥锁保护 std::cout,避免多线程同时打印时输出内容互相交错
std::mutex cout_mutex;
int main() {
    // 不显式传入并发提示参数,此时内部使用默认值 BOOST_ASIO_CONCURRENCY_HINT_SAFE
    // 表示"默认假设单线程运行",但依然保证完整的线程安全
    boost::asio::io_context io_context;
    // work_guard 的作用是防止 io_context 在还没有任何异步任务注册的时候,
    // run() 因为"队列是空的"而立刻返回
    // 后面注册完定时器之后,这个 guard 会被显式释放,让 run() 能正常退出
    auto work_guard = boost::asio::make_work_guard(io_context);
    // 创建 5 个定时器,每个的到期时间依次错开,
    // 用来模拟"多个异步任务陆续完成"的场景
    std::vector<std::unique_ptr<boost::asio::steady_timer>> timers;
    for (int i = 1; i <= 5; ++i) {
        auto timer = std::make_unique<boost::asio::steady_timer>(
            io_context, std::chrono::milliseconds(i * 200));
        // 每个定时器的回调函数里打印自己是被哪个线程执行的,
        // 这样就能观察到多个线程在真正并发地处理这些回调
        timer->async_wait([i](const boost::system::error_code& ec) {
            if (!ec) {
                std::lock_guard<std::mutex> lock(cout_mutex);
                std::cout << "[定时器 " << i << "] 触发,"
                          << "由线程 " << std::this_thread::get_id()
                          << " 执行\n";
            }
        });
        timers.push_back(std::move(timer));
    }
    // 所有定时器都注册完了,释放 work_guard,
    // 这样当队列里的任务全部处理完之后,run() 就能正常返回,不会一直卡住
    work_guard.reset();
    // 开 3 个工作线程,每个线程都独立调用同一个 io_context 的 run()
    // 这正是默认 SAFE 提示下的经典用法:多线程共享同一个事件循环
    std::vector<std::thread> pool;
    for (int i = 0; i < 3; ++i) {
        pool.emplace_back([&io_context]() {
            io_context.run(); // 多个线程在这里"抢"着执行队列里的回调
        });
    }
    // 等待所有工作线程结束
    for (auto& t : pool) {
        t.join();
    }
    std::cout << "所有定时器回调都已处理完毕,程序结束\n";
    return 0;
}

关键点说明:

  • boost::asio::make_work_guard(io_context) 创建了一个"占位任务",防止 io_context 在还没注册任何异步操作之前就因为队列为空而让 run() 立刻返回。这是编写多线程 Asio 程序时的常见写法。
  • 三个线程都调用了同一个 io_context.run(),Boost.Asio 内部会自动帮你把不同的完成回调分发给这几个空闲的线程去执行,你完全不需要自己写锁去协调"谁去执行哪个回调"。
  • 输出里你会看到不同的线程 ID 在处理不同的定时器回调,这就是默认 SAFE 提示下"内部依然线程安全"的直接体现。

五、完整代码示例二:显式指定并发提示

如果你确定整个程序只会用一个线程跑 io_context,可以显式传入 BOOST_ASIO_CONCURRENCY_HINT_UNSAFE,跳过内部加锁,换取更高的性能。下面演示怎么构造这样一个 io_context

// 编译命令:
// g++ -std=c++17 concurrency_hint_unsafe.cpp -o concurrency_hint_unsafe -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
int main() {
    // 显式传入 BOOST_ASIO_CONCURRENCY_HINT_UNSAFE,
    // 告诉内部实现:"我保证只会用一个线程来操作这个 io_context 和它下面的 I/O 对象"
    // 这样内部就可以完全跳过加锁,换取更好的性能
    boost::asio::io_context io_context(BOOST_ASIO_CONCURRENCY_HINT_UNSAFE);
    boost::asio::steady_timer timer(io_context, std::chrono::milliseconds(500));
    timer.async_wait([](const boost::system::error_code& ec) {
        if (!ec) {
            // 这里绝对不能有第二个线程同时操作 io_context 或者 timer,
            // 一旦跨线程使用就是未定义行为(可能崩溃,也可能表现得"看起来正常"但其实数据已经损坏)
            std::cout << "[定时器] 触发,当前是 UNSAFE 模式,单线程运行\n";
        }
    });
    // 全程只用主线程一个线程来调用 run(),符合 UNSAFE 模式的使用要求
    io_context.run();
    std::cout << "程序结束\n";
    return 0;
}

关键点说明:

  • 这里传给构造函数的参数其实就是一个普通的 int 常量,BOOST_ASIO_CONCURRENCY_HINT_UNSAFE 只是给这个整数值起的一个有意义的名字,方便阅读代码时一眼看出意图。
  • 一旦选择了 UNSAFE,就相当于你在跟编译器和库做了一个"君子协定":你保证不会跨线程使用它。如果违反这个约定,库不会帮你检查,也不会报错,而是直接表现为未定义行为——这也是为什么这个例子里从头到尾只用主线程一个线程。

六、时序图:多线程处理同一个 io_context 的完成事件

用时序图把"代码示例一"里三个线程分工处理定时器回调的过程画出来:

工作线程 3 工作线程 2 工作线程 1 io_context(内部任务队列) 主线程 工作线程 3 工作线程 2 工作线程 1 io_context(内部任务队列) 主线程 定时器陆续到期,完成事件不断进入队列 队列已空 注册 5 个定时器的 async_wait(handler) work_guard.reset(),允许 run() 在队列清空后退出 启动线程,调用 io_context.run() 启动线程,调用 io_context.run() 启动线程,调用 io_context.run() 分发第 1 个完成事件(handler) 分发第 2 个完成事件(handler) 执行 handler,打印"定时器 1 触发" 执行 handler,打印"定时器 2 触发" 分发第 3 个完成事件(handler) 执行 handler,打印"定时器 3 触发" 分发第 4 个完成事件(T1 处理完后又领到新任务) 执行 handler,打印"定时器 4 触发" 分发第 5 个完成事件 执行 handler,打印"定时器 5 触发" run() 返回 run() 返回 run() 返回 线程结束,join() 完成 线程结束,join() 完成 线程结束,join() 完成 打印"所有定时器回调都已处理完毕"

七、小结

  • 并发提示是构造 io_context 时给出的一个"性能优化建议",用来告诉内部实现你大概会用几个线程去跑它,但它本身不会限制你实际使用的线程数量。
  • 默认值 SAFE 假设单线程场景来做优化,但依然保持完整的线程安全,既可以从多个线程操作不同的 I/O 对象,也可以让多个线程一起调用 run(),这是构建线程池处理并发任务的常见模式。
  • UNSAFE 会彻底关闭内部加锁,换来更高性能,但代价是你必须严格保证所有相关操作都发生在同一个线程里,否则就是未定义行为。
  • UNSAFE_IO 是一个折中方案:只关闭"反应器"部分(负责监听底层 I/O 事件)的加锁,"调度器"部分(负责分发回调)依然保留锁保护,所以 run() 必须限定在单线程调用,但其他 I/O 对象的操作仍然可以跨线程使用。

Boost.Asio 的事件处理循环 —— run()、poll()、stop() 到底在干什么

一、先建立一个直观印象:事件循环是什么

前面提到,io_context 是负责调度所有异步任务的"调度中心"。但光创建它、往里面塞任务是不够的,必须有一个东西真正"转起来",不断去检查"有没有任务完成了、该不该通知谁",这个不断循环检查的过程就叫事件处理循环(event processing loop)
io_context 内部维护了一个任务队列,凡是通过 async_xxx() 方法或者 post()/dispatch() 提交进来的任务,都会先被放进这个队列。事件循环的工作就是:不断从队列里取出"已经准备好可以执行"的任务(比如定时器到期了、数据到达了),然后调用对应的完成处理函数(handler)。
调用 io_context::run() 就是启动这个事件循环最直接的方式:
run() = 不断执行事件循环,直到队列中没有任何未完成的任务 \text{run()} = \text{不断执行事件循环,直到队列中没有任何未完成的任务} run()=不断执行事件循环,直到队列中没有任何未完成的任务
也就是说,run()阻塞当前线程,一直卡在那里处理任务,直到所有异步任务都完成、所有对应的完成处理函数都被调用过一遍,才会返回。

二、除了 run(),还有一堆"控制事件循环节奏"的方法

如果每次都要一次性把所有任务跑完才能拿回控制权,这在很多场景下并不方便。比如你可能只想"处理一下已经准备好的任务,别的先不管",或者"最多跑 100 毫秒就必须回来做别的事"。Boost.Asio 为此提供了好几种变体:

方法名行为说明会不会阻塞一次处理几个任务
run()一直运行,直到所有任务都完成阻塞,直到没有任务为止不限,处理完所有
poll()只执行当前"已经就绪"的任务,不等待新任务出现几乎不阻塞不限,但只处理已就绪的
poll_one()只执行一个已经就绪的任务几乎不阻塞最多1个
run_one()运行事件循环,最多执行一个任务,如果没有任务就绪会等待会阻塞直到有任务可执行最多1个
run_for(时长)在指定的一段时长内运行事件循环阻塞,但最多阻塞这么久不限,时间到就停
run_until(时间点)运行到某个具体的时间点为止阻塞,但最多到这个时间点不限,时间到就停
run_one_for(时长)在指定时长内,最多执行一个任务阻塞,但最多这么久最多1个
run_one_until(时间点)到指定时间点为止,最多执行一个任务阻塞,但最多到这个时间点最多1个

从这张表可以看出一个规律:这些方法基本是从两个维度组合出来的——“要不要限制只执行1个任务”“要不要限制运行的时间长度”。理解了这个规律,记住这些方法名字就会容易很多。

三、事件循环怎么被"暂停"和"恢复"

除了让事件循环自然跑完,还可以主动打断它:

  • io_context::stop():强制让事件循环停下来(即使还有任务没处理完)。
  • io_context::stopped():查询当前事件循环是不是已经处于停止状态,返回 true/false
    有一个细节很容易搞混:调用 stop() 之后,正在执行中的那个任务不会被打断,会正常执行完;但队列里还排队等着的其他任务会保持"待处理(pending)"状态,不会被继续处理。
    如果之后想让这些还没处理的任务继续跑,只需要重新调用前面表格里提到的任意一种"启动事件循环"的方法即可(比如再调用一次 run()),之前积压的任务就会接着被处理。不过要注意,io_context 一旦进入 stopped 状态,直接再调用 run() 是不会生效的,需要先调用 restart() 把停止状态清除掉。

四、完整可运行的代码:亲眼看看 run_one / stop / stopped / restart 的效果

下面这个例子往 io_context 里塞了三个任务,然后依次演示:只跑一个任务、检查状态、强制停止、停止后 run() 不生效、重启后剩下的任务继续被处理。

#include <boost/asio.hpp>
#include <iostream>
int main() {
    // (1) 创建事件调度中心,此时里面还没有任何任务
    boost::asio::io_context io_context;
    // (2) 用post()往io_context的任务队列里塞三个任务
    //     post()提交的任务不会立刻执行,必须等事件循环真正运行起来才会被处理
    boost::asio::post(io_context, [] { std::cout << "任务A执行" << std::endl; });
    boost::asio::post(io_context, [] { std::cout << "任务B执行" << std::endl; });
    boost::asio::post(io_context, [] { std::cout << "任务C执行" << std::endl; });
    std::cout << "===== 第一次调用run_one(),只执行一个任务 =====" << std::endl;
    io_context.run_one();   // (3) 事件循环只跑一次,处理一个已经就绪的任务后立刻返回
    std::cout << "===== 检查io_context是否已经停止 =====" << std::endl;
    // (4) 这里预期是false,因为我们只是执行完了一个任务,并没有调用stop()
    std::cout << "stopped() = " << std::boolalpha << io_context.stopped() << std::endl;
    std::cout << "===== 调用stop(),强制停止事件循环 =====" << std::endl;
    io_context.stop();      // (5) 主动叫停,此时任务B、C还没被执行,处于pending状态
    std::cout << "stopped() = " << std::boolalpha << io_context.stopped() << std::endl;
    std::cout << "===== stop()生效后再调用run(),不会执行任何任务 =====" << std::endl;
    io_context.run();       // (6) 因为已经是stopped状态,这次run()什么都不会做,立刻返回
    std::cout << "===== 调用restart()清除停止状态,再run()继续跑完剩下任务 =====" << std::endl;
    io_context.restart();   // (7) 把stopped状态重置掉,让事件循环重新可以被启动
    io_context.run();       // (8) 这次会把队列里剩下的任务B、C都执行完,才返回
    std::cout << "程序结束" << std::endl;
    return 0;
}

编译方式:

g++ -std=c++17 event_loop.cpp -o event_loop -lboost_system -lpthread

逐段解释这份代码:

  1. 创建 io_context,这一步只是准备好调度中心,还没有任何任务。
  2. boost::asio::post(io_context, 回调函数) 是一种"往任务队列里塞一件事,让事件循环有空的时候去做"的方式,跟异步方法(比如 async_wait)的效果类似,只不过这里的任务不依赖任何具体的 I/O 设备,纯粹就是"找个机会执行一下这个函数"。三次 post() 之后,队列里依次排着任务 A、B、C。
  3. run_one() 只让事件循环处理一个已经就绪的任务,处理完(打印"任务A执行")就立刻返回,不会继续处理 B 和 C。
  4. 这时候查询 stopped(),结果是 false——事件循环只是暂时没有继续跑而已,并不是"被停止"了,这是两个不同的概念。
  5. 调用 stop() 之后,io_context 进入停止状态,此时 stopped() 会返回 true。队列里的任务 B、C 依然保留着,只是不会被处理。
  6. 因为 io_context 已经处于 stopped 状态,直接再调用 run() 不会有任何效果,会立刻返回,不会打印任何"任务xx执行"的内容。
  7. restart() 的作用就是把 stopped 状态清除掉,让 io_context 恢复到"可以正常运行事件循环"的状态,但注意它不会清空任务队列,之前排队的任务 B、C 依然还在。
  8. 这次调用 run(),因为状态已经恢复正常,事件循环会继续处理剩下的任务,把 B 和 C 都执行完才返回。
    预期的运行结果:
任务A执行 ===== 检查io_context是否已经停止 =====
stopped() = false ===== 调用stop(),强制停止事件循环 =====
stopped() = true ===== stop()生效后再调用run(),不会执行任何任务 ===== ===== 调用restart()清除停止状态,再run()继续跑完剩下任务 =====
任务B执行
任务C执行
程序结束

五、时序图:把上面的执行过程画出来

任务队列(A,B,C) io_context main() 任务队列(A,B,C) io_context main() 内部状态被标记为stopped B,C保持pending,不会被执行 因为已经stopped 立刻返回,什么都不做 清除stopped状态 队列里的B,C依然保留 post(任务A) post(任务B) post(任务C) run_one() 取出任务A 执行任务A(打印"任务A执行") run_one()返回 队列里还剩B,C stopped() 返回false stop() stopped() 返回true run() restart() run() 取出任务B 执行任务B(打印"任务B执行") 取出任务C 执行任务C(打印"任务C执行") 队列已空,run()返回

从图里能清楚看到两个关键行为:stop() 只是"暂停处理",不会丢掉还没处理的任务;而 restart() 只负责恢复"可以运行"的状态,真正把剩余任务处理掉靠的还是之后再调用一次 run()(或其他启动事件循环的方法)。

六、小结


概念作用一句话记忆
事件处理循环不断检查并执行已完成任务对应的handlerio_context真正干活的地方
run()阻塞运行直到所有任务完成“全部跑完我再走”
poll() / poll_one()只处理已就绪的任务,不等待“能做多少做多少,不等”
run_one() / run_one_for() / run_one_until()最多只处理一个任务“干一件事就回来”
run_for() / run_until()限定运行的时间范围“最多陪你跑这么久”
stop()强制让事件循环停下来“喊停,未完成的先放着”
stopped()查询是否处于停止状态“问一下现在是不是停着的”
restart()清除停止状态,允许事件循环再次运行“解除禁令,可以再跑了”

理解事件循环最核心的一点是:io_context 本身只是一个"任务队列+调度逻辑"的容器,真正让任务被执行的动作,都要靠显式调用 run() 或它的某个变体去触发;而 stop()/stopped()/restart() 这一组方法,则是用来控制"这个调度到底在不在运转"的开关,跟任务本身有没有被处理完是两件不完全相同的事情。

用同步的方式和操作系统打交道

1. 同步操作大致是什么感觉

Boost.Asio 里的 I/O 对象基本上都同时提供两套方法:一套是同步的(比如 wait()),一套是异步的(比如 async_wait())。这一节先讲同步的这一套。
用同步方式的时候,通常做法很直接:创建一个 I/O 对象,然后直接调用它的同步方法,比如:

boost::asio::io_context io_context;
boost::asio::steady_timer timer(io_context, 3s);
timer.wait();

调用 timer.wait() 之后,这个请求会先交给 I/O 执行上下文对象(也就是 io_context),由它去请操作系统真正执行这个操作。操作系统把任务做完之后,会把结果交还给 io_contextio_context 再把结果(或者出错时的错误信息)转交给发起请求的 I/O 对象(这里是 timer)。
用一个简单的类比来说:

同步:  你 --调用wait()--> [ 一直卡在这里,什么都不干 ] --结果出来了--> 你才能继续往下走
异步:  你 --调用async_wait()--> 你可以立刻去做别的事... 直到某一刻回调函数突然被触发

同步方式最大的特点就是:调用的线程会被阻塞,一直卡在这一行,直到操作系统把事情办完才会往下走。

2. 完整例子1:最基本的同步等待

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
int main() {
    // io_context:I/O执行上下文对象,所有I/O对象都要依附于它
    boost::asio::io_context io_context;
    // steady_timer:一个I/O对象,构造时传入io_context和"3秒后到期"
    boost::asio::steady_timer timer(io_context, 3s);
    std::cout << "开始同步等待,这一行调用会阻塞3秒...\n";
    try {
        // 同步等待:调用这一行的线程会一直卡住,
        // 直到操作系统真正把这个定时任务处理完
        timer.wait();
        std::cout << "定时器到期,程序继续往下走\n";
    } catch (const boost::system::system_error& e) {
        // 默认情况下,如果操作出错,wait()会抛出这种类型的异常
        std::cerr << "等待过程中出错: " << e.what() << '\n';
    }
    return 0;
}

编译运行:

g++ -std=c++17 sync_wait.cpp -o sync_wait -lboost_system -lpthread
./sync_wait

运行后大约3秒才会看到第二行输出,因为 timer.wait() 这一整行代码在这3秒里都不会返回。
整个同步等待的过程画成时序图是这样的(可以对比一下之前异步例子里 async_wait() 立刻返回的画法,区别一目了然):

操作系统 io_context(I/O执行上下文) steady_timer(I/O对象) 调用线程 操作系统 io_context(I/O执行上下文) steady_timer(I/O对象) 调用线程 调用线程被阻塞在这一行,什么都干不了 操作系统真正去等,直到定时器到期 timer.wait() 把这次等待请求转交给io_context 请求操作系统执行这个等待操作 操作完成,把结果(或错误)返回给io_context 把结果翻译好之后交给timer wait()此时才真正返回,调用线程恢复执行

3. 不想用异常?用 error_code 接收结果

上面那个例子里,如果 wait() 出错了,默认会抛出一个 boost::system::system_error 类型的异常。但有些场景下我们不想用异常来处理错误(比如在性能敏感或者不方便用 try/catch 的代码路径里),这时候可以给同步方法额外传一个 boost::system::error_code 类型的对象,用它来接收操作的结果,而不是抛异常:

boost::system::error_code ec;
timer.wait(ec);

这样一来,就算操作出错了,程序也不会崩溃或者被异常打断,而是把错误信息安安静静地写进 ec 里,等调用完之后自己去检查 ec 就行。

错误处理方式写法出错时的行为
抛异常(默认)timer.wait();出错时抛出boost::system::system_error类型的异常,需要try/catch来接住
error_code方式boost::system::error_code ec; timer.wait(ec);出错时不抛异常,而是把错误信息写进ec,需要自己手动检查

4. 完整例子2:用 error_code 真正观察一次"出错"

光是给一个正常到期的定时器传 error_code 意义不大,因为它几乎不会出错。为了真正体会一下 error_code 是怎么捕捉错误的,下面这个例子专门制造了一次"错误":开一个线程去同步等待一个5秒的定时器,主线程只等1秒就把这个定时器提前取消掉,被取消的等待就会带着一个错误提前返回。

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    // 定时器设置为5秒后到期,不过我们马上会在下面把它提前取消掉
    boost::asio::steady_timer timer(io_context, 5s);
    // 开一个线程专门去做同步等待,这样主线程才能腾出手来稍后调用cancel()
    std::thread waiter([&timer]() {
        boost::system::error_code ec; // 用来接收wait()的结果,而不是让它抛异常
        std::cout << "等待线程: 开始同步等待timer...\n";
        timer.wait(ec); // 阻塞在这里,直到定时器到期,或者被别处取消
        if (!ec) {
            std::cout << "等待线程: 定时器正常到期\n";
        } else {
            std::cout << "等待线程: 等待被打断,错误信息: "
                      << ec.message() << '\n';
        }
    });
    std::this_thread::sleep_for(1s);
    std::cout << "主线程: 提前取消定时器\n";
    timer.cancel(); // 取消定时器,waiter线程里的wait(ec)会因此立刻返回一个错误
    waiter.join();
    return 0;
}

编译运行方式和例1一样。运行后大约1秒左右就会看到:

等待线程: 开始同步等待timer...
主线程: 提前取消定时器
等待线程: 等待被打断,错误信息: Operation canceled

可以看到,timer.wait(ec) 并没有一直傻等到第5秒,而是因为 cancel() 被提前触发,很快就带着一个"操作被取消"的错误返回了,而且全程没有抛出任何异常——错误信息完完整整地被 ec 接住了。
整个过程的时序如下:

操作系统 io_context steady_timer waiter线程 主线程 操作系统 io_context steady_timer waiter线程 主线程 创建timer,5秒后到期 启动waiter线程 timer.wait(ec),同步阻塞 转交这次等待请求 请求操作系统等待5秒 sleep_for(1s) timer.cancel() 通知取消这个定时器上的所有等待 告诉操作系统提前结束这次等待 返回"操作被取消"的错误 把错误翻译好之后交给timer wait(ec)返回,ec里带着"操作被取消"的信息 打印"等待被打断,错误信息:..."

5. 同步 vs 异步:先对比一下,方便后面理解异步部分


方式调用之后会发生什么函数什么时候返回
同步(wait())发起请求后,调用线程被阻塞,直到操作系统完成任务操作真正完成之后才返回
异步(async_wait())发起请求后立刻返回,真正的等待交给io_context的事件循环去处理函数调用后马上返回,结果通过回调函数在将来某个时刻通知

同步方式的好处是代码写起来最直观,跟平时写的普通函数调用没什么区别;缺点也很明显——调用的那个线程在等待期间完全被"占用",什么别的事都干不了。如果要在等待的同时还想干别的活,要么像例2那样单独开一个线程去做同步等待,要么就是接下来要讲的异步方式。

异步操作(Asynchronous Operations)

一、异步操作比同步操作多了什么

在前面的例子里我们已经见过 async_wait 这种带 async_ 前缀的函数了。这一节要搞清楚的是:调用异步操作的时候,你额外传进去的那个函数到底是什么、它什么时候被调用、以及它背后完整的执行链路是怎样的。
调用异步操作时,除了正常的参数之外,你还必须额外传一个叫**完成处理函数(completion handler)**的东西。这其实就是一个"可调用对象"(函数、lambda、函数对象都可以),当异步操作真正完成的时候,会由 io_context 负责去调用它,告诉你这次操作的结果或者是不是出错了。
它的函数签名是固定的:

void completion_handler(const boost::system::error_code& ec);

也就是说,不管你的异步操作是等定时器、读 socket 还是解析域名,完成处理函数最终收到的参数永远是一个 boost::system::error_code,用来告诉你"这次操作到底成功了没有,如果失败了是什么原因"。
拿定时器举例,异步调用看起来是这样的:

timer.async_wait(completion_handler);

二、完整的执行链路:从调用到回调触发

这一整套流程涉及三个角色:你的程序io_context操作系统。梳理一下具体发生了什么:

  1. 你调用 timer.async_wait(completion_handler)timer 这个 I/O 对象把这个请求转发给它绑定的 io_context
  2. io_context 拿到这个请求后,去向操作系统申请"帮我启动这个异步操作"(比如"帮我在 2 秒后通知我")。
  3. 操作系统在后台处理这个操作,操作真正完成的时候(比如定时器真的到了 2 秒),操作系统会把结果放进一个队列里,而 io_context 一直在监听这个队列。
  4. io_context 从队列里取出这个结果,把操作系统层面的错误信息翻译成统一的 error_code 对象。
  5. io_context 调用你之前传进去的 completion_handler,把这个 error_code 作为参数传给它,这样你的程序就知道操作完成了,以及是否出错。
    要让 io_context 真正按照这套流程运转起来,你的程序必须调用 io_context::run()(或者前面提到过的其他管理事件循环的函数)。调用 run() 之后,当前线程会阻塞在这里,专门负责处理还没完成的异步操作;一旦所有异步操作都处理完了,run() 才会返回。
    用一张表把这五个步骤和"谁负责做"对应起来:

步骤谁负责做了什么
1I/O 对象(如 timer)把异步请求转发给 io_context
2io_context向操作系统申请启动这个异步操作
3操作系统后台执行操作,完成后把结果放进队列
4io_context从队列取出结果,翻译成 error_code
5io_context调用你注册的 completion_handler,把 error_code 传进去

三、完成处理函数必须"可拷贝构造"

这里有个容易被忽略但很重要的规则:完成处理函数必须是可拷贝构造的,也就是说它必须有一个能正常工作的拷贝构造函数。这是因为 io_context 内部会在存储、转发这个处理函数的过程中,对它进行拷贝(有时甚至不止一次)。
另外还有一条设计上的考量:如果异步操作需要用到一些临时资源(比如内存缓冲区、线程、文件描述符这类东西),这些资源会在调用完成处理函数之前就被释放掉。这样设计的好处是:如果你在完成处理函数里面又发起了同一个异步操作(这在网络编程里非常常见,比如"读完一次数据就继续读下一次"这种循环模式),新的操作和旧的操作在资源占用上不会重叠,从而避免系统里资源占用的峰值越垒越高。

四、完整代码示例一:标准签名的完成处理函数

先用一个符合标准签名的独立函数作为完成处理函数,把整个流程走一遍。

// 编译命令:
// g++ -std=c++17 async_operation_basic.cpp -o async_operation_basic -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
// 完成处理函数必须符合固定签名:
// void(const boost::system::error_code&)
// io_context 在操作完成后会调用这个函数,把结果通过 error_code 传进来
void completion_handler(const boost::system::error_code& ec) {
    if (!ec) {
        // ec 转换成 bool 时,"没有错误"对应 false,所以 !ec 表示成功
        std::cout << "[完成处理函数] 异步操作成功完成\n";
    } else {
        // 如果操作出错(比如被取消),ec 会带上具体的错误信息
        std::cout << "[完成处理函数] 异步操作出错: " << ec.message() << "\n";
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));
    std::cout << "发起异步操作:timer.async_wait(completion_handler)\n";
    // timer 这个 I/O 对象把请求转发给 io_context,
    // io_context 再去请求操作系统启动这个定时器
    // 这一行函数调用立刻返回,不会阻塞
    timer.async_wait(completion_handler);
    std::cout << "调用 io_context.run(),阻塞当前线程处理未完成的异步操作\n";
    // run() 阻塞在这里,直到操作系统把"定时器到期"的结果放进队列,
    // io_context 取出结果、翻译成 error_code、调用 completion_handler
    // 之后,队列里没有更多待处理任务了,run() 才会返回
    io_context.run();
    std::cout << "run() 返回,程序结束\n";
    return 0;
}

关键点说明:

  • completion_handler 是一个独立的普通函数,天然满足"可拷贝构造"这个要求(函数指针本身可以随意拷贝)。
  • timer.async_wait(completion_handler) 这一行只是"登记"了这个操作,真正的执行和回调触发都要等到 io_context.run() 被调用之后才会发生。

五、完整代码示例二:自定义可调用对象作为完成处理函数

为了直观地观察到"完成处理函数会被拷贝"这件事,下面自己写一个函数对象类,显式实现拷贝构造函数并在里面打印日志,这样就能亲眼看到 io_context 内部到底拷贝了几次。

// 编译命令:
// g++ -std=c++17 async_operation_copyable.cpp -o async_operation_copyable -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
// 自定义一个函数对象类,用来当完成处理函数
// 重点是显式实现拷贝构造函数,观察它被拷贝的次数
class MyHandler {
public:
    explicit MyHandler(int id) : id_(id) {
        std::cout << "[MyHandler] 构造,id = " << id_ << "\n";
    }
    // 拷贝构造函数:io_context 内部转发、存储这个处理函数时会用到它
    // 这也是"完成处理函数必须可拷贝构造"这条规则要求存在的具体函数
    MyHandler(const MyHandler& other) : id_(other.id_) {
        std::cout << "[MyHandler] 拷贝构造,id = " << id_ << "\n";
    }
    // 重载 operator(),让这个类的对象可以像函数一样被调用
    // 参数签名依然要符合完成处理函数的标准签名
    void operator()(const boost::system::error_code& ec) const {
        if (!ec) {
            std::cout << "[MyHandler::operator()] id = " << id_
                      << ",异步操作成功完成\n";
        } else {
            std::cout << "[MyHandler::operator()] id = " << id_
                      << ",异步操作出错: " << ec.message() << "\n";
        }
    }
private:
    int id_;
};
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, std::chrono::milliseconds(500));
    std::cout << "构造一个 MyHandler 实例,准备传给 async_wait\n";
    MyHandler handler(42);
    std::cout << "调用 async_wait,注意接下来可能会发生拷贝\n";
    // 这里传入的是一个左值 handler,async_wait 内部会对它进行拷贝,
    // 然后把拷贝出来的这一份保存起来,直到操作完成才调用
    timer.async_wait(handler);
    std::cout << "进入事件循环\n";
    io_context.run();
    std::cout << "程序结束\n";
    return 0;
}

关键点说明:

  • 运行这个例子,你会看到"构造"只发生一次,但"拷贝构造"至少发生一次(具体次数取决于 Boost.Asio 内部实现细节和编译器优化,但至少会有一次,因为 io_context 需要把这个处理函数单独存一份,不能依赖调用者传进来的 handler 变量一直存活)。
  • 这正好印证了前面说的规则:完成处理函数必须可拷贝构造,如果 MyHandler 没有实现拷贝构造函数(比如里面持有一个 std::unique_ptr 成员且没有自定义拷贝逻辑),这段代码根本编译不过。

六、完整代码示例三:通过取消操作观察 error_code

再写一个例子,故意在定时器到期之前把它取消掉,这样可以看到完成处理函数收到的 error_code 不再是"无错误",而是携带了具体的失败原因。

// 编译命令:
// g++ -std=c++17 async_operation_cancel.cpp -o async_operation_cancel -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
int main() {
    boost::asio::io_context io_context;
    // 设置一个 5 秒后才到期的定时器,之后我们会提前把它取消掉
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(5));
    timer.async_wait([](const boost::system::error_code& ec) {
        if (ec == boost::asio::error::operation_aborted) {
            // 操作被取消时,error_code 会精确地告诉我们原因是"操作被中止"
            std::cout << "[完成处理函数] 定时器被提前取消了\n";
        } else if (!ec) {
            std::cout << "[完成处理函数] 定时器正常到期\n";
        } else {
            std::cout << "[完成处理函数] 其他错误: " << ec.message() << "\n";
        }
    });
    // 再开一个线程,专门负责在 1 秒后调用 timer.cancel()
    // cancel() 会让之前挂起的 async_wait 提前以"被取消"的状态结束
    std::thread canceller([&timer]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "[取消线程] 调用 timer.cancel()\n";
        timer.cancel();
    });
    std::cout << "进入事件循环,等待定时器到期或被取消\n";
    io_context.run();
    canceller.join();
    std::cout << "程序结束\n";
    return 0;
}

关键点说明:

  • timer.cancel() 会让之前注册的 async_wait 提前结束,并且完成处理函数收到的 error_code 会等于 boost::asio::error::operation_aborted,这是 Boost.Asio 统一定义的一个特殊错误码,专门表示"这个操作是被主动取消的,不是真的执行失败"。
  • 这个例子也说明了完成处理函数的签名为什么必须包含 error_code:光靠"函数被调用了"这一个事实,是没办法区分"操作正常完成"和"操作被取消/失败"的,必须靠这个参数来传达具体结果。

七、时序图:完整的异步操作执行链路

把"程序、I/O 对象、io_context、操作系统"四者之间的完整交互过程画成时序图:

completion_handler 操作系统 io_context I/O 对象(timer) 你的程序 completion_handler 操作系统 io_context I/O 对象(timer) 你的程序 操作在后台执行中... timer.async_wait(completion_handler) 转发异步请求,附带 completion_handler 拷贝 completion_handler 并保存起来 请求启动这个异步操作 async_wait 立刻返回,不阻塞 调用立刻返回 io_context.run() 阻塞,进入事件循环,等待队列有结果 操作完成,把结果放进队列 io_context 监听到队列里有新结果 从队列取出结果 把操作系统的错误信息翻译成 error_code 释放这次异步操作占用的临时资源(在调用 handler 之前) 调用 completion_handler(ec) 执行你写的回调逻辑,处理成功或失败的情况 回调执行完毕 队列已空,run() 返回

八、小结

  • 异步操作除了正常参数外,必须额外传入一个符合 void(const boost::system::error_code&) 签名的完成处理函数,操作真正完成时会由 io_context 负责调用它。
  • 完整链路是:I/O 对象把请求转发给 io_contextio_context 请求操作系统启动操作 → 操作系统完成后把结果放进队列 → io_context 取出结果、翻译成 error_code → 调用完成处理函数。这整套流程必须靠 io_context::run() 驱动,不调用 run() 的话完成处理函数永远不会被执行。
  • 完成处理函数必须可拷贝构造,因为 io_context 需要在内部单独保存一份,不能依赖调用者传进来的那个变量一直存活。
  • 异步操作用到的临时资源会在调用完成处理函数之前就释放掉,这样即使你在处理函数里立刻发起同一个操作的下一轮调用,也不会出现资源占用重叠、峰值越堆越高的问题。

Boost.Asio 的错误处理(异常方式)—— use_future 和取消操作是怎么配合的

一、Boost.Asio 处理错误的两条路子

调用一个 I/O 对象的方法时,如果操作失败了(比如定时器被取消、网络连接断开),Boost.Asio 给了两种得知"出错了"的方式:

方式怎么用错误信息怎么拿到
错误码方式调用方法时传入一个 boost::system::error_code& 引用参数检查这个引用变量是否为"有错误"的状态
异常方式调用方法时传错误码参数操作失败会抛出 boost::system::system_error 异常,需要用 try/catch 捕获

简单说:给不给 error_code,决定了 Boost.Asio 用哪种方式告诉你出错了。 前面章节里我们一直在用第一种(检查 error_code),这次来看第二种——用异常捕获错误。

二、use_future:让异步操作直接变成一个 std::future

正常情况下,async_wait() 这类异步方法需要传入一个回调函数(completion handler),操作完成后由 io_context 主动调用这个回调。但 Boost.Asio 还支持另一种写法:传入 boost::asio::use_future 作为参数,这样 async_wait() 就不再要求你写回调函数,而是直接返回一个 std::future 对象
这跟之前学过的 std::future/std::promise 机制是同一套东西:

  • 异步操作在后台完成后,结果(或者异常)会被自动存进这个 future 内部。
  • 我们只需要在需要结果的地方调用 future.get()
    • 如果操作正常完成,get() 直接把结果返回给你(本例是 void,所以只是正常返回,不抛异常)。
    • 如果操作出错了(比如被取消),get() 会重新抛出内部存好的异常。
      这样一来,原本要写回调函数处理错误的逻辑,就可以简化成熟悉的 try { ... } catch (...) { ... } 结构。

三、完整可运行代码(一):定时器正常到期,不抛异常

先看一份可以直接编译运行的完整示例。这里定时器设置为 1 秒后到期,主线程睡眠 3 秒之后再尝试取消定时器:

#include <boost/asio.hpp>
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
    // (1) 创建执行上下文对象,事件循环的调度中心
    boost::asio::io_context io_context;
    // (2) 创建一个定时器,1秒后到期,绑定到io_context
    boost::asio::steady_timer timer(io_context, 1s);
    // (3) 调用async_wait时传入boost::asio::use_future,
    //     这样函数不再需要回调函数,而是直接返回一个std::future<void>对象
    auto fut = timer.async_wait(boost::asio::use_future);
    // (4) 单独开一个后台线程去真正驱动事件循环,
    //     因为run()会阻塞,不能让它占用主线程,否则主线程没法继续往下执行
    std::thread io_thread([&io_context]() {
        io_context.run();
    });
    // (5) 主线程睡眠3秒,这个时间比定时器的1秒长很多,
    //     所以定时器大概率已经在第1秒左右正常完成了
    std::this_thread::sleep_for(3s);
    // (6) 尝试取消定时器;但此时定时器早已完成,所以这次cancel()不会有实际效果
    timer.cancel();
    try {
        // (7) fut.get()去拿结果:
        //     如果异步操作正常完成,这里什么都不会抛,直接往下走
        //     如果异步操作被取消/出错,这里会重新抛出对应的异常
        fut.get();
        std::cout << "Timer expired successfully!\n";
    } catch (const boost::system::system_error& e) {
        // (8) 捕获Boost.Asio专用的异常类型system_error,
        //     e.code()可以拿到具体的错误码对象,message()把它转成可读的文字说明
        std::cout << "Timer failed: " << e.code().message() << '\n';
    }
    // (9) 等待后台线程结束,避免main函数提前退出导致线程被强制终止
    io_thread.join();
    return 0;
}

编译方式:

g++ -std=c++17 timer_future.cpp -o timer_future -lboost_system -lpthread

因为定时器只需要 1 秒就能到期,而主线程要等 3 秒才去调用 cancel(),所以真正执行的顺序是:定时器早就正常完成了,cancel() 面对的已经是一个"已经结束"的操作,不会产生任何效果。所以这份代码运行的输出是:

Timer expired successfully!

四、完整可运行代码(二):在到期之前取消,真正观察异常被抛出

如果想亲眼看到 catch 块真正被触发、异常真正被抛出,需要让 cancel() 在定时器到期之前被调用。做法很简单:把定时器的到期时间调长,把主线程等待的时间调短,让 cancel() “抢在前面”:

#include <boost/asio.hpp>
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    // 定时器改成3秒后才到期,明显比接下来的等待时间长
    boost::asio::steady_timer timer(io_context, 3s);
    auto fut = timer.async_wait(boost::asio::use_future);
    std::thread io_thread([&io_context]() {
        io_context.run();
    });
    // 只睡1秒,此时定时器(3秒)还远没到期
    std::this_thread::sleep_for(1s);
    // 在定时器到期之前调用cancel(),这次是真正意义上的"取消正在等待的操作"
    timer.cancel();
    try {
        fut.get();
        std::cout << "Timer expired successfully!\n";
    } catch (const boost::system::system_error& e) {
        std::cout << "Timer failed: " << e.code().message() << '\n';
    }
    io_thread.join();
    return 0;
}

这次因为取消发生在到期之前,async_wait 这次等待被真正中断了,future 内部存的不是正常结果,而是一个"操作被取消"(operation_aborted)的错误,fut.get() 会把它重新抛出成 boost::system::system_error 异常,被 catch 块捕获。运行结果大致是:

Timer failed: Operation canceled

(具体的文字提示 e.code().message() 会因操作系统和 Boost 版本略有差异,但含义都是"操作被取消"。)
用一个简单的判断关系总结这两种情况:设定时器的到期时刻为 t e x p i r e t_{expire} texpire,实际调用 cancel() 的时刻为 t c a n c e l t_{cancel} tcancel,那么:
{ t c a n c e l < t e x p i r e    ⟹    取消生效, f u t . g e t ( ) 抛出异常 t c a n c e l ≥ t e x p i r e    ⟹    定时器早已完成, c a n c e l ( ) 无效, f u t . g e t ( ) 正常返回 \begin{cases} t_{cancel} < t_{expire} \implies \text{取消生效,}fut.get()\text{抛出异常} \\ t_{cancel} \geq t_{expire} \implies \text{定时器早已完成,}cancel()\text{无效,}fut.get()\text{正常返回} \end{cases} {tcancel<texpire取消生效,fut.get()抛出异常tcanceltexpire定时器早已完成,cancel()无效,fut.get()正常返回

五、时序图(一):取消发生在到期之后(对应第一份代码)

future对象 steady_timer io_thread(后台线程) main线程 future对象 steady_timer io_thread(后台线程) main线程 大约1秒后 定时器正常到期 操作早已完成 cancel()没有实际效果 创建timer(1s) async_wait(use_future) 返回fut 启动后台线程运行io_context.run() 事件循环开始等待定时器 把"成功完成"的结果写入fut sleep_for(3s) cancel() fut.get() 正常返回,不抛异常 打印"Timer expired successfully!" io_thread.join()

六、时序图(二):取消发生在到期之前(对应第二份代码)

future对象 steady_timer io_thread(后台线程) main线程 future对象 steady_timer io_thread(后台线程) main线程 定时器还没到期(3秒) 这次真正中断了等待 创建timer(3s) async_wait(use_future) 返回fut 启动后台线程运行io_context.run() 事件循环开始等待定时器 sleep_for(1s) cancel() 把operation_aborted错误写入fut fut.get() 重新抛出boost::system::system_error异常 catch块捕获,打印"Timer failed: ..." io_thread.join()

对比这两张图能很直观地看出:cancel() 能不能真正影响结果,完全取决于它被调用的那一刻,对应的异步操作到底完成了没有。已经完成的操作再去"取消"是没有意义的;只有正在等待中的操作被取消,才会触发异常这条路径。

七、小结


概念作用一句话记忆
error_code&参数把错误信息写进传入的变量里“错误放在你给我的盒子里”
不传error_code操作失败时直接抛system_error异常“出错了就直接扔给你”
boost::asio::use_future让异步方法返回std::future而不是走回调“把异步结果包装成future,方便统一用get()取”
future.get()取结果,如果内部存的是异常就重新抛出“要么给你结果,要么把异常还给你”
timer.cancel()尝试中断还没完成的异步等待“能不能取消,看操作完没完成”

这份示例最值得记住的一点是:use_future 把"异步操作完成/失败"这件事,转换成了我们更熟悉的"函数调用要么正常返回、要么抛异常"的编程模型,配合 try/catch 就能写出跟同步代码几乎一样直观的错误处理逻辑,而不需要在每个回调函数里单独判断 error_code

Boost.Asio 的单线程模型 —— 事件循环和回调函数在同一个线程里跑

一、什么是"单线程方式"

写异步程序时,第一反应可能会觉得"异步"就该配合"多线程"一起用。但 Boost.Asio 官方推荐的起点方案其实恰恰相反:io_context 的事件循环,和所有完成处理函数(completion handler),都跑在同一个线程里,通常就是 main 线程本身。
这么做的好处很直接:

  • 不需要考虑多线程之间抢锁、数据竞争的问题,因为所有代码本质上是"排队"依次执行的。
  • 程序结构简单,出问题也容易排查。
    但这个方案有一个硬性前提:每一个完成处理函数都必须写得又短又快,绝对不能在里面做阻塞或者耗时很久的操作。原因也很直接——既然大家都挤在同一个线程里跑,只要有一个 handler 卡住了,整个线程就会被卡住,其他所有待处理的异步任务(包括事件循环本身)全都得跟着等,程序看起来就像"死机"了一样。

二、逐行拆解这份最小示例代码

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: " << ec.message() << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));
    timer.async_wait(&handle_timer_expiry);
    io_context.run();
    return 0;
}

逐段说明:

  • handle_timer_expiry 函数:这是一个独立定义的普通函数,专门用作定时器的完成处理函数(completion handler)。它接收一个 const boost::system::error_code& ec 参数:
    • 如果 ec 表示"没有错误"(!ec 为真),说明定时器正常到期,打印一句提示。
    • 否则说明操作出了问题(比如被取消了),把具体的错误信息通过 ec.message() 打印到标准错误流。
  • boost::asio::io_context io_context;:创建事件调度中心,这是整个程序里唯一的执行上下文对象。
  • boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));:创建一个 1 秒后到期的定时器,绑定到 io_context 上。这里用 std::chrono::seconds(1) 和前面例子里的 1s 写法是完全等价的,只是没有用字面量语法糖。
  • timer.async_wait(&handle_timer_expiry);:注册完成处理函数。注意这里传的是函数指针 &handle_timer_expiry(也可以直接写 handle_timer_expiry,函数名本身会自动退化成指针),意味着"定时器到期后,请调用这个函数"。这一行调用后立刻返回,不会等待 1 秒。
  • io_context.run();:让事件循环真正开始工作。因为这行代码是在 main 线程里直接调用的,所以事件循环本身、以及后续 handle_timer_expiry 被调用执行,都会发生在 main 线程上——这正是"单线程方式"的核心体现:没有额外创建任何线程,全部工作都挤在 main 函数所在的这一个线程里顺序完成。

三、完整可运行的代码

上面这份代码本身已经是完整、可以直接编译运行的程序,这里原样给出并补充一点点日志方便观察执行顺序:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
// 定时器到期后会被调用的完成处理函数
void handle_timer_expiry(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: " << ec.message() << std::endl;
    }
}
int main() {
    // (1) 创建执行上下文对象,程序里只有这一个线程会用到它
    boost::asio::io_context io_context;
    // (2) 创建1秒后到期的定时器,绑定到io_context
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));
    // (3) 注册完成处理函数,async_wait立刻返回,不阻塞
    timer.async_wait(&handle_timer_expiry);
    std::cout << "main线程: 已经注册好定时器,即将调用run()\n";
    // (4) run()在main线程里被直接调用,
    //     所以事件循环和handle_timer_expiry都在main线程上执行
    io_context.run();
    std::cout << "main线程: run()已经返回,所有任务都处理完了\n";
    return 0;
}

编译方式:

g++ -std=c++17 single_thread_timer.cpp -o single_thread_timer -lboost_system -lpthread

预期输出(“Timer expired!” 出现前会有大约 1 秒的等待):

main线程: 已经注册好定时器,即将调用run()
Timer expired!
main线程: run()已经返回,所有任务都处理完了

四、时序图:确认事件循环和回调真的在同一个线程里

main线程 main线程 run()内部阻塞等待 直到定时器到期 大约1秒后,定时器到期 创建io_context 创建steady_timer(1s) async_wait(&handle_timer_expiry) 立刻返回,不阻塞 打印"已经注册好定时器..." io_context.run() 事件循环调用handle_timer_expiry(ec) 打印"Timer expired!" 队列已空,run()返回 打印"run()已经返回..."

从图里能看出,从头到尾只出现了一个参与者(Main,因为没有创建任何额外线程——创建定时器、运行事件循环、执行完成处理函数,这几件事全部依次发生在同一个线程的同一条时间线上。

五、用文本图直观感受"单线程"意味着什么

main线程的时间线(从左到右):
|--创建timer--|--async_wait(注册回调)--|--run()开始阻塞等待--|--(定时器到期)--|--执行handle_timer_expiry--|--run()返回--|
                                                                            ^
                                                          如果这个位置的回调函数写得很耗时,
                                                          整条时间线会在这里被拖长,
                                                          后面所有排队的任务都要跟着往后延

这张示意图想强调的是:单线程模型下,所有环节都排在同一条时间线上顺序执行。只要某个完成处理函数耗时过长,就相当于把这条时间线在那个点"卡住"了,其余原本该被处理的任务只能干等着。

六、单线程方式的优缺点


方面说明
优点:编程简单不涉及多线程同步问题,不用加锁,出问题好排查
优点:资源开销小不需要额外创建和维护线程
缺点:完成处理函数必须快任何一个handler阻塞或耗时过长,都会拖慢整个程序的响应
适用场景完成处理函数本身工作量很小的程序,比如简单的定时器、轻量级网络协议处理
不适用场景完成处理函数里包含耗时计算、阻塞式IO、复杂业务逻辑的程序

七、小结


概念说明
单线程方式io_context.run()和所有完成处理函数都在同一个线程执行
完成处理函数(handler)异步操作完成后被调用的函数,本例是handle_timer_expiry
核心约束handler必须短小、非阻塞,否则会卡住整个线程
下一步要解决的问题如果handler本身就是耗时任务该怎么办

单线程方式是使用 Boost.Asio 时最推荐的起点,原因是它简单、可靠、容易理解。但它的可用性完全建立在"每个 handler 都很快"这个前提之上——一旦程序里出现了需要长时间处理的任务,就必须考虑别的办法(比如把耗时任务丢到别的线程去做),这也是后续要继续学习的内容。

长时间运行的任务:交给后台线程去做,结果再传回主线程

1. 要解决的问题

有些任务本身很耗时(比如复杂计算、访问一个很慢的外部资源),如果直接把它塞进 io_context 的事件循环里跑,就会把整个事件循环卡住,其他本该被处理的异步事件也都得跟着等。
一个常见的做法是:主体逻辑依然围着 io_context 转,但是把真正耗时的部分丢给一个单独的线程去跑,等这个线程把活干完了,再通过 io_context 把结果"传"回主线程(或者说,传回调用 io_context.run() 的那个线程)。

2. 完整可运行代码

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
// 这是真正"耗时"的后台任务,在一个独立的线程里运行,
// 不会占用io_context事件循环所在的那个线程。
void long_running_task(boost::asio::io_context& io_context, int task_duration) {
    std::cout << "Background task started: Duration = "
              << task_duration << " seconds.\n";
    // 用睡眠来模拟一个耗时的操作,比如复杂计算、访问慢速外部资源等
    std::this_thread::sleep_for(std::chrono::seconds(task_duration));
    // 任务做完之后,不要在这里直接操作共享数据,
    // 而是把"接下来要做的事情"打包成一个lambda,post给io_context,
    // 让它在调用run()的那个线程里安全地被执行
    io_context.post([&io_context]() {
        std::cout << "Background task completed.\n";
        io_context.stop(); // 主动让io_context.run()尽快返回
    });
}
int main() {
    boost::asio::io_context io_context;
    // work_guard:在还没有任何真正的异步任务被post进来之前,
    // 防止io_context.run()因为"暂时没活干"而提前退出
    auto work_guard = boost::asio::make_work_guard(io_context);
    // 往io_context里post一个任务:这个任务本身跑得很快,
    // 它只负责"开一个新线程去跑真正耗时的工作,然后马上把这个线程detach掉"
    io_context.post([&io_context]() {
        std::thread t(long_running_task, std::ref(io_context), 2);
        std::cout << "Detaching thread" << std::endl;
        // 必须detach:如果不这么做,当这个lambda函数返回、局部变量t被销毁时,
        // 一个既没有join()、也没有detach()的std::thread对象在析构时
        // 会直接调用std::terminate(),把整个程序搞崩溃
        t.detach();
    });
    std::cout << "Running io_context...\n";
    // run()会先执行上面post进来的那个"创建线程并detach"的任务;
    // 之后即使队列暂时空了,因为有work_guard在,run()也不会返回,
    // 而是继续阻塞等待,直到2秒后后台线程把"完成"消息post进来,
    // 并且调用了io_context.stop()
    io_context.run();
    std::cout << "io_context exit.\n";
    return 0;
}

3. 编译与运行

g++ -std=c++17 threaded_task.cpp -o threaded_task -lboost_system -lpthread
./threaded_task

大致会看到这样的输出:

Running io_context...
Detaching thread
Background task started: Duration = 2 seconds.
Background task completed.
io_context exit.

需要提一句:Detaching threadBackground task started: Duration = 2 seconds. 这两行谁先谁后其实并不是完全确定的——因为它们分别来自主线程(准确说是执行 run() 的那个线程)和刚创建出来的后台线程 t,两个线程几乎是同时在跑,具体谁先打印完全取决于操作系统的线程调度,这是多线程程序里很常见的一种现象。

4. 代码逐段解析

4.1 work_guard:为什么这里必须要有它

main() 里有一个细节特别容易被忽略:io_context.post(...) 这一句先把"创建线程并detach"这个任务放进了队列,紧接着才调用 io_context.run()run() 执行完这个任务之后,队列会暂时变空——因为刚创建出来的后台线程 t 是完全独立跑着的,它并不会被 io_context 当成"还有一项工作没完成"来计数。
如果没有 work_guardrun() 在这一刻会发现"队列空了,也没有任何未完成的异步操作",于是立刻返回,主线程接着就会打印 io_context exit. 然后退出整个程序——可后台线程这时候可能才刚刚睡下,2秒后它试图把"完成"消息 post 回来的时候,早就没有人在跑事件循环去处理这个任务了。有了 work_guardrun() 就会一直耐心等着,直到后台线程真正把消息传回来为止。

4.2 t.detach():为什么不能不写

std::thread t(...) 是那个 post 进去的 lambda 函数里的一个局部变量。这个 lambda 一旦执行完,局部变量 t 就要被销毁。C++ 标准规定:一个还没有 join() 过、也没有 detach() 过的 std::thread 对象,如果在析构的时候还处于"可joinable"的状态,程序会直接调用 std::terminate() 强制终止——这是标准库为了防止"线程对象没了但线程还在跑"这种危险情况设下的保护机制。所以这里必须显式调用 t.detach(),告诉运行时"这个线程以后自己管自己,不需要谁来 join() 它了"。

4.3 io_context.stop():为什么必须显式调用它

因为有 work_guard 撑着,io_context.run() 永远不会因为"暂时没活干"而自动退出。如果没有人显式调用 io_context.stop()run() 就会一直阻塞下去,整个程序也就永远不会结束。所以后台任务做完之后,专门 post 了一个任务,在打印完成消息的同时调用 stop(),这样 run() 才有机会真正返回。

5. 整体流程一图看懂

被post的"完成"任务 后台线程t(long_running_task) 被post的"建线程"任务 io_context 主线程(main) 被post的"完成"任务 后台线程t(long_running_task) 被post的"建线程"任务 io_context 主线程(main) 队列暂时空了,但因为有work_guard,run()继续阻塞等待 创建io_context和work_guard io_context.post(建线程的lambda) io_context.run(),进入事件循环 执行这个被post的任务 std::thread t(long_running_task,...) 打印"Detaching thread" t.detach() 打印"Background task started..." sleep_for(2秒) io_context.post(完成的lambda) 执行这个被post的任务 打印"Background task completed." io_context.stop() run()因为stop()被调用而返回 打印"io_context exit."

也可以用一张更简化的流程图快速回顾整个思路:

主线程: 创建io_context --post(建线程任务)--> run()阻塞等待(work_guard撑着)
                                              |
                                              v
                                     后台线程t: sleep 2秒 --post(完成任务)--> 触发stop()
                                                                                |
主线程: run()返回,打印io_context exit. <----------------------------------------+

6. 三个关键动作,缺一不可


关键操作如果去掉会发生什么
work_guardrun()执行完第一个被post的任务后,发现队列暂时空了,就会立刻返回;2秒后后台线程再想把完成消息post回去,已经没有事件循环在处理了,io_context exit.会提前打印出来,后台任务的完成消息永远等不到被处理的机会
t.detach()t是lambda里的局部变量,lambda一返回t就要被销毁;一个还没join()也没detach()std::thread对象析构时会直接调用std::terminate(),程序会崩溃退出
io_context.stop()因为有work_guard在,run()不会因为"没活干"而自动退出;如果没人显式调用stop(),run()会永远阻塞下去,整个程序也就跟着卡死,永远不会结束

多个 I/O 执行上下文对象:每个线程一个

一、这种模型和之前学的有什么不一样

前面讲了两种线程玩法:一种是主线程和 io 线程分工,run() 挪到独立线程里跑;另一种是多个线程共享同一个 io_context,一起去抢着处理同一批完成回调。
这一节要介绍的是第三种模式:每个线程各自拥有一个完全独立的 io_context 对象,彼此之间互不共享、互不干扰。你可以把它理解成"把单线程模型复制了好几份,每一份都在自己独立的线程里自娱自乐"——每个线程内部依然是我们最早学的那种"一个线程对应一个 io_context"的简单结构,只不过现在同时跑了好几份这样的结构。
这种模式特别适合完成处理函数又短又不会阻塞的场景。因为每个线程各管各的 io_context,完成处理函数一旦执行时间稍微长一点或者不小心阻塞住了,就会直接拖慢这一个线程里所有排在后面的异步操作——毕竟这个线程只有它自己的 io_context,没有其他线程能来帮忙分担。
用一张表对比一下这两种"多线程玩法"的区别:

模型io_context 的数量完成回调在哪里执行适用场景
多线程共享同一个 io_context1 个,多个线程一起调用它的 run()哪个线程先"抢到"就在哪个线程执行,具体是谁不确定回调之间可能需要互相协作,或者想让系统自动做负载均衡
每个线程一个独立 io_contextN 个,一个线程对应一个固定在各自所属的那个线程里执行,互不干扰每个线程处理相对独立的任务,回调短小快速,不需要跨线程协作

二、代码逐行讲解

下面这份代码创建了 4 个线程,每个线程内部都各自建立一个 io_context,设置一个 1 秒后触发的定时器,然后阻塞运行自己的事件循环。原始代码里有一处笔误(错误地用了中文全角引号 ' 而不是英文单引号),下面的版本已经修正,并补充了详细注释,保证可以直接编译运行。

// 编译命令(需要支持 C++20 的编译器,因为用到了 std::jthread 和 std::osyncstream):
// g++ -std=c++20 per_thread_io_context.cpp -o per_thread_io_context -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
// std::osyncstream 是 C++20 引入的"同步输出流",
// 多个线程同时往里面写内容时,它会保证每一次整体输出不会跟别的线程交错在一起,
// 这里用一个宏把它包装成 sync_cout,用起来就跟平时的 std::cout 差不多
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals; // 这样才能直接写 1s 这种字面量
// 每个线程要执行的任务函数,i 用来标识是第几号线程,方便观察输出
void background_task(int i) {
    sync_cout << "线程 " << i << ":开始运行...\n";
    // 关键点:这里的 io_context 是这个函数内部的局部变量,
    // 也就是说每一次调用 background_task,都会创建一个全新的、
    // 只属于当前这一个线程的 io_context,彼此完全独立
    boost::asio::io_context io_context;
    // work_guard 用来防止 io_context 在还没有任何"待处理工作"时,
    // run() 因为发现队列是空的就立刻返回
    // 虽然接下来马上就会注册一个定时器(本身也算一份待处理工作),
    // 但显式使用 work_guard 是一种更稳妥的写法:
    // 不管注册顺序或者内部时机有什么细微差别,
    // 只要 work_guard 还没被 reset(),run() 就绝对不会提前退出
    auto work_guard = boost::asio::make_work_guard(io_context);
    sync_cout << "线程 " << i << ":设置定时器...\n";
    // 每个线程的定时器也是各自独立的局部变量,绑定到各自的 io_context 上
    boost::asio::steady_timer timer(io_context, 1s);
    // 这里用 [&] 按引用捕获,因为 timer、work_guard 这些变量
    // 在 lambda 真正被调用之前(也就是 io_context.run() 阻塞期间)
    // 都还活在 background_task 这个函数的栈帧里,没有被提前销毁,
    // 所以按引用捕获在这里是安全的
    timer.async_wait(
        [&](const boost::system::error_code& ec) {
            if (!ec) {
                sync_cout << "线程 " << i << ":定时器正常到期!\n";
            } else {
                sync_cout << "线程 " << i << ":定时器出错: "
                          << ec.message() << "\n";
            }
            // 定时器的回调处理完之后,显式释放 work_guard,
            // 这样 io_context 才知道"已经没有更多工作要做了",
            // 接下来的 run() 才能正常返回,而不是永远阻塞下去
            work_guard.reset();
        });
    sync_cout << "线程 " << i << ":开始运行 io_context...\n";
    // 阻塞在这里,直到 work_guard 被释放并且队列清空,
    // run() 才会返回,这个函数(也就是这个线程的任务)才算跑完
    io_context.run();
}
int main() {
    const int num_threads = 4;
    // std::jthread 是 C++20 引入的"可自动 join 的线程",
    // 它的析构函数会自动帮你调用 join(),不需要像 std::thread 那样
    // 手动记得去 join,避免了忘记 join 导致程序直接崩溃的问题
    std::vector<std::jthread> threads;
    for (auto i = 0; i < num_threads; ++i) {
        // 每次循环都创建一个新线程,运行 background_task(i),
        // 因为 background_task 内部的 io_context 是局部变量,
        // 所以这 4 个线程各自拥有完全独立、互不干扰的 io_context
        threads.emplace_back(background_task, i);
    }
    // 这里没有显式调用 join(),但因为 threads 里存的是 std::jthread,
    // 当 threads 这个 vector 在 main 函数结束时被销毁,
    // 每个 jthread 的析构函数会自动帮我们等待对应的线程执行完毕
    return 0;
}

关键点说明:

  • 整个模型的核心在于 io_contextbackground_task 函数内部的局部变量。函数被 4 个不同的线程分别调用了 4 次,每一次调用都会在各自的线程栈上创建一份全新的 io_context,天然就是互相独立、不共享的。
  • work_guard 的存在保证了 io_context.run() 一定会稳稳地阻塞到定时器回调真正执行完、并且显式调用了 work_guard.reset() 之后才返回,不会因为内部实现的一些时序细节而提前退出。
  • std::osyncstream 解决的是"多线程同时打印到 std::cout 导致内容交错乱码"的问题。如果换成普通的 std::cout <<,四个线程的输出很可能会拼接在一起,变成一团根本看不懂的字符。osyncstream 保证的是每一次完整的 sync_cout << ... << ...; 语句作为一个整体,不会被其他线程的输出打断。
  • std::jthread 相比 std::thread 最大的区别是它的析构函数会自动 join(),也就是说 main 函数里那个 threads 向量在退出作用域时,会自动等待所有 4 个线程都执行完毕,不需要你手写循环去逐个 join()

三、结构示意图:4 个完全独立的 io_context

   线程 0                线程 1                线程 2                线程 3
+-----------+        +-----------+        +-----------+        +-----------+
| io_context|        | io_context|        | io_context|        | io_context|
|  (独立)    |        |  (独立)    |        |  (独立)    |        |  (独立)    |
|           |        |           |        |           |        |           |
|  timer    |        |  timer    |        |  timer    |        |  timer    |
|  1秒定时器 |        |  1秒定时器 |        |  1秒定时器 |        |  1秒定时器 |
+-----------+        +-----------+        +-----------+        +-----------+
      |                    |                    |                    |
   各自 run()           各自 run()           各自 run()           各自 run()
      |                    |                    |                    |
   彼此之间没有任何共享状态,互不通信,完全并行运行

跟之前"多线程共享同一个 io_context"的模型放在一起对比会更清楚:

共享模型:
    线程A、线程B、线程C  ---都调用--->  同一个 io_context.run()
                                      (内部有锁保护,安全地分工处理任务队列)
每线程独立模型:
    线程A --> 自己的 io_context.run()
    线程B --> 自己的 io_context.run()
    线程C --> 自己的 io_context.run()
    (彼此之间没有交集,谁也不知道别人在干什么)

四、时序图:4 个线程并行执行的完整过程

线程3 线程2 线程1 线程0 主线程 main() 线程3 线程2 线程1 线程0 主线程 main() 1 秒后... 1 秒后... 1 秒后... 1 秒后... par [线程0独立运行] [线程1独立运行] [线程2独立运行] [线程3独立运行] 创建并启动,运行 background_task(0) 创建并启动,运行 background_task(1) 创建并启动,运行 background_task(2) 创建并启动,运行 background_task(3) 创建自己的 io_context 和 timer 注册 async_wait,阻塞 run() 定时器到期,执行回调,reset work_guard run() 返回,线程结束 创建自己的 io_context 和 timer 注册 async_wait,阻塞 run() 定时器到期,执行回调,reset work_guard run() 返回,线程结束 创建自己的 io_context 和 timer 注册 async_wait,阻塞 run() 定时器到期,执行回调,reset work_guard run() 返回,线程结束 创建自己的 io_context 和 timer 注册 async_wait,阻塞 run() 定时器到期,执行回调,reset work_guard run() 返回,线程结束 jthread 析构时自动 join jthread 析构时自动 join jthread 析构时自动 join jthread 析构时自动 join

五、小结

  • "每个线程一个独立 io_context"这种模式,本质上就是把最基础的单线程模型原样复制了 N 份,每一份都跑在自己专属的线程里,彼此之间没有任何共享状态,天然就不需要考虑跨线程的数据竞争问题。
  • 它最适合完成处理函数又轻又快、不会阻塞的场景,因为一旦某个线程里的回调卡住了,只会拖慢这一个线程自己的任务,不会波及其他线程,但也享受不到"多个线程互相帮忙分担任务"的好处。
  • work_guard 保证了即使定时器一类的异步操作本身已经足够让 io_context 保持"忙碌"状态,显式使用它依然是更稳妥、更不容易踩坑的写法。
  • std::osyncstreamstd::jthread 都是 C++20 带来的实用工具,前者解决多线程打印内容交错的问题,后者省去了手动 join() 的麻烦,两者搭配 Boost.Asio 写多线程程序会顺手很多。

Boost.Asio 的单线程模型 —— 事件循环和回调函数在同一个线程里跑

一、什么是"单线程方式"

写异步程序时,第一反应可能会觉得"异步"就该配合"多线程"一起用。但 Boost.Asio 官方推荐的起点方案其实恰恰相反:io_context 的事件循环,和所有完成处理函数(completion handler),都跑在同一个线程里,通常就是 main 线程本身。
这么做的好处很直接:

  • 不需要考虑多线程之间抢锁、数据竞争的问题,因为所有代码本质上是"排队"依次执行的。
  • 程序结构简单,出问题也容易排查。
    但这个方案有一个硬性前提:每一个完成处理函数都必须写得又短又快,绝对不能在里面做阻塞或者耗时很久的操作。原因也很直接——既然大家都挤在同一个线程里跑,只要有一个 handler 卡住了,整个线程就会被卡住,其他所有待处理的异步任务(包括事件循环本身)全都得跟着等,程序看起来就像"死机"了一样。

二、逐行拆解这份最小示例代码

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: " << ec.message() << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));
    timer.async_wait(&handle_timer_expiry);
    io_context.run();
    return 0;
}

逐段说明:

  • handle_timer_expiry 函数:这是一个独立定义的普通函数,专门用作定时器的完成处理函数(completion handler)。它接收一个 const boost::system::error_code& ec 参数:
    • 如果 ec 表示"没有错误"(!ec 为真),说明定时器正常到期,打印一句提示。
    • 否则说明操作出了问题(比如被取消了),把具体的错误信息通过 ec.message() 打印到标准错误流。
  • boost::asio::io_context io_context;:创建事件调度中心,这是整个程序里唯一的执行上下文对象。
  • boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));:创建一个 1 秒后到期的定时器,绑定到 io_context 上。这里用 std::chrono::seconds(1) 和前面例子里的 1s 写法是完全等价的,只是没有用字面量语法糖。
  • timer.async_wait(&handle_timer_expiry);:注册完成处理函数。注意这里传的是函数指针 &handle_timer_expiry(也可以直接写 handle_timer_expiry,函数名本身会自动退化成指针),意味着"定时器到期后,请调用这个函数"。这一行调用后立刻返回,不会等待 1 秒。
  • io_context.run();:让事件循环真正开始工作。因为这行代码是在 main 线程里直接调用的,所以事件循环本身、以及后续 handle_timer_expiry 被调用执行,都会发生在 main 线程上——这正是"单线程方式"的核心体现:没有额外创建任何线程,全部工作都挤在 main 函数所在的这一个线程里顺序完成。

三、完整可运行的代码

上面这份代码本身已经是完整、可以直接编译运行的程序,这里原样给出并补充一点点日志方便观察执行顺序:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
// 定时器到期后会被调用的完成处理函数
void handle_timer_expiry(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: " << ec.message() << std::endl;
    }
}
int main() {
    // (1) 创建执行上下文对象,程序里只有这一个线程会用到它
    boost::asio::io_context io_context;
    // (2) 创建1秒后到期的定时器,绑定到io_context
    boost::asio::steady_timer timer(io_context, std::chrono::seconds(1));
    // (3) 注册完成处理函数,async_wait立刻返回,不阻塞
    timer.async_wait(&handle_timer_expiry);
    std::cout << "main线程: 已经注册好定时器,即将调用run()\n";
    // (4) run()在main线程里被直接调用,
    //     所以事件循环和handle_timer_expiry都在main线程上执行
    io_context.run();
    std::cout << "main线程: run()已经返回,所有任务都处理完了\n";
    return 0;
}

编译方式:

g++ -std=c++17 single_thread_timer.cpp -o single_thread_timer -lboost_system -lpthread

预期输出(“Timer expired!” 出现前会有大约 1 秒的等待):

main线程: 已经注册好定时器,即将调用run()
Timer expired!
main线程: run()已经返回,所有任务都处理完了

四、时序图:确认事件循环和回调真的在同一个线程里

main线程 main线程 run()内部阻塞等待 直到定时器到期 大约1秒后,定时器到期 创建io_context 创建steady_timer(1s) async_wait(&handle_timer_expiry) 立刻返回,不阻塞 打印"已经注册好定时器..." io_context.run() 事件循环调用handle_timer_expiry(ec) 打印"Timer expired!" 队列已空,run()返回 打印"run()已经返回..."

从图里能看出,从头到尾只出现了一个参与者(Main,因为没有创建任何额外线程——创建定时器、运行事件循环、执行完成处理函数,这几件事全部依次发生在同一个线程的同一条时间线上。

五、用文本图直观感受"单线程"意味着什么

main线程的时间线(从左到右):
|--创建timer--|--async_wait(注册回调)--|--run()开始阻塞等待--|--(定时器到期)--|--执行handle_timer_expiry--|--run()返回--|
                                                                            ^
                                                          如果这个位置的回调函数写得很耗时,
                                                          整条时间线会在这里被拖长,
                                                          后面所有排队的任务都要跟着往后延

这张示意图想强调的是:单线程模型下,所有环节都排在同一条时间线上顺序执行。只要某个完成处理函数耗时过长,就相当于把这条时间线在那个点"卡住"了,其余原本该被处理的任务只能干等着。

六、单线程方式的优缺点


方面说明
优点:编程简单不涉及多线程同步问题,不用加锁,出问题好排查
优点:资源开销小不需要额外创建和维护线程
缺点:完成处理函数必须快任何一个handler阻塞或耗时过长,都会拖慢整个程序的响应
适用场景完成处理函数本身工作量很小的程序,比如简单的定时器、轻量级网络协议处理
不适用场景完成处理函数里包含耗时计算、阻塞式IO、复杂业务逻辑的程序

七、小结


概念说明
单线程方式io_context.run()和所有完成处理函数都在同一个线程执行
完成处理函数(handler)异步操作完成后被调用的函数,本例是handle_timer_expiry
核心约束handler必须短小、非阻塞,否则会卡住整个线程
下一步要解决的问题如果handler本身就是耗时任务该怎么办

单线程方式是使用 Boost.Asio 时最推荐的起点,原因是它简单、可靠、容易理解。但它的可用性完全建立在"每个 handler 都很快"这个前提之上——一旦程序里出现了需要长时间处理的任务,就必须考虑别的办法(比如把耗时任务丢到别的线程去做),这也是后续要继续学习的内容。

用一个 I/O 执行上下文对象并行处理工作

一、这次要讲的是哪种模式

前面已经见过"多个线程共享同一个 io_context"这种玩法了——当时是每个线程往里面投递一些工作,谁有空谁就去执行完成处理函数。现在要更系统地讲一下这种模式:用一个线程池,池子里每个线程都调用同一个 io_contextrun(),让这些线程互相"抢"任务队列里的活干
这套机制之所以能成立,全靠 io_context 自带的线程安全事件分发系统——它内部已经做好了加锁保护,允许多个线程同时调用 run() 去争抢、执行队列里等待处理的异步操作,你完全不需要自己在外面再套一层锁去协调这些线程该怎么分工。

二、完整代码示例一:线程池竞争处理单个定时器任务

下面这段代码创建了一个跟 CPU 核心数量相同的线程池,每个线程都调用同一个 io_context.run()。这里只注册了一个 2 秒后到期的定时器,所以最终只会有池子里的某一个线程"抢到"并执行这个任务,其他线程会在 run() 里等到队列彻底清空后各自返回。

// 编译命令:
// g++ -std=c++17 thread_pool_single_task.cpp -o thread_pool_single_task -lpthread
#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
using namespace std::chrono_literals; // 让我们能直接写 2s 这种字面量
int main() {
    // 全局只有这一个 io_context,之后会被多个线程共同调用它的 run()
    boost::asio::io_context io_context;
    // 只注册了这一个异步任务:2 秒后到期的定时器
    boost::asio::steady_timer timer(io_context, 2s);
    timer.async_wait(
        [](const boost::system::error_code& /*ec*/) {
            // 这里没有用参数 ec,所以用注释掉的参数名表示"故意不使用它"
            // 到底是线程池里的哪一个线程执行这个回调,运行前是不确定的,
            // 取决于当时正好是哪个线程"抢到"了这个已完成的任务
            std::cout << "定时器到期!\n";
        });
    // 用 hardware_concurrency() 获取当前机器建议的并发线程数,
    // 这个数字通常接近 CPU 的逻辑核心数量,是线程池大小的一个合理起点
    const std::size_t num_threads = std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    // 创建 num_threads 个线程,每一个线程做的事情完全一样:
    // 调用同一个 io_context 的 run(),进入事件循环去抢任务执行
    for (std::size_t i = 0; i < num_threads; ++i) {
        threads.emplace_back([&io_context]() {
            io_context.run();
        });
    }
    // 等待所有线程结束。因为只有一个异步任务,
    // 大多数线程的 run() 会很快发现队列是空的然后直接返回,
    // 只有抢到那个定时器任务的线程会先执行完回调,然后再返回
    for (auto& t : threads) {
        t.join();
    }
    return 0;
}

关键点说明:

  • 所有线程调用的都是同一个 io_context 对象的 run(),这跟上一节"每个线程一份独立 io_context"的模型完全不同——那边是互不相干的 N 份,这边是所有线程共享同一份。
  • 因为只注册了一个定时器任务,这个例子其实没法充分展示"并行处理多个任务"的效果,更多是在演示这种线程池结构本身的写法。下一个例子会注册多个任务,效果会更直观。

三、完整代码示例二:线程池并行处理多个任务

为了更清楚地看到"多个线程真的在同时执行不同的完成处理函数",下面这个例子注册了 8 个定时器,每个的到期时间都错开一点,配合一个受互斥锁保护的计数器,观察这些任务是怎么被线程池分头处理掉的。

// 编译命令:
// g++ -std=c++17 thread_pool_multi_task.cpp -o thread_pool_multi_task -lpthread
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
// 完成处理函数如果会修改共享资源(这里是 std::cout 和一个计数器),
// 就必须自己负责加锁保护,io_context 不会替你做这件事
std::mutex output_mutex;
int completed_count = 0;
int main() {
    boost::asio::io_context io_context;
    // 用智能指针保存定时器,防止 vector 扩容时发生拷贝/移动导致定时器失效
    std::vector<std::unique_ptr<boost::asio::steady_timer>> timers;
    const int task_count = 8;
    for (int i = 1; i <= task_count; ++i) {
        // 让 8 个定时器的到期时间错开,模拟"陆续完成"的真实场景
        auto timer = std::make_unique<boost::asio::steady_timer>(
            io_context, std::chrono::milliseconds(i * 100));
        timer->async_wait([i](const boost::system::error_code& ec) {
            if (!ec) {
                // 多个线程可能同时执行到这里,修改 completed_count
                // 和往 std::cout 打印内容都属于"共享资源",必须加锁保护
                std::lock_guard<std::mutex> lock(output_mutex);
                ++completed_count;
                std::cout << "任务 " << i << " 完成,"
                          << "执行线程: " << std::this_thread::get_id()
                          << ",当前已完成 " << completed_count
                          << " / " << task_count << " 个任务\n";
            }
        });
        timers.push_back(std::move(timer));
    }
    const std::size_t num_threads = std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    for (std::size_t i = 0; i < num_threads; ++i) {
        threads.emplace_back([&io_context]() {
            // 池子里的每个线程都在这里"抢活干":
            // 谁先跑到这一行、谁先注意到队列里有新完成的任务,
            // 谁就负责执行对应的完成处理函数
            io_context.run();
        });
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "全部 " << task_count << " 个任务处理完毕\n";
    return 0;
}

关键点说明:

  • 注意 completed_countstd::cout 都是被多个线程共享、并且会被修改的资源,所以在回调函数里必须用 std::lock_guard<std::mutex> 加锁保护,这正是原文强调的那句话:如果完成处理函数会跨线程共享数据或者修改公共资源,必须自己负责同步和线程安全io_context 只保证它自己内部的调度是线程安全的,不会替你保护你自己业务逻辑里用到的数据。
  • 运行这个例子你会看到,"任务 X 完成"的打印顺序基本上符合定时器到期的先后顺序(因为间隔了 100 毫秒,差距比较明显),但打印出来的"执行线程"这一列,会看到不同的线程 ID 轮流出现——这就是线程池真正在并行处理不同任务的直接证据。
  • 同时也印证了原文提到的另一点:完成处理函数的执行顺序是没有保证的。虽然这个例子因为间隔较大所以看起来"基本有序",但如果多个定时器几乎同时到期,具体谁先谁后完全取决于当时哪个线程先被调度到、先注意到队列里的新任务,没有任何顺序保证。

四、这种模式的优点和潜在代价


方面说明
可扩展性多个线程可以同时利用多核 CPU 去处理不同的异步任务,而不是只靠一个线程一件一件地串行处理
延迟因为多个任务可以被并发处理,不需要排队等前一个任务处理完,整体的响应延迟会降低
吞吐量与争用相比单线程处理大量并发 I/O 操作时容易形成的瓶颈,这种方式能减少争用(contention)、提高吞吐量
完成处理函数的线程安全如果多个完成处理函数会共享数据或修改公共资源,必须自己用互斥锁等同步手段保护,io_context 不会替你处理这部分
执行顺序没有任何顺序保证,哪个任务先完成、被哪个线程执行、先后打印出来的顺序,都可能因为运行时的调度情况而变化
线程池大小线程之间要竞争从队列里取任务,如果线程池大小设置得不合理(太多或太少),可能带来锁争用或者频繁上下文切换的额外开销,理想情况下应该让线程数量匹配硬件线程数(比如例子里用的 hardware_concurrency()

五、结构示意图:线程池竞争抢占任务队列

                     +----------------------+
                     |      io_context       |
                     |    内部任务队列         |
                     |  [任务1][任务2][任务3]..|
                     +----------------------+
                        ^      ^      ^      ^
                        |      |      |      |
                     抢任务   抢任务  抢任务  抢任务
                        |      |      |      |
                  +--------+ +--------+ +--------+ +--------+
                  | 线程 0  | | 线程 1  | | 线程 2  | | 线程 3  |
                  | run()  | | run()  | | run()  | | run()  |
                  +--------+ +--------+ +--------+ +--------+

每个线程都在做同一件事——反复问 io_context:"队列里还有没有已经完成、等着被执行的任务?"一旦有,谁先问到就归谁处理,处理完了接着再问下一个,直到队列彻底清空。

六、时序图:多个任务被线程池分头处理的过程

线程 2 线程 1 线程 0 io_context 任务队列 主线程 main() 线程 2 线程 1 线程 0 io_context 任务队列 主线程 main() 定时器 A 率先到期 定时器 B、C 几乎同时到期 队列已空 注册 3 个定时器的 async_wait(handler) 启动线程,调用 io_context.run() 启动线程,调用 io_context.run() 启动线程,调用 io_context.run() 线程1 恰好先查询到,领走这个任务 执行 handler,加锁更新共享计数器 线程0 领走定时器 B 对应的任务 线程2 领走定时器 C 对应的任务 执行 handler,加锁更新共享计数器 执行 handler,加锁更新共享计数器 run() 返回 run() 返回 run() 返回 join() 完成 join() 完成 join() 完成

七、小结

  • 用一个 io_context 配合一个线程池、让所有线程都调用同一个 run(),是并行处理大量异步任务的常见做法,背后依赖的是 io_context 自带的线程安全事件分发能力。
  • 这种模式能提升可扩展性(充分利用多核)、降低延迟(任务并发处理而不是排队等待)、减少单线程处理大量并发 I/O 时容易出现的瓶颈。
  • 但完成处理函数如果涉及共享数据或公共资源,必须自己加锁或使用其他同步手段保护,io_context 只负责调度层面的线程安全,不会替你的业务逻辑兜底。
  • 任务的完成顺序和被哪个线程执行都是不确定的;线程池大小也不是越大越好,理想情况是让线程数量匹配硬件线程数,太多线程反而会因为互相竞争任务队列而带来额外的锁争用和上下文切换开销。

管理对象生命周期 —— 用 shared_from_this 防止异步回调访问"死"对象

一、先搞清楚 C++ 里"对象的生命周期"指什么

C++ 标准对"对象的生命周期"有一个很明确的定义:
对象的生命周期 = [ 构造函数执行完毕的那一刻    ,    析构函数开始执行的那一刻 ) \text{对象的生命周期} = [\text{构造函数执行完毕的那一刻} \;,\; \text{析构函数开始执行的那一刻}) 对象的生命周期=[构造函数执行完毕的那一刻,析构函数开始执行的那一刻)
也就是说,一旦析构函数开始执行,这个对象就已经"死"了——哪怕它占用的内存暂时还没被真正回收、看起来"好像还能用",只要访问它的成员变量或调用它的成员函数,就属于未定义行为(undefined behavior),可能崩溃,也可能"看起来正常"但其实已经埋下了隐患。

二、为什么异步编程特别容易撞上这个问题

同步代码里,对象的生命周期和函数调用的先后顺序是很直观的——一个函数调用发生时,调用它所依赖的对象通常都还"活着",因为代码是顺序执行的。
但异步编程完全不是这么回事:我们注册一个完成处理函数(比如 timer.async_wait(回调函数))之后,这个回调函数不会立刻执行,而是要等到某个未来的时间点(定时器到期、数据到达等)才会被 io_context 调用。在这段"等待"的时间里,程序的其他部分完全可能已经把创建回调时所依赖的那个对象销毁掉了——如果回调函数里用到了这个对象(比如通过 this 指针访问成员变量),到时候执行的就是"访问一个已经死掉的对象",后果不可预测。

三、用一个错误示范直观感受这个问题

下面这份代码故意示范了"没有做好生命周期管理"会发生什么。注意:这份代码包含未定义行为,只是用来教学演示,实际项目中不要这样写

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
// 一个"有问题"的定时器包装类:没有对生命周期做任何保护
class BadTimerUser {
public:
    BadTimerUser(boost::asio::io_context& io_context, int id)
        : timer_(io_context, 2s), id_(id) {
        std::cout << "BadTimerUser " << id_ << " 被构造\n";
    }
    ~BadTimerUser() {
        std::cout << "BadTimerUser " << id_ << " 被析构\n";
    }
    void start() {
        // 危险点: lambda里直接捕获裸指针this,
        // 完全没有任何机制保证2秒后回调真正执行时,这个对象是否还活着
        timer_.async_wait([this](const boost::system::error_code& ec) {
            // 如果对象已经被delete了,下面这行是未定义行为
            std::cout << "[危险回调] 定时器到期,对象id = " << id_ << std::endl;
        });
    }
private:
    boost::asio::steady_timer timer_;
    int id_;
};
int main() {
    boost::asio::io_context io_context;
    // 用new在堆上手动创建对象,模拟生命周期由外部代码手动控制的场景
    BadTimerUser* user = new BadTimerUser(io_context, 1);
    user->start();
    // 危险操作: 定时器还没到期(要等2秒),这里就提前把对象销毁了
    delete user;
    std::cout << "对象已经被delete,但定时器还没到期\n";
    // 2秒后,之前注册的回调依然会被io_context调用,
    // 但它试图访问的id_成员所在的内存,其实已经不再属于一个"活着"的对象了
    io_context.run();
    return 0;
}

逐段说明问题出在哪:

  • start() 里的 timer_.async_wait([this](...) {...}):捕获的是裸指针 this,lambda 内部完全依赖这个指针在将来还指向一个有效对象。
  • delete user;:对象在定时器到期之前就被销毁了,析构函数已经跑完,id_ 所在的那块内存理论上已经"不属于任何对象"了。
  • io_context.run();:事件循环照样会在 2 秒后调用之前注册的回调,回调内部访问 id_ 时,本质上是在读一块"野内存"——这就是为什么说这是未定义行为:它可能凑巧还没被覆盖、看起来打印出了正确的值,也可能已经被其他数据覆盖、打印出乱码,更糟的情况下会直接让程序崩溃。

四、解决方案:shared-from-this 模式

思路很直接:既然问题出在"回调执行时对象可能已经死了",那就让对象在有异步操作依赖它的这段时间里,自己"延长"自己的寿命,保证不会被提前销毁。
具体做法是:对象自己持有(或者临时创建)一个指向自己的 std::shared_ptr,然后把这个 shared_ptr(而不是裸指针 this)传递或拷贝进异步回调里。这样一来,只要这个回调还没执行完、还"拿着"这份 shared_ptr,对象的引用计数就不会归零,对象就会一直存活,哪怕外部所有其他地方都已经不再持有这个对象的 shared_ptr 了。
C++11 开始提供了一个专门配合这个模式的工具——std::enable_shared_from_this<T> 模板基类。只要让自己的类 T 公开继承这个基类,就能在类内部调用 shared_from_this() 方法,拿到一个指向"自己"的 shared_ptr<T>

4.1 它的工作原理

std::enable_shared_from_this<T> 内部悄悄保存了一个 std::weak_ptr<T>,指向"管理这个对象的那个 shared_ptr 所在的控制块"。这个 weak_ptr 是在对象第一次被 std::shared_ptr 接管的时候(比如通过 std::make_shared<T>(...) 或者 std::shared_ptr<T>(new T(...)))自动被设置好的。之后调用 shared_from_this(),本质上就是把这个内部的 weak_ptr “升级”(lock)成一份新的 shared_ptr,跟外部原来的那份 shared_ptr 共享同一个控制块、同一份引用计数。

                 +---------------------------+
                 |     控制块(control block)   |
                 |     引用计数 use_count      |
                 +---------------------------+
                        ^              ^
                        |              |
          外部持有的shared_ptr    lambda回调里持有的self
          (比如main里的user)     (由timer的回调保存)
                        |              |
                        v              v
                      +------------------------+
                      |        T 对象本体        |
                      | 继承enable_shared_from_ |
                      | this<T>,内部存着一份    |
                      | weak_ptr<T>指向控制块    |
                      +------------------------+

只要控制块里的引用计数 use_count 不为 0 0 0,对象就不会被析构:
对象被析构的条件: u s e _ c o u n t = 0 \text{对象被析构的条件:} use\_count = 0 对象被析构的条件:use_count=0
哪怕外部那份 shared_ptr(比如 main 里的 user)已经被释放,只要回调里的 self 还没用完,use_count 就至少还是 1 1 1,对象依然安全存活。

五、完整可运行的正确版本代码

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <memory>
using namespace std::chrono_literals;
// 正确写法: 公开继承std::enable_shared_from_this<SafeTimerUser>
class SafeTimerUser : public std::enable_shared_from_this<SafeTimerUser> {
public:
    SafeTimerUser(boost::asio::io_context& io_context, int id)
        : timer_(io_context, 2s), id_(id) {
        std::cout << "SafeTimerUser " << id_ << " 被构造\n";
    }
    ~SafeTimerUser() {
        std::cout << "SafeTimerUser " << id_ << " 被析构\n";
    }
    void start() {
        // 关键一步: 调用shared_from_this()拿到一份指向"自己"的shared_ptr,
        // 命名为self,接下来把self(而不是裸指针this)捕获进lambda
        auto self = shared_from_this();
        timer_.async_wait([self](const boost::system::error_code& ec) {
            // 这里访问self->id_是完全安全的:
            // 只要这个lambda还没执行完,self就一直持有对象的一份所有权,
            // 对象的引用计数就不可能降到0,也就不会被析构
            std::cout << "[安全回调] 定时器到期,对象id = " << self->id_ << std::endl;
        });
    }
private:
    boost::asio::steady_timer timer_;
    int id_;
};
int main() {
    boost::asio::io_context io_context;
    // 必须用std::shared_ptr来管理这个对象,
    // 因为shared_from_this()要求对象从一开始就被某个shared_ptr接管,
    // 否则调用shared_from_this()会抛出std::bad_weak_ptr异常
    auto user = std::make_shared<SafeTimerUser>(io_context, 1);
    user->start();
    // 主动放弃main这里持有的这份shared_ptr的所有权,
    // 但因为lambda内部的self依然持有另一份指向同一个对象的shared_ptr,
    // 所以对象不会在这里被销毁,会一直安全存活到回调执行完毕
    user.reset();
    std::cout << "外部的shared_ptr已经reset,但对象依然安全存活\n";
    // 2秒后,回调被安全调用;调用完毕后lambda(以及它持有的self)被销毁,
    // 这时候对象的引用计数才真正归零,对象随之被析构
    io_context.run();
    return 0;
}

编译方式:

g++ -std=c++17 shared_from_this_demo.cpp -o shared_from_this_demo -lboost_system -lpthread

预期输出("[安全回调]"那一行大约在 2 秒后才出现,析构一定发生在它之后):

SafeTimerUser 1 被构造
外部的shared_ptr已经reset,但对象依然安全存活
[安全回调] 定时器到期,对象id = 1
SafeTimerUser 1 被析构

可以看到,即使 main 里的 user.reset() 早早地放弃了对象的所有权,对象也没有立刻被析构,而是一直"撑"到回调真正执行完毕之后才被销毁——这正是 shared_from_this 模式想要达到的效果。

六、两种写法的执行时序对比

6.1 错误写法:对象可能在回调触发前就被销毁

io_context BadTimerUser对象(堆上) main线程 io_context BadTimerUser对象(堆上) main线程 析构函数执行完毕 对象已经"死"了 大约2秒后 之前注册的回调依然会被调用 未定义行为: 可能崩溃,也可能"看似正常" new BadTimerUser(...) async_wait(捕获裸指针this) delete user io_context.run() 访问已经销毁对象的id_

6.2 正确写法:shared_ptr 保证对象活到回调结束

io_context SafeTimerUser对象(堆上) main线程 io_context SafeTimerUser对象(堆上) main线程 引用计数不为0 对象依然安全存活(靠lambda里的self撑着) 大约2秒后 定时器到期 回调执行完毕,lambda和它持有的self被销毁 引用计数降为0 make_shared<SafeTimerUser>(...) 引用计数=1 start()内部调用shared_from_this() 生成self,引用计数=2 async_wait(捕获self拷贝) user.reset() 外部shared_ptr释放,引用计数降为1 io_context.run() 安全访问self->>id_ 对象此刻确定还活着 析构函数被调用,对象真正被销毁

七、使用 shared_from_this 时要记住的几条规则


规则说明
必须公开继承class T : public std::enable_shared_from_this<T>,继承方式必须是public
对象必须已经由shared_ptr管理调用shared_from_this()之前,对象必须已经通过std::make_sharedstd::shared_ptr(new T(...))被接管
不能在构造函数里调用构造函数执行期间,对象还没有真正被shared_ptr接管,此时调用shared_from_this()会抛出std::bad_weak_ptr
捕获的是shared_ptr本身回调里应该捕获shared_from_this()返回的shared_ptr(比如self),而不是继续捕获裸指针this
多次调用返回同一个控制块多次调用shared_from_this()得到的多个shared_ptr,共享同一份引用计数,不会重复计数出错

八、小结


概念作用
对象生命周期从构造函数结束到析构函数开始之间的这段区间
悬空访问问题异步回调延迟执行,可能在对象已经被销毁后才访问它
std::enable_shared_from_this<T>让类可以安全地获取一份指向自身的shared_ptr
shared_from_this()返回与外部共享同一控制块的shared_ptr<T>
shared-from-this模式self(而不是裸this)捕获进异步回调,用引用计数延长对象寿命

这个模式最核心的价值在于:它把"对象什么时候可以被销毁"这件事,从人工去猜测"回调什么时候执行完",变成了交给引用计数自动判断——只要还有地方(哪怕只是某个还没跑完的异步回调)持有指向这个对象的 shared_ptr,对象就不会被销毁;等最后一份 shared_ptr 也用完了,对象才会被自动、安全地析构。

实现一个回声服务器(Echo Server)

一、为什么网络编程特别适合用 Boost.Asio

网络数据的传输往往需要很长时间才能完成,中途还可能出现各种各样的错误(连接断开、超时、对方拒绝连接等等)。正因为这种"耗时长、易出错"的特性,网络 I/O 天生就很适合用 Boost.Asio 这种异步框架来处理——事实上,网络相关的功能也是这个库最早支持的那批服务。
在实际的工业应用里,Boost.Asio 最常见的用途就是开发网络程序,因为它对 TCP、UDP、ICMP 这几种互联网协议都有很好的支持。它提供的 socket 接口是基于 BSD socket API 设计的,既可以用底层接口去追求极致的性能和可控性,也可以用更高层的异步接口去追求开发效率。这一节我们聚焦在异步编程上,用高层接口实现一个回声服务器(echo server)
所谓回声服务器,就是一个监听在某个地址和端口上的程序,客户端发什么内容过来,它原样再发回去。我们用 TCP 协议来实现这个服务器。

二、整体结构:EchoServer 负责接客,Session 负责聊天

先搞清楚整个程序分成哪几块:

  • main() 函数:只做三件事——创建 io_context,用它和一个端口号构造出 EchoServer 对象,然后调用 io_context.run() 启动事件循环。
  • EchoServer:负责"开门迎客",也就是监听端口、接受新的客户端连接。每当有一个新客户端连进来,它就负责创建一个专门服务这个客户端的 Session 对象,然后继续监听下一个连接。
  • Session:负责"接待"某一个具体的客户端,管理这一条连接上的数据读写循环——读到什么就写回去,写完了接着读下一轮。
    用一张表把两个类的职责分清楚:

类名负责的事情持有的关键成员
EchoServer监听端口,接受新连接,为每个新连接创建一个 Sessiontcp::acceptor(负责接受连接的对象)
Session管理单个客户端连接的读写循环tcp::socket(这个连接对应的套接字)、data_ 缓冲区

这样拆分的好处是:EchoServer 只管"接人",不用操心每个客户端具体怎么读写数据;Session 只管"服务一个人",不用关心还有没有别的客户端在排队连接。这样一来,服务器可以同时服务很多个客户端,每条连接的状态都是彼此独立、异步管理的。
用一张 ASCII 图把这种"一对多"的结构画出来:

                      +----------------+
                      |   EchoServer   |
                      |                |
                      |  tcp::acceptor |  <-- 只有一个,负责监听端口
                      +----------------+
                              |
                   每接受一个新连接,就创建
                              |
           +------------------+------------------+
           |                  |                  |
           v                  v                  v
    +-------------+    +-------------+    +-------------+
    |  Session A  |    |  Session B  |    |  Session C  |
    | (客户端1)    |    | (客户端2)    |    | (客户端3)    |
    +-------------+    +-------------+    +-------------+

三、完整可运行代码

下面把 mainEchoServerSession 三部分整合成一个可以直接编译运行的完整文件,每一处关键代码都加上了详细注释。

// 编译命令:
// g++ -std=c++17 echo_server.cpp -o echo_server -lpthread
// 如果链接报错缺符号,再加上 -lboost_system
#include <boost/asio.hpp>
#include <cstddef>
#include <iostream>
#include <memory>
#include <utility>
// 把 boost::asio::ip::tcp 引入当前作用域,方便后面直接写 tcp::socket、tcp::acceptor
using boost::asio::ip::tcp;
// ======================================================================
// Session:负责管理"一个具体客户端连接"的读写循环
// 继承自 std::enable_shared_from_this<Session>,
// 这样 Session 对象内部就能安全地创建"指向自己的 shared_ptr"
// ======================================================================
class Session : public std::enable_shared_from_this<Session> {
public:
    // 构造函数接收一个已经建立好连接的 socket,通过移动语义把它的所有权
    // 转移进来(socket 本身不允许拷贝,只能移动)
    explicit Session(tcp::socket socket) : socket_(std::move(socket)) {}
    // 外部调用这个函数来启动这条连接的读写循环
    void start() { do_read(); }
private:
    // 缓冲区最大能容纳的字节数,读取到的数据会先暂存在 data_ 数组里
    static constexpr std::size_t max_length = 1024;
    // 发起一次异步读取
    void do_read() {
        // shared_from_this() 会返回一个指向"当前这个 Session 对象"的 shared_ptr,
        // 把它存到 self 这个局部变量里,之后会被 lambda 按值捕获
        auto self(shared_from_this());
        // async_read_some 异步地从 socket_ 里读取数据,写入 data_ 缓冲区,
        // 最多读取 max_length 字节;这一行调用立刻返回,不会阻塞
        socket_.async_read_some(
            boost::asio::buffer(data_, max_length),
            // 同时按值捕获 this(用来访问成员函数/成员变量)
            // 和 self(用来保证 Session 对象在这次异步操作完成之前不会被销毁)
            [this, self](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    // 读取成功,length 是这次实际读到的字节数
                    // 接下来把这些内容原样写回给客户端
                    do_write(length);
                }
                // 如果读取出错(比如客户端断开连接),
                // 这里什么都不做,循环自然结束,
                // 等这个 lambda 执行完、self 被销毁,
                // Session 对象就会因为没有其他 shared_ptr 指向它而被自动释放
            });
    }
    // 把 data_ 缓冲区里的前 length 个字节异步写回给客户端
    void do_write(std::size_t length) {
        auto self(shared_from_this());
        // async_write 会保证把这 length 个字节全部写完才算成功
        // (相比 socket_.async_write_some,它会自动处理"写了一部分"的情况)
        boost::asio::async_write(
            socket_, boost::asio::buffer(data_, length),
            [this, self](boost::system::error_code ec, std::size_t /*length*/) {
                if (!ec) {
                    // 写回成功,重新发起下一轮读取,
                    // 这样就形成了"读到什么就写回什么,然后继续读"的循环
                    do_read();
                }
            });
    }
    tcp::socket socket_;       // 这条连接对应的套接字
    char data_[max_length];    // 用来临时存放读到的数据,也是写回时的数据源
};
// ======================================================================
// EchoServer:负责监听端口、接受新连接,为每个新连接创建一个 Session
// ======================================================================
class EchoServer {
public:
    // 构造函数需要一个 io_context(所有 I/O 对象都要绑定它)和监听的端口号
    EchoServer(boost::asio::io_context& io_context, short port)
        // acceptor_ 用 io_context 和一个 endpoint 对象来初始化
        // tcp::v4() 表示使用 IPv4 协议
        // 没有指定具体 IP 地址,意味着监听所有网卡地址(也就是 INADDR_ANY)
        : acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) {
        // 构造完成后立刻开始监听、接受连接
        do_accept();
    }
private:
    // 发起一次异步的"接受新连接"操作
    void do_accept() {
        // async_accept 会在有新客户端连接进来时,把对应的 socket 交给回调函数
        // 这一行调用立刻返回,不会阻塞
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    // 没有出错,说明成功接受了一个新连接
                    // 用 make_shared 创建一个 Session 对象,
                    // 把刚建立好的 socket 移动进去,然后立刻调用 start() 启动它
                    //
                    // 这里创建的 shared_ptr 是暂时的:
                    // start() 内部的 do_read() 会通过 shared_from_this()
                    // 再创建一份指向同一个 Session 的 shared_ptr(就是 self),
                    // 所以即使这里创建的这个临时 shared_ptr 在这一行结束后被销毁,
                    // Session 对象也不会被析构,因为 self 还在里面保着一份引用计数
                    std::make_shared<Session>(std::move(socket))->start();
                }
                // 不管这次接受连接成功还是失败,都要继续监听下一个连接,
                // 否则服务器处理完一个客户端后就再也不会接受新客户端了
                do_accept();
            });
    }
    tcp::acceptor acceptor_; // 负责监听端口、接受连接的对象
};
// ======================================================================
// main:程序入口
// ======================================================================
int main() {
    constexpr int port = 1234; // 服务器监听的端口号
    try {
        boost::asio::io_context io_context;
        // 创建 EchoServer 时,构造函数内部就已经开始监听并等待第一个连接了
        EchoServer server(io_context, port);
        // 启动事件循环,阻塞在这里,处理所有连接的接受、读取、写入等异步操作
        // 只要程序不主动退出,这个服务器就会一直运行下去
        io_context.run();
    } catch (std::exception& e) {
        // 捕获运行过程中可能出现的异常(比如端口已被占用导致 acceptor 构造失败)
        std::cerr << "Exception: " << e.what() << "\n";
    }
    return 0;
}

四、为什么 self 这个变量看起来没被用到,却又必须存在

do_read()do_write() 里都有这么一行:

auto self(shared_from_this());

然后在 lambda 的捕获列表里写了 [this, self],但 lambda 函数体内部却完全没有直接用到 self 这个名字,看上去很多余。这里的关键在于:self 按值捕获进 lambda 之后,会在 lambda 内部生成一份 self 的拷贝,而拷贝一个 shared_ptr 会让它指向对象的引用计数加一。
也就是说,只要这个 lambda(也就是异步操作的完成处理函数)还"活着"、还没被执行或者销毁,它内部持有的那一份 self 拷贝就会一直让 Session 对象的引用计数保持在"至少 1",Session 对象就不会被提前析构。this 指针则是用来让 lambda 内部能够访问 Session 的成员函数(比如调用 do_write)和成员变量(比如 socket_data_),但它本身不持有所有权,不会影响对象的生命周期。
这两者分工非常清楚:this 负责"怎么访问",self 负责"活多久"。等到所有异步操作都完成、所有相关的 lambda 都执行完毕并被销毁之后,如果已经没有其他地方持有指向这个 Sessionshared_ptr 了(比如 EchoServer::do_accept 里创建的那个临时指针也早就出了作用域),Session 对象才会真正被析构掉,这条连接的生命周期到此结束。
这种写法保证了在异步操作等待期间,相关对象绝对不会因为"看起来没人用了"就被提前销毁,从而避免悬空指针导致的崩溃或者未定义行为。

五、单个客户端连接的完整生命周期

用时序图把"一个客户端连接从建立到关闭"的完整过程画出来:

Session 对象 io_context EchoServer::acceptor_ 客户端 Session 对象 io_context EchoServer::acceptor_ 客户端 self 持有一份 shared_ptr,保证 Session 存活 所有相关 lambda 执行完毕, 没有 shared_ptr 再指向这个对象, Session 被自动析构 发起 TCP 连接请求 之前已经注册了 async_accept(handler) 新连接到达,回调 handler(ec, socket) make_shared<Session>(移动 socket) 调用 session->>start() 内部调用 do_read() 再次调用 do_accept(),继续监听下一个连接 socket_.async_read_some(handler) 发送数据,例如 "Hello world!" 读取完成,回调 handler(ec, length) 调用 do_write(length) async_write(socket_, 数据, handler) 写回完成,回调 handler(ec, length) 调用 do_read(),进入下一轮循环 断开连接 async_read_some 返回错误码 ec ec 不为空,不再调用 do_write,循环终止

六、如何测试这个回声服务器

编译并运行这个程序之后,打开另一个终端,用 telnet 命令连接上去,随便输入点什么,回车之后应该会看到服务器把同样的内容发回来:

$ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello world!
Hello world!
telnet> quit
Connection closed.

这里 localhost 对应的是 127.0.0.1,表示我们连接的是本机上正在运行的服务器;1234 是我们在代码里指定的监听端口。输入 Hello world! 并回车后,服务器原样把这行内容发了回来,这正是"回声服务器"这个名字的由来。想退出连接的话,先按下 Ctrl + ] 组合键进入 telnet 的命令模式,再输入 quit 即可。

七、小结

  • 网络 I/O 天生具备"耗时不确定、容易出错"的特点,非常适合用 Boost.Asio 的异步模型来处理,这也是网络功能最早被加入这个库的原因。
  • 一个典型的异步 TCP 服务器可以拆成两层:EchoServer(或者说 acceptor 层)只管接受新连接,Session(或者说 connection 层)只管处理某一条具体连接的数据收发,两者职责分离,服务器就能同时服务很多个互不干扰的客户端。
  • Session 继承自 std::enable_shared_from_this<Session>,配合在每次发起异步操作前用 shared_from_this() 创建一份 self 并按值捕获进 lambda,这套组合拳保证了只要还有异步操作在等待完成,对象就绝对不会被提前销毁,是 Boost.Asio 里管理异步对象生命周期最经典的模式。
  • thisself 分工不同:this 用来访问对象的成员,self 用来延长对象的生命周期,两者缺一不可,理解了这一点,就能看懂几乎所有 Boost.Asio 异步连接类代码里反复出现的这个写法。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐