本文中进行了对于另外一篇文章的简单总结和简化,可以在阅读完这篇文章之后去进行另外一篇文章的阅读,即逻辑杂谈(1)
Boost.Asio 的异步机制初探:启动时机与传输时机
启动时机
在最近的异步读写实例中,我注意到一些值得深入理解的点。其中最重要的一点,就是“异步驱动的执行者是谁”。
框架的本质
学过 Qt 的人应该清楚,框架和库的区别非常关键:
- 库(Library):由开发者主动调用函数完成任务。
- 框架(Framework):由框架主动调用你写的逻辑,开发者只需补充局部的业务处理。
ASIO 也是一种典型的框架思维。在同步逻辑中,我们仍然主动调用 API,而异步逻辑中,我们仅仅注册想要的行为,执行权则交由 ASIO 来驱动。
框架的执行逻辑
ASIO 内部维护了一个事件循环(io_context
),它类似线程池的轮询机制,框架通过它来调度各类 I/O 事件。而对于使用者来说,最重要的,是明确事件循环的启动与执行时机。
来看下面这段代码:
1 |
|
注意,_ioc.run()
才是服务器的真正启动点,而不是 Server
构造函数。我们不建议将 run
封装进构造函数中——那样会违反单一职责原则,造成耦合。
异步状态机的思想
理解 ASIO 的异步机制,本质上就是理解状态转移的过程。
状态机的抽象:函数执行流的每一个位置,视为程序的一个状态;当执行跳转到下一个函数,就发生了一次“状态转移”。
我们来看实际代码:
1 | Server::Server(boost::asio::io_context& ioc, short port) |
状态转移过程推演
- 构造函数中调用
start_accept()
,首次注册异步接收。 - 异步函数
async_accept
不会阻塞,而是向io_context
注册一个状态转移:- 触发点:客户端发起连接。
- 转移点:执行
handle_accept
。
- 一旦客户端连接建立,
handle_accept
被框架调用,状态转移发生。 - 在
handle_accept
结束时再次调用start_accept()
,重新注册下一次接收逻辑。
这就构成了一个回环式状态转移的循环。
传输时机
当客户端发起连接请求之后,服务器将进入下一阶段的处理——数据的接收与处理。
套接字的创建与替换
你需要知道:服务器并不会直接使用监听套接字来接收数据,而是由操作系统生成新的连接套接字来处理数据。监听套接字的职责仅是监听并接收连接请求。
1 | _acceptor.async_accept(new_session->getSocket(), |
在一次状态转移中:
- 操作系统在监听套接字接收请求后,会创建新的连接套接字。
async_accept
中的new_session->getSocket()
会成为数据传输的新载体。- 此时,程序注册的
handle_accept()
就绑定到了这条连接上。
数据如何传输?
操作系统接收到数据后:
- 数据首先到达 TCP 的内核缓冲区。
- 然后通过 OS 的内部机制,映射到与该连接对应的套接字缓冲区。
- 如果程序注册了对应的读取逻辑(比如
async_read_some
),那么就会触发下一步的状态转移。
但如果没有注册任何读取逻辑,则这些数据将被视为无用并被丢弃,造成资源浪费。
状态转移链的维护
在一次状态处理后,我们常常会再次注册下一次状态转移,例如:
1 | start_accept(); |
这一点非常重要,因为 每一个异步操作只会注册一次状态转移。触发一次后就失效,如果不再注册,则程序不会再响应新的事件。
错误处理与双重析构风险
在复杂的网络逻辑中,存在如下风险:
- 客户端断开连接。
- 程序逻辑触发某个会话的销毁,同时又注册了新的状态处理。
- 客户端发送
FIN
包,ASIO 响应后再次触发状态处理逻辑。 - 如果新状态中尝试访问已销毁的会话对象,会造成 双重析构 或 悬空指针,进而可能导致服务器崩溃。
因此,生命周期管理 是异步编程中最难也最重要的部分。需要确保每一个异步回调的对象都在有效生命周期内。
总结
- 异步机制的核心是“状态转移”。
- 每一个异步函数注册的其实是一次触发式的状态变换。
io_context.run()
是整个框架执行的起点。- 状态转移必须在每次处理后主动注册下一次,以实现循环。
- 错误处理需要特别注意对象生命周期,避免析构冲突。