Boost.Asio 异步编程
本文说明调度系统与车辆通信中的异步模型,供首次阅读Vehicle::start与TcpClient的开发人员使用。读完后应能判断:Vehicle::start(io)返回时连接尚未建立的原因,以及post、dispatch、poll、run、steady_timer的职责。
本文对应 Boost 1.74。文中官方文档链接均指向该版本。
1. 同步阻塞无法同时服务多台车辆
调度系统需要同时维持上百台车辆的连接。同步连接函数在操作系统完成三次握手之前不会返回。调用线程在此期间不能发起下一台车辆的连接,也不能处理已建立连接上的数据。若为每台车辆单独分配一条阻塞等待的线程,线程数量将随车辆数量线性增长。
异步接口把“发起操作”和“操作完成”分开。调用方提交完成处理函数后立即返回,随后可以继续为其他车辆提交操作。操作完成时,事件循环再调用先前提交的处理函数。
后文的类型和函数都落在这一划分上。io_context保存尚未执行的完成处理函数;async_wait、async_connect负责提交操作;io.run()在操作完成时执行对应的处理函数。
2. io_context 是完成处理函数的队列
boost::asio::io_context是完成处理函数的队列,并负责在定时器到期或套接字就绪时把处理函数放入该队列。
以async_开头的函数不调用用户传入的处理函数。它们完成两步后即返回:
- 记录完成条件,以及条件满足时要调用的函数。
- 把控制权交还调用方。
条件满足后,处理函数进入io_context。执行它的是正在调用io.run()或io.poll()的线程。
调度系统中的调用路径与此一致。Vehicle::start把io_context传入TcpClient。TcpClient::start调用async_resolve和async_connect后返回。VehicleManager在另一条线程上调用io.run()。连接建立后,由该线程调用onConnected。
3. 基本接口
| 接口 | 作用 | 调用返回时的状态 |
|---|---|---|
io_context | 完成处理函数队列;定时器到期或套接字就绪时将处理函数入队 | 对象已创建,尚未执行任何等待 |
steady_timer timer(io) | 定时器,到期后把处理函数提交给该io | 尚未开始等待 |
expires_after(时长) | 将到期时间设为当前时间加指定时长 | 不阻塞,不执行处理函数 |
async_wait(处理函数) | 提交到期时要调用的函数 | 立即返回,处理函数尚未执行 |
io.run() | 由当前线程执行队列中的处理函数;存在未完成操作时等待其完成 | 可能长时间不返回 |
io.poll() | 执行当前已经就绪的处理函数,然后返回 | 不等待尚未到期的定时器 |
asio::post(io, 函数) | 将函数放入队列 | 立即返回 |
asio::dispatch(io, 函数) | 当前线程已在该io的处理函数中时直接调用;否则与post相同,只入队 | 见第 7 节 |
steady_timer的构造函数接收io,因为定时器不拥有线程,必须指定到期后使用哪一个io_context。套接字、resolver和重连定时器同样绑定到这个io_context。
4. 定时器的执行顺序
下面的程序对应官方教程 Timer.2,用于观察expires_after、async_wait、poll和run的先后关系。
#include<boost/asio.hpp>#include<chrono>#include<iostream>intmain(){boost::asio::io_context io;boost::asio::steady_timertimer(io);timer.expires_after(std::chrono::milliseconds(300));timer.async_wait([](constboost::system::error_code&ec){std::cout<<"定时器回调 ec="<<ec.message()<<"\n";});std::cout<<"async_wait 已返回,回调还没执行\n";constautopolled=io.poll();std::cout<<"poll() 不等待,执行了 "<<polled<<" 个回调\n";std::cout<<"run() 会停住,直到 300ms 到点\n";io.run();}编译并运行:
g++-std=c++17-otimer_demo timer_demo.cpp-lpthread-lboost_system./timer_demo输出顺序如下:
async_wait 已返回,回调还没执行 poll() 不等待,执行了 0 个回调 run() 会停住,直到 300ms 到点 定时器回调 ec=Success各步含义:
expires_after把到期时间写成 300 毫秒之后。async_wait提交处理函数并返回。此时时间未到,处理函数尚未入队。poll只执行已经就绪的处理函数。定时器尚未到期,因此返回 0。run等待到期。300 毫秒后处理函数入队并执行。- 不存在其他未完成操作,
run返回,main结束。
ec.message()为Success表示定时器正常到期。若在到期前取消该定时器,错误码为Operation canceled。
周期任务在处理函数内再次调用expires_after和async_wait,从而安排下一次到期。心跳和重连退避都采用这一结构。Timer.3 与 Timer.4 说明如何在处理函数中持有定时器对象,以便发起下一次等待。
5. 阅读顺序
官方教程 Timer.1 至 Timer.5 分别对应上述接口,篇幅较短,建议按下列顺序阅读。
Timer.1
同步等待。wait()阻塞当前线程直至到期,不提交处理函数,也不调用io.run()。这是第 1 节所述的阻塞写法。Timer.2
将wait()替换为async_wait,再由io.run()执行处理函数。阅读后运行第 4 节的程序,核对输出顺序。Timer.3 与 Timer.4
说明处理函数如何持有定时器,从而再次调用expires_after和async_wait。车辆心跳与重连退避属于这类循环。Timer.5
多个线程同时对同一个io_context调用run时,处理函数可能并发访问同一对象。strand使提交到它上面的处理函数串行执行。核心模型
说明异步操作、io_context与完成处理函数三者的关系。异步操作包括async_wait和async_connect。
下列参考页用于查阅具体语义,不必通读:
- post
- dispatch
- run
- poll
事件循环的中文材料,可阅读陈硕的 muduo 与《Linux 多线程服务端编程》前几章。其中的 Reactor 循环与io.run()的职责相同:线程等待事件就绪,然后调用预先提交的处理函数。muduo 不使用 Asio,二者的调度模型一致。
接口作者 Christopher Kohlhoff 的演讲Thinking Asynchronously: Designing Applications with Boost.Asio讨论如何划分“发起操作”与“操作完成”。视频地址为 https://www.youtube.com/watch?v=D-lTwGJRx0o。页面中可通过… More→Show transcript打开英文讲稿。幻灯片地址为 https://raw.githubusercontent.com/boostcon/2011_presentations/master/mon/thinking_asynchronously.pdf。
6. post 只负责入队
boost::asio::io_context io;boost::asio::post(io,[]{std::cout<<"A\n";});boost::asio::post(io,[]{std::cout<<"B\n";});std::cout<<"post 已返回\n";io.run();输出:
post 已返回 A B两次post返回时,A 和 B 均未执行。run按入队顺序取出并调用它们。队列为空且不存在未完成的异步操作时,run返回。
run返回后若要再次使用同一io_context,须先调用io.restart()。未调用restart()时,后续run立即返回,不再执行处理函数。
7. dispatch 在事件循环线程上直接调用
dispatch与post的差异取决于调用方是否已经运行在该io_context的处理函数中。
boost::asio::io_context io;boost::asio::post(io,[&io]{std::cout<<"1 进入外层回调\n";boost::asio::dispatch(io,[]{std::cout<<"2 dispatch 当场执行\n";});std::cout<<"3 dispatch 已返回\n";boost::asio::post(io,[]{std::cout<<"5 post 等外层回调结束后才执行\n";});std::cout<<"4 post 只是入队\n";});io.run();输出:
1 进入外层回调 2 dispatch 当场执行 3 dispatch 已返回 4 post 只是入队 5 post 等外层回调结束后才执行外层处理函数由run执行。其中的dispatch在返回前完成内层函数。post只把内层函数入队,等外层处理函数返回后才执行。
从其他线程调用dispatch时,该线程并未运行此io_context,因此行为与post相同,只将函数入队。
8. run 与 poll
run | poll | |
|---|---|---|
| 已经就绪的处理函数 | 执行 | 执行 |
| 尚未到期的定时器 | 当前线程等待,到期后执行 | 立即返回 |
| 队列为空且没有未完成操作 | 返回 | 返回 |
run_one最多执行一个处理函数;存在未完成操作时会等待。poll_one最多执行一个已经就绪的处理函数;没有就绪项时立即返回。阅读代码时首先区分run与poll。
VehicleManager调用run。域名解析、连接、读数据和重连定时器完成后,由该调用执行对应处理函数,然后继续等待下一次完成。
9. executor_work_guard
当队列为空且不存在未完成的异步操作时,run返回。若线程需要先停在run中,等待其他线程随后post,应使用executor_work_guard:
boost::asio::io_context io;autowork=boost::asio::make_work_guard(io);// 队列为空时,io.run() 仍不返回// 其他线程 asio::post(io, ...) 后,处理函数仍由 run 执行work.reset();// 释放该计数后,队列排空时 run 方可返回VehicleManager::addIoContext为每个io_context创建一个work_guard。场景刚加载、尚未建立连接时,执行io.run()的线程因此不会退出。
10. strand
多个线程可以同时对一个io_context调用run。此时不同处理函数可能并发修改同一对象。
strand是一种 executor。提交到同一strand的处理函数按提交顺序串行执行。Timer.5 用它保护定时器。TcpClient的strand_使同一连接上的解析、连接、读和写处理函数按顺序访问该套接字。
11. 相关类型
executor
提交工作的句柄。io.get_executor()返回该io_context的 executor。post与dispatch的第一个参数可以是io_context、executor 或strand。steady_timer、socket、acceptor、resolver
这些是 I/O 对象。操作完成时,它们把处理函数提交到构造时所绑定的io_context。thread_pool
提供一组用于计算任务的线程,也支持post。它不等待套接字或定时器。车辆通信使用io_context。io_service
Boost 1.66 之前的类型名。自 1.66 起,对应类型为io_context。旧文档中的io_service按io_context理解。Reactor 与 Proactor
muduo 与《Linux 多线程服务端编程》描述的 Reactor 是:线程等待文件描述符或定时器就绪,再调用预先提交的处理函数。Asio 文档将自身的模型称为 Proactor:由库发起操作,完成后调用用户的处理函数。阅读调度系统代码时,采用“提交处理函数,由io.run()执行”这一描述即可。
12. 与调度系统代码的对应关系
| 概念 | 代码位置 |
|---|---|
创建io_context | SceneManager中make_shared<io_context>(),随后addIoContext |
线程执行run | VehicleManager::start为每个io_context启动线程并调用run |
将io_context传入车辆 | vehicle->start(*primaryIoContext) |
| 发起连接后立即返回 | TcpClient::start中的async_resolve与async_connect |
| 到期后再次发起 | reconnectTimer_的expires_after与async_wait |
| 完成处理函数 | onConnected,以及readData中的读完成处理 |
| 同一连接上的处理函数串行执行 | TcpClient的strand_ |
阅读以async_开头的函数时,确认以下三点:
- 处理函数提交到哪一个
io_context。 - 哪一条线程通过
run执行它。 - 该函数返回时,操作是否已经完成。
对async_resolve、async_connect、async_read_some和async_wait,函数返回时操作尚未完成。