引言
Hical 的所有异步 I/O 都基于 Boost.Asio 协程(co_await+boost::asio::use_awaitable)。路由处理器返回Awaitable<HttpResponse>,中间件用洋葱模型co_await next(req),连接池用co_await timer.async_wait()做非阻塞等待。
协程消除了回调地狱,但引入了一套全新的陷阱。这篇记录的每一个坑,都是在压测或线上环境中真实触发过的。
目录
- Hical 踩坑实录五部曲(一):Boost.Asio 协程开发的 N 个坑
- 引言
- 目录
- 坑 1:co_await 后 this 悬挂——对象已析构
- 坑 2:协程异常传播——catch 里不能 co_await
- 坑 3:steady_timer 当协程信号量的技巧
- 坑 4:jthread vs thread——精准匹配停止信号
- 坑 5:多线程 io_context + 协程的线程安全陷阱
- 坑 6:detached 协程的异常黑洞
- 坑 7:io_context::stop() 不等于安全退出
- 总结:协程安全编程检查清单
坑 1:co_await 后 this 悬挂——对象已析构
现象:压测时低概率崩溃,堆栈指向 TcpServer 的 accept 循环,访问了已释放的内存。
最小复现:
// ❌ 危险的写法Awaitable<void>TcpServer::acceptLoop(){while(running_){autosocket=co_awaitacceptor_.async_accept(use_awaitable);// ⚠️ 如果在 co_await 期间 TcpServer 被析构,// this 已经是悬空指针!this->createConnection(std::move(socket));// 💥 use-after-free}}根因:协程帧通过co_spawn(io_context, coroutine, detached)提交到 io_context。协程帧的生命周期由 io_context 管理,与创建协程的对象完全分离。
TcpServer 对象 协程帧 [构造] ────────────── [创建] [运行中] [挂起在 co_await] [析构] ← 先死 [恢复] ← 后恢复 this → 💥 野指针当某个线程析构了 TcpServer(比如main()退出),而协程帧还挂在 io_context 的等待队列里——恢复执行时this就是野指针。
解决方案:shared_ptr<atomic<bool>>作为生命周期令牌。
// TcpServer.h — 定义生命周期标志std::shared_ptr<std::atomic<bool>>alive_;// 构造时:创建标志,默认 truealive_=std::make_shared<std::atomic<bool>>(true);// 析构时:置 falsealive_->store(false);为什么用shared_ptr包装?因为协程帧需要持有标志的引用计数。如果alive_是 TcpServer 的普通成员,TcpServer 析构后alive_也被销毁——去读它同样是 use-after-free。shared_ptr保证只要有人持有引用计数,标志本身就一直活着。
使用模式 1——accept 循环中的双重检查:
while(running_.load()&&alive_->load()){tcp::socket socket=co_awaitacceptor_.async_accept(use_awaitable);// co_await 恢复后再次检查——这是关键if(!alive_->load()){break;// TcpServer 已析构,安全退出}createConnection(std::move(socket));}使用模式 2——连接关闭回调中的守卫:
autoaliveFlag=alive_;// 闭包捕获 shared_ptr(引用计数 +1)auto*self=this;conn->onClose([aliveFlag,self](constTcpConnection::Ptr&c){if(aliveFlag->load())// 先检查再访问{self->removeConnection(c);}});教训:在协程世界里,每个co_await都是一个生命周期断裂点。恢复后不能假设this、闭包捕获的指针、甚至栈上引用仍然有效。模式:捕获shared_ptr哨兵,恢复后先检查再使用。
坑 2:协程异常传播——catch 里不能 co_await
现象:编译器报错cannot use co_await in a catch handler。
最小复现:
// ❌ 编译错误——C++ 标准禁止try{co_awaitconn->execute(sql);}catch(...){co_awaitconn->rollback();// 编译器拒绝throw;}根因:C++ 标准 [dcl.fct.def.coroutine] p6 明确禁止在catch块内使用co_await。原因是co_await可能挂起并恢复协程,而 catch 块依赖于栈展开的状态——恢复后这个状态可能无效。
解决方案:exception_ptr中转模式——catch 只捕获、不处理,异步清理放到 catch 外面。
Hical 的 DbMiddleware 是这个模式的最佳示范:
// DbMiddleware.h — 洋葱模型的异步回滚autoconn=co_awaitpool->acquire();req.setAttribute(DbConnectionPool::hConnKey,conn);if(opts.autoTransaction)co_awaitconn->beginTransaction();std::exception_ptr eptr;// 异常中转HttpResponse res;try{res=co_awaitnext(req);// 执行业务逻辑if(opts.autoTransaction&&conn->inTransaction())co_awaitconn->commit();}catch(...){eptr=std::current_exception();// 只捕获,不在这里 co_await}// ✅ catch 外面——可以 co_awaitif(eptr&&conn->inTransaction()){try{co_awaitconn->rollback();}catch(...){}// rollback 本身失败也处理不了}pool->release(std::move(conn));// 归还连接if(eptr)std::rethrow_exception(eptr);// 重新抛出原始异常co_returnres;流程图:
co_await next(req) │ ├─ 成功 → co_await commit() → 归还连接 → co_return res │ └─ 异常 → eptr = current_exception() → co_await rollback() ← catch 外,合法 → 归还连接 → rethrow_exception(eptr) ← 重新抛给上层经验:这个模式在 Hical 中被多处使用。凡是需要"异常后异步清理"的场景,都用exception_ptr中转。
坑 3:steady_timer 当协程信号量的技巧
现象:数据库连接池的acquire()在连接耗尽时需要等待。第一版用std::condition_variable实现——结果阻塞了整个 io_context 线程,所有协程都卡住了。
根因:condition_variable::wait()是阻塞操作。而 Asio 协程运行在 io_context 的事件循环线程上——阻塞这个线程意味着整个事件循环停转:
┌─────────────── io_context 线程 ────────────────┐ │ handler A → handler B → co_await → handler C │ │ ↓ │ │ cv.wait() ← 阻塞整个线程! │ │ handler C/D/E 全部无法执行 │ └─────────────────────────────────────────────────┘ vs. ┌─────────────── io_context 线程 ────────────────┐ │ handler A → handler B → co_await timer → ... │ │ ↓ │ │ 协程挂起(线程不阻塞) │ │ handler C/D/E 正常执行 │ │ timer.cancel() → 协程恢复 │ └─────────────────────────────────────────────────┘解决方案:用steady_timer代替condition_variable。核心思路:timer.cancel()导致co_await timer.async_wait()返回operation_aborted,从而唤醒等待的协程。
acquire 端——挂起等待:
// DbConnectionPool.cpp — 池满时协程挂起structWaiter{std::shared_ptr<boost::asio::steady_timer>timer;std::shared_ptr<std::shared_ptr<DbConnection>>result;// 堆分配结果槽};// 创建 waiterautotimer=std::make_shared<steady_timer>(m_ioCtx,m_config.acquireTimeout);autoresult=std::make_shared<std::shared_ptr<DbConnection>>();m_waiters.push_back({timer,result});lock.unlock();// 释放锁,让其他协程可以 release// co_await 只挂起协程,不阻塞线程boost::system::error_code ec;co_awaittimer->async_wait(redirect_error(use_awaitable,ec));if(*result){co_returnstd::move(*result);// release 已转交连接}throwstd::runtime_error("DbConnectionPool: acquire timeout");release 端——唤醒等待者:
// DbConnectionPool.cpp — 连接归还时唤醒voidDbConnectionPool::release(std::shared_ptr<DbConnection>conn){std::lock_guardlock(m_mutex);if(!m_waiters.empty()){autowaiter=std::move(m_waiters.front());m_waiters.pop_front();*(waiter.result)=std::move(conn);// 转交连接waiter.timer->cancel();// cancel 唤醒 co_awaitreturn;}// 无等待者,归入空闲池m_idle.push_back(std::move(conn));}为什么结果要用shared_ptr<shared_ptr<DbConnection>>两层指针?
因为release()在 cancel timer 之前就要写入结果。如果 result 是协程帧里的局部变量,release 线程写入时协程帧可能还没被调度,局部变量的地址不稳定。堆分配的shared_ptr让 result 的生命周期独立于协程帧。
对比:
| 方案 | 挂起协程 | 阻塞线程 | 唤醒机制 |
|---|---|---|---|
condition_variable | ❌ | ✅ 阻塞 | notify_one() |
steady_timer | ✅ | ❌ 不阻塞 | cancel() |
boost::asio::experimental::channel | ✅ | ❌ | 内置 |
经验:在 Asio 协程中,任何阻塞操作都是错误的。mutex::lock()、condition_variable::wait()、future::get()都会冻结事件循环。所有同步原语都要替换为异步等价物。
坑 4:jthread vs thread——精准匹配停止信号
现象:不是 bug,但经常被问到——为什么 Hical 只有AsyncFileSink用了std::jthread,其他地方全部用std::thread?
分析:关键在于停止信号的来源。
AsyncFileSink——需要stop_token:
后台写盘线程在循环中等待数据到达。停止时需要:
- 唤醒正在等待的线程
- 让线程处理完剩余数据
- 线程安全地退出
jthread+stop_token完美匹配:
// AsyncFileSink.cppm_bgThread=std::jthread([this](std::stop_token stopToken)// 接收 stop_token{backgroundLoop(std::move(stopToken));});voidAsyncFileSink::backgroundLoop(std::stop_token stopToken){while(true){std::unique_lock<std::mutex>lock(m_bufMutex);// stop_token 直接集成到 condition_variable_any// 停止请求到来时自动唤醒——无需额外 notifym_cond.wait_for(lock,stopToken,// ← 关键m_opts.flushInterval,[this](){return!m_curBuf.empty();});if(stopToken.stop_requested()&&m_curBuf.empty())break;// 数据处理完毕,优雅退出if(!m_curBuf.empty()){m_curBuf.swap(m_flushBuf);// 双缓冲交换m_curBuf.clear();}// ... 锁外写盘 ...}// 关闭前排空残余数据// ...}// 析构函数什么都不用做——jthread 自动 request_stop() + join()EventLoopPool——不需要stop_token:
io_context 工作线程的停止信号来自io_context::stop(),调用后run()自然返回。stop_token是多余的:
// EventLoopPool.cppvoidEventLoopPool::start(){for(auto&loop:loops_){auto*ptr=loop.get();threads_.emplace_back([ptr](){ptr->run();// io_context::run()});}}voidEventLoopPool::stop(){for(auto&loop:loops_)loop->stop();// io_context::stop()for(auto&thread:threads_)if(thread.joinable())thread.join();// 显式 jointhreads_.clear();}决策矩阵:
| 条件 | 选择 |
|---|---|
线程自己需要接收停止信号(stop_token) | std::jthread |
| 停止信号来自外部机制(io_context::stop、原子标志) | std::thread+ 显式 join |
| 不确定 | 倾向std::thread——避免传递错误语义 |
经验:不要无脑把所有thread换成jthread。如果不用stop_token,jthread只是多了个自动 join——但会给读代码的人传递错误信号(“这个线程应该用 stop_token 停止”),增加理解成本。
坑 5:多线程 io_context + 协程的线程安全陷阱
现象:Hical 使用 1 thread : 1 io_context 模型(EventLoopPool),每个连接绑定到一个固定的 io_context。但在共享状态访问时仍然出现了竞争。
架构:
EventLoopPool ├─ io_context[0] + thread[0] ── conn_a, conn_b ├─ io_context[1] + thread[1] ── conn_c, conn_d └─ io_context[2] + thread[2] ── conn_e, conn_f Round-Robin 分发:新连接依次分配到 io_context[0], [1], [2], [0], ...每个连接只在自己绑定的 io_context 线程上执行所有操作——读、写、关闭。看起来不需要锁?
问题:跨连接的共享状态(路由表、中间件链、连接集合)仍然可能被多个线程同时访问:
io_context[0] ─ conn_a ─┐ io_context[1] ─ conn_b ─┤── 都要查路由表 → Router(只读✅) io_context[2] ─ conn_c ─┘ ├── 都要操作连接集合 → connections_(需要保护⚠️)解决方案——分层保护:
| 共享状态 | 访问模式 | 保护策略 |
|---|---|---|
| Router 路由表 | 启动前写入,运行时只读 | 无需锁 |
| MiddlewarePipeline | build()预构建后缓存,运行时只读 | 无需锁 |
| TcpServer::connections_ | 运行时增删 | dispatch 到 accept 线程 |
| Logger::m_sinks | 运行时可能 addSink | COW(读无锁) |
连接集合的 dispatch 串行化:
// TcpServer.cpp — 连接移除必须在 accept 线程voidTcpServer::removeConnection(constTcpConnection::Ptr&conn){baseLoop_->dispatch([this,conn](){connections_.erase(conn);// 在 accept 线程执行,无竞争});}中间件链的build()预构建——运行时零分配零锁:
// Middleware.cpp — 从后向前构建洋葱链MiddlewareNextMiddlewarePipeline::buildChainFrom(conststd::vector<MiddlewareHandler>&middlewares,MiddlewareNext finalHandler){MiddlewareNext current=std::move(finalHandler);for(inti=static_cast<int>(middlewares.size())-1;i>=0;--i){automw=middlewares[i];current=[mw=std::move(mw),next=std::move(current)](HttpRequest&r)->Awaitable<HttpResponse>{co_returnco_awaitmw(r,next);};}returncurrent;}// 启动时一次性构建,运行时直接调用缓存的链pipeline.build(routerHandler);// 运行时——无锁执行autoresponse=co_awaitpipeline.execute(req);经验:1:1 模型不等于完全无锁。共享状态仍需保护,但策略可以更轻量——能用"启动前初始化 + 运行时只读"就不用锁,能用 dispatch 串行化就不用 mutex。
坑 6:detached 协程的异常黑洞
现象:一个协程里的异常被静默吞掉,没有任何日志输出,问题排查了很久。
根因:boost::asio::detached意味着"不关心结果"——包括异常。未捕获的异常在 detached 协程中行为不一致(不同 Asio 版本和编译器有差异),最坏情况是直接std::terminate(),最好情况是静默吞掉。
// ❌ 异常黑洞boost::asio::co_spawn(io_ctx,[]()->Awaitable<void>{throwstd::runtime_error("oops");// 去哪了?},boost::asio::detached);解决方案:
// ✅ 始终提供异常处理器boost::asio::co_spawn(io_ctx,[]()->Awaitable<void>{throwstd::runtime_error("oops");},[](std::exception_ptr eptr){if(eptr){try{std::rethrow_exception(eptr);}catch(conststd::exception&e){HICAL_LOG_ERROR("coroutine failed: {}",e.what());}}});// 或者:协程内部自行 try-catch 所有异常boost::asio::co_spawn(io_ctx,[]()->Awaitable<void>{try{co_awaitriskyOperation();}catch(conststd::exception&e){HICAL_LOG_ERROR("operation failed: {}",e.what());}},boost::asio::detached);// 协程内部已处理,detached 安全Hical 的策略:
- 对"不应该抛异常"的协程(如 accept 循环):协程内部 try-catch 所有异常
- 对"可能抛异常"的协程(如健康检查、idle 检测):提供显式异常处理器
坑 7:io_context::stop() 不等于安全退出
现象:调用io_context::stop()后,某些资源没有被正确清理,导致析构时访问已释放的内存。
根因:stop()只是设置标志让run()返回。但:
- 正在执行的 handler 会跑完——stop 不是中断
- 挂起的协程不会被通知——它们还在等 I/O 完成
- pending 的 async 操作不会被 cancel——需要显式 cancel
io_context::stop() │ ├─ 正在执行的 handler → 继续执行到结束 ← 可能访问即将析构的对象 ├─ 挂起的协程 → 仍在等待 I/O ← 需要 cancel socket/timer └─ run() → 返回解决方案:stop 之前先 cancel 所有活跃资源:
// 正确的关闭顺序voidTcpServer::stop(){running_.store(false);// 1. 关闭 acceptor(取消 pending accept)boost::system::error_code ec;acceptor_.close(ec);// 2. 关闭所有活跃连接(取消 pending read/write)for(auto&conn:connections_){conn->close();}connections_.clear();// 3. 取消所有 timer// ...// 4. 最后停止 io_contextfor(auto&loop:loops_){loop->stop();}}经验:io_context::stop()只是"告诉 run() 别再等了",不是"安全关闭所有资源"。正确的关闭流程是:先 cancel 所有 I/O 操作,再 stop io_context,最后 join 线程。
总结:协程安全编程检查清单
| # | 规则 | 原因 |
|---|---|---|
| 1 | 每个co_await后检查对象存活性 | 协程帧生命周期独立于对象 |
| 2 | catch 里不 co_await,用exception_ptr中转 | C++ 标准限制 |
| 3 | 不在协程中使用阻塞同步原语 | 会冻结整个 io_context 事件循环 |
| 4 | co_spawn始终提供异常处理器 | detached的异常行为不可靠 |
| 5 | stop io_context 前先 cancel 所有 I/O | stop 不会主动 cancel |
| 6 | 共享状态用 dispatch 串行化或 COW | 1:1 模型不等于无锁 |
| 7 | jthread 用于 stop_token 场景,thread 用于 io_context 场景 | 精准匹配停止信号 |
核心原则:在协程世界里,每个co_await都是一个时间旅行门——恢复时世界可能已经变了。不要假设任何状态在co_await前后保持一致。
下篇预告
在第二篇中,我们将面对三平台编译差异的修罗场:
- Concepts 约束检查— 同一份 concept 代码在 GCC、Clang、MSVC 上的行为差异
__VA_OPT__递归宏— MSVC 需要/Zc:preprocessor才能正常工作- PMR allocator 传播— 嵌套容器的分配器在不同标准库实现下的行为不一致
敬请期待!
hical— 基于 C++20/26 的现代高性能 Web 框架 | GitHub