☰
Boost.Asio实战:异步网络编程、Strand并发控制与批量写库
2026/10/7 21:46:14 网站建设 项目流程

去年接手一个物联网网关项目,要把上千台终端设备的实时数据接进来,再写进时序数据库。网络层选型时我几乎没有犹豫就定了Boost.Asio。不是说裸socket和epoll不行,而是连接数量上去之后,手动维护状态机、处理半包、跨平台迁移,每一步都在消耗精力。Asio把这些琐碎但关键的逻辑统一抽象成"异步操作+回调",让业务逻辑可以集中在一个on_read/on_write里。

这篇文章不打算复述官方文档,只讲我自己实际跑过、坑过、优化过的经验:从同步server起步,讲到异步模型、回调生命周期和strand,再讲如何把收到的数据安全地批量写入数据库。不管你是刚接触C++网络编程,还是已经写过一阵子socket但没上过Asio,应该都能找到有用的东西。

1. 为什么最后选了Boost.Asio:手写Socket的痛,Asio刚好都能治

1.1 "每连接一个线程"为什么扛不住

早期做局域网小工具,用socket加线程,一个连接一个std::thread,连接少的时候挺好。到了网关这种要支撑几千路长连接的项目,这个模型直接暴雷。

首先是线程切换成本。两千个线程,每个栈默认8MB虚拟内存,光栈空间就吃掉不少,再加上上下文切换,CPU时间大片浪费在线程调度上。其次是同步阻塞带来的连锁反应:某个连接上的业务如果慢了,对应线程被卡住,Accept侧还得继续拉线程,数量一路暴涨。

后来转epoll,属于另一种痛苦。边沿触发还是水平触发、事件表怎么维护、连接断开时状态怎么清理、业务层协议怎么从字节流里切包,全部自己造。写出来能跑,但代码量很大,而且换到Windows又要重来。我一度转向libevent,可C接口的复杂度不低,回调里到处是void*上下文,C++这边维护起来还是别扭。

Boost.Asio的抽象从根本上解决了这些问题。

1.2 Asio的核心抽象:IO对象加Proactor模式

很多人以为Asio只是把epoll包了一层,其实它借鉴的是Proactor模式:你发起一个async_read,给它一个完成回调,底层等待操作完成,然后把回调投递到io_context上执行。用户看到的不是"这个socket可读了",而是"读完了,数据在这里"。

这个差异非常关键。epoll给你的是"事件到达"通知,你还要自己去判断读多少、是否读完;Asio直接把"操作完成"这个结果交给你的业务代码。Linux上它用epoll实现,Windows上落到IOCP,接口完全一致。跨平台这点,对需要适配多种部署环境的服务来说是实打实的省力。

io_context是整个库的核心调度器,它自己不会主动干活,必须调用run()才会投产。可以把run()理解为"进入事件循环,不断取出已完成操作的handler并执行"。这个设计让线程模型非常灵活:你可以单线程跑,也可以多个线程同时调run(),让handler并行执行。

1.3 环境搭建:头文件、链接库、第一个编译问题

起步阶段卡人的往往不是概念,而是编译链接。

Ubuntu/Debian下:

sudo apt install libboost-all-dev g++ -std=c++17 -O2 -pthread main.cpp -lboost_system -o server

Boost 1.66之后,Asio主体基本是header-only,不需要单独链接libboost_system;但如果你用到boost::system::error_code,或者使用的是较老版本Boost,依然要-lboost_system。与其省这个链接,我建议一开始就加上,避免老项目升级时踩undefined reference的坑。

Windows上我一般用vcpkg安装:

vcpkg install boost-asio boost-system

然后集成到CMake,注意只保留一份Boost,别把系统里的和vcpkg里的混用,符号版本冲突排查起来很麻烦。这里提一个常见报错:编译通过但链接时报undefined reference to boost::system::detail::...,多半就是缺了boost_system,或者Boost头文件与库版本不一致。

2. 先把同步模型跑透:Echo Server的骨架和buffer的真实语义

2.1 十几行代码搭一个同步TCP服务端

网上有很多博客上来直接甩异步代码,读者看完云里雾里。我建议先跑通同步模型,把acceptor、socket、buffer这几个基础概念落到实处。

下面是一个最简同步echo server:

#include <boost/asio.hpp> #include <iostream> using boost::asio::ip::tcp; int main() { try { boost::asio::io_context io; tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080)); std::cout << "listen on 8080" << std::endl; while (true) { tcp::socket socket(io); acceptor.accept(socket); std::array<char, 128> buf; boost::system::error_code ec; size_t len = socket.read_some(boost::asio::buffer(buf), ec); if (ec) { std::cerr << "read failed: " << ec.message() << std::endl; continue; } boost::asio::write(socket, boost::asio::buffer(buf, len), ec); if (ec) { std::cerr << "write failed: " << ec.message() << std::endl; } } } catch (std::exception& e) { std::cerr << "exception: " << e.what() << std::endl; } return 0; }

这段代码的关键在于:acceptor负责监听,accept阻塞到有连接进来;read_some读一段数据;write原样回写。异常方面,boost::asio多数的函数都有抛异常版本和返回error_code版本,服务端代码我更习惯用error_code版本,把业务逻辑里面的正常关闭、异常断开分开处理。

2.2 buffer是视图不是容器:它不拷贝,只是描述内存区间

boost::asio::buffer是新手容易理解错的地方。它不是容器,不拥有内存,只是包装了"起始地址+长度"的一个视图。

std::string s = "hello"; boost::asio::write(sock, boost::asio::buffer(s)); // 同步写,没问题

同步操作里,write返回时数据已经写入OS发送缓冲区,内存安全性比较简单。但如果是异步操作,这里就暗藏危机:

void bad_write(tcp::socket& sock, boost::asio::io_context& io) { std::string s = "hello"; boost::asio::async_write(sock, boost::asio::buffer(s), [](auto, size_t) {}); // 函数结束,s 析构,但异步写入可能还没触发回调 }

异步回调触发时,buffer指向的内存早就释放了,轻则读到垃圾数据,重则崩溃。在异步场景里,所有传给async_*的buffer,其底层内存必须活到回调执行完。

2.3 同步模型的致命伤:一个连接阻塞整个循环

上面的server一次只能服务一个连接。只要某个客户端连上之后不发数据,accept之后的read_some就卡在那里,后面的连接全部排队。

改成每个连接开一个线程:

while (true) { auto sock = std::make_shared<tcp::socket>(io); acceptor.accept(*sock); std::thread([sock]() { handle_client(*sock); }).detach(); }

能并发,但回到了1.1的问题。线程数随连接数上涨,资源利用率低。所以同步模型适合写客户端、压测脚本、一次性工具,真正的高并发服务还是要走异步。

3. 异步模型进阶:回调、生命周期和strand

3.1 async_accept与enable_shared_from_this保命

异步服务端经典写法是:acceptor不断async_accept,每次连接创建一个Session,Session用shared_ptr管理。

class Session : public std::enable_shared_from_this<Session> { public: explicit Session(tcp::socket socket) : socket_(std::move(socket)) {} void start() { do_read(); } private: void do_read() { auto self = shared_from_this(); socket_.async_read_some(boost::asio::buffer(data_, max_length), [this, self](boost::system::error_code ec, std::size_t length) { if (!ec) { do_write(length); } }); } void do_write(std::size_t length) { auto self = shared_from_this(); 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_; enum { max_length = 1024 }; char data_[max_length]; }; class Server { public: Server(boost::asio::io_context& io, short port) : acceptor_(io, tcp::endpoint(tcp::v4(), port)) { do_accept(); } private: void do_accept() { acceptor_.async_accept( [this](boost::system::error_code ec, tcp::socket socket) { if (!ec) { std::make_shared<Session>(std::move(socket))->start(); } do_accept(); }); } tcp::acceptor acceptor_; };

为什么Session必须继承enable_shared_from_this?因为异步链是一个环:回调里捕获了Session对象,回调执行后又注册下一个回调,等于Session被回调链持有,必须保证最后一个回调执行完之前对象不会析构。如果用裸指针或者栈对象,回调触发时对象可能已经没了,这是异步网络编程里最常见的内存问题。

每次在do_read和do_write入口写auto self = shared_from_this();,是为了把引用计数先加一,保证整个操作期间Session存活。然后在lambda捕获列表里带上self,让生命周期顺着回调链一路延续。

3.2 async_read_some与async_read:TCP流没有消息边界

async_read_some只保证"读到一些字节",不保证正好是一个业务消息。很可能一条业务消息拆成了两次到达,也很可能两条消息粘在一次到达里。TCP是字节流,消息边界必须由应用层自己定义。

我习惯的做法是:约定一个简单帧格式,头部4字节表示长度,后面跟着payload。然后在Session里维护一个接收缓冲:

std::vector<char> read_buf_; std::vector<char> frame_buf_;

do_read不断把新数据追加到read_buf_,然后尝试从read_buf_里切出完整帧:

void do_read() { auto self = shared_from_this(); boost::asio::async_read(socket_, boost::asio::buffer(temp_buf_, temp_buf_.size()), [this, self](boost::system::error_code ec, std::size_t len) { if (ec) { handle_ec(ec); return; } read_buf_.insert(read_buf_.end(), temp_buf_.begin(), temp_buf_.begin() + len); while (try_parse_one_frame()) {} do_read(); }); } bool try_parse_one_frame() { if (read_buf_.size() < 4) return false; uint32_t body_len = ntohl(*(uint32_t*)read_buf_.data()); if (read_buf_.size() < 4 + body_len) return false; // 取出完整包,交给业务层 handle_frame(read_buf_.data() + 4, body_len); read_buf_.erase(read_buf_.begin(), read_buf_.begin() + 4 + body_len); return true; }

注意ntohl那行涉及字节序,跨平台时要小心。更稳妥是用memcpy读长度,避免未对齐访问。

3.3 多线程run()之后,strand是必需品

io_context::run()可以同时在多个线程里调用,这样多个handler会并发执行。但同一个socket上的读写如果并发,就乱套了:两个线程同时写,数据交叉;一个在读一个在关,资源释放的时机也会出问题。

strand就是解决这个问题的。它保证投递到同一个strand上的handler不会并发执行,相当于给异步操作上了一把"逻辑锁"。

boost::asio::strand<boost::asio::io_context::executor_type> strand_ = boost::asio::make_strand(io); boost::asio::async_write(socket_, buffer, boost::asio::bind_executor(strand_, [this, self](boost::system::error_code ec, std::size_t len) { ... }));

把Session里所有handler都通过bind_executor绑定到同一个strand,这样即便io_context被多个线程跑,同一Session内部仍然是串行的。新版Asio也可以直接用make_strand,风格更简洁。

很多人问能不能用一个全局mutex替代strand?可以,但代价是两个不同Session之间本来可以并行的读写也被串行化了,吞吐直接下降。strand是细粒度的,比全局锁合理得多。

3.4 超时、心跳与deadline_timer搭配

网络层最常见的故障就是"半开连接":对端已经消失,本端还不知道。做法是用steady_timer配合读写操作做超时控制。

boost::asio::steady_timer timer(io); timer.expires_after(std::chrono::seconds(30)); auto self = shared_from_this(); timer.async_wait([this, self](const boost::system::error_code& ec) { if (!ec) { // 超时,主动断开 boost::system::error_code ignored; socket_.close(ignored); } });

注意几点:timer的回调也要绑定到同一个strand,否则它可能和正在执行的读写回调并发;另外每次读到数据后要把timer重置一下,改成"空闲超时"而不是"绝对超时"。我一般把timer和读写操作都放在Session内部,统一由strand串行化,避免竞态。

4. 打通业务系统:把收到的数据批量写进数据库

4.1 网络回调里写库是"阻塞罪"

网关场景下,服务端收到数据后通常要落库,比如写进TDengine、MySQL、PostgreSQL。最容易犯的错误是在Asio的回调里直接调用数据库同步接口。

io_context的线程就那么几个,回调里一旦发生磁盘I/O和SQL执行,这个线程就被占住了。执行时间一长,后面所有连接的回调都排队,网络延迟跟着飙升。数据库写慢100毫秒,就可能拖累几十个连接。

4.2 双缓冲队列加独立写线程,把网络层与存储层解耦

我采用的通用模式是:网络回调只做解析和入队,独立的工作线程批量取数写库。

struct Record { uint64_t ts; double value; }; std::mutex mtx; std::deque<Record> queue; bool running = true; void on_message(Record rec) { std::lock_guard<std::mutex> lk(mtx); queue.push_back(std::move(rec)); } void db_worker() { while (running) { std::vector<Record> batch; { std::lock_guard<std::mutex> lk(mtx); batch.assign(queue.begin(), queue.end()); queue.clear(); } if (batch.empty()) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } batch_insert_to_db(batch); } }

这个设计的好处是:网络回调耗时可预测,不会因为数据库抖动拖垮整个网络层;同时写库是批量操作,吞吐比单条插入高一个量级。

4.3 接到TDengine场景:taos_stmt_prepare批量绑定

如果存储端是TDengine,我给的方案是预编译语句批量绑定。TDengine的C接口提供了taos_stmt_prepare,类似MySQL的prepared statement,可以先把SQL模板准备好,再用参数数组循环绑定。

taos_stmt* stmt = taos_stmt_init(taos_conn); const char* sql = "INSERT INTO ? USING metrics TAGS(?, ?) VALUES (?, ?)"; taos_stmt_prepare(stmt, sql, strlen(sql)); // 绑定表名和标签 taos_bind_t tb[1]; // ... 初始化每个字段的buffer、length、type taos_stmt_bind_param(stmt, tb, 1); // 追加多行值 taos_bind_t values[3]; // ... 初始化时间戳、设备ID、数值字段 for (size_t i = 0; i < batch.size(); i++) { // 逐个绑定参数 values[0].u.var.i64 = batch[i].ts; values[1].u.var.buflen = ...; // 这里注意每个字段的buffer和len都要指向有效内存 taos_stmt_bind_param(stmt, values, 3); taos_stmt_add_batch(stmt); } taos_stmt_execute(stmt); taos_stmt_close(stmt);

这段代码的重点是每次taos_stmt_bind_param传入的参数在调用期间必须有效,add_batch会把当前参数复制进内部缓冲区,所以可以复用values数组,但字符串类型的字段要保证指针指向的内容不被提前释放。实际开发中不同TDengine版本的taos_bind_t字段名略有差异,建议打开头文件确认一下,或者用官方示例对照。还有一点,批量大小不要贪多,10到100条一批实测吞吐和内存占用都比较合适。这个思路同样适用于MySQL的prepare绑定接口,原理是一样的。

4.4 更现代的写法:C++20协程让异步代码变同步

Boost.Asio从1.74开始积极拥抱C++20协程,用use_awaitable可以把回调嵌套改写得跟同步一样直观:

boost::asio::awaitable<void> handle_session(tcp::socket sock) { try { std::array<char, 1024> buf; while (true) { size_t n = co_await sock.async_read_some(boost::asio::buffer(buf), boost::asio::use_awaitable); co_await boost::asio::async_write(sock, boost::asio::buffer(buf, n), boost::asio::use_awaitable); } } catch (const boost::system::system_error& e) { // 处理异常 } } boost::asio::awaitable<void> listener() { auto exec = co_await boost::asio::this_coro::executor; tcp::acceptor acceptor(exec, {tcp::v4(), 8080}); while (true) { auto sock = co_await acceptor.async_accept(boost::asio::use_awaitable); boost::asio::co_spawn(exec, handle_session(std::move(sock)), boost::asio::detached); } }

协程底层仍然是handler,好处是写复杂的收发顺序时不再一个套一个回调。但协程不是银弹:协程栈上的对象生命周期要心里有数,取消操作和超时也要显式处理。如果团队对C++20协程不熟,我建议先保持传统回调风格,稳定再说。

5. 这些年的错题本:连接、内存、线程与性能

5.1 对端关闭连接时,EOF不是错误

很多人在async_read_some回调里看到ec就当作错误处理,打印日志、断开连接。实际上boost::asio::error::eof表示对端正常关闭了连接(读完所有数据后收到FIN),这不是异常,是正常的业务结束。正确做法是把这个分支当作"清理Session资源"的信号,而不是报警。

真正需要注意的是connection_reset_by_peer这种异常断开,比如对端进程崩溃、网络超时重扔RST。TCP这种场景很多,不能一看到ec就panic式地把整个服务打挂,要把"可预期断开"和"异常断开"分开处理。

5.2 per-Session buffer固定长度可能浪费,也容易踩踏

我早期偷懒,给每个Session固定一个4KB的数组当接收缓冲。后来发现有的消息只有几十字节,有的消息有几十KB,固定长度要么浪费内存,要么一条大消息直接撑爆缓冲。

后来改成三层结构:小消息直接走栈上的临时缓冲区,中等消息走Session内部vector,大消息走单独的内存块。具体大小根据业务包特征调。其实核心原则很简单:不要让所有连接都按最大包分配内存,也不要让多个连接共享同一个缓冲区。共享缓冲区在多线程回调下就是定时炸弹。

5.3 一个io_context配几个线程合适

这是个老问题。我的经验值是:

  • 纯转发场景,CPU核数和io_context线程数差不多就行,比如8核就开8个线程
  • 如果handler里有轻量计算但数据库操作放在单独的worker线程里,那么网络线程开2~4个就够了,开多了反而增加上下文切换
  • 如果handler里有耗时CPU计算,比如解析超大JSON、协议编解码,这部分最好挪到专用线程池,或者增加网络线程数到CPU核数的1.5倍左右并加strand保护

有一个容易忽略的点:线程数超过CPU核数后,靠并行提高吞吐的收益快速递减,反而线程切换成了瓶颈。我见过有人给8核机器开了64个run()线程,性能没涨多少,CPU上下文切换却高了十倍。

5.4 handler里无意拷贝导致内存疯涨

Session写法规范后,内存问题多半出在handler捕获里。比如这样:

std::string big_json; big_json = ...; socket_.async_write(socket_, boost::asio::buffer(big_json), [this, self, big_json](auto...) {});

这里big_json被按值捕获,又复制了一份。如果消息量大,等于每个发送队列里都有一份大字符串的副本,内存很快飙上去。正确做法是让big_json的生命周期和异步操作绑定,但不要无谓拷贝:可以用std::shared_ptr<std::string>,把同一个对象传入buffer方法和lambda捕获。

auto data = std::make_shared<std::string>(big_json); boost::asio::async_write(socket_, boost::asio::buffer(*data), [this, self, data](auto...) {});

这样底层buffer指向的还是data持有的那一段内存,lambda再持有一份shared_ptr,没有第二份数据副本。注意,若数据本身不需要跨线程移动,shared_ptr的引用计数更新虽有原子操作开销,但通常远小于一次大内存拷贝。

5.5 排错清单:从现象到原因的快速对照

现象可能原因处理方向
编译链接报boost::systemundefined缺-lboost_system或Boost版本混乱检查路径,只保留一份Boost
服务启动报bind失败端口被占用,或之前进程没退干净netstat -tunlp查端口,等待TIME_WAIT释放或改用SO_REUSEADDR
回调里访问Session崩溃没有用shared_from_this延长生命周期检查是否用裸指针捕获Session
多条连接数据互相穿插共享了同一个buffer改成per-Session独立buffer
高并发下CPU高但吞吐低线程数超过CPU核数,频繁切换减少run()线程数,检查是否有核间迁移
内存随时间缓慢上涨handler按值拷贝大对象用shared_ptr持有数据,避免二次拷贝

我自己的排错习惯是:先用strace看一下系统调用,确认是网络层问题还是业务层问题;再看io_context线程数是否合理;最后才怀疑业务代码。网络库踩坑的规律往往是"生命周期和资源所有权没想清楚",而不是Asio本身不够好用。

写到这里,最想强调的还是那句老话:异步网络编程也好,Boost.Asio也好,真正的难点并不在于某个API的用法,而在于你愿不愿意把"数据何时到达、对象何时销毁、回调何时并发"这三件事想透。把这些基本功做扎实了,不管前端的消费者是连接池、数据库还是协程风格的业务代码,都能稳稳接住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询