Hical 踩坑实录五部曲(一):Boost.Asio 协程开发的 N 个坑

引言

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 个坑](#Hical 踩坑实录五部曲(一):Boost.Asio 协程开发的 N 个坑)
    • 引言
    • 目录
    • [坑 1:co_await 后 this 悬挂------对象已析构](#坑 1:co_await 后 this 悬挂——对象已析构)
    • [坑 2:协程异常传播------catch 里不能 co_await](#坑 2:协程异常传播——catch 里不能 co_await)
    • [坑 3:steady_timer 当协程信号量的技巧](#坑 3:steady_timer 当协程信号量的技巧)
    • [坑 4:jthread vs thread------精准匹配停止信号](#坑 4:jthread vs thread——精准匹配停止信号)
    • [坑 5:多线程 io_context + 协程的线程安全陷阱](#坑 5:多线程 io_context + 协程的线程安全陷阱)
    • [坑 6:detached 协程的异常黑洞](#坑 6:detached 协程的异常黑洞)
    • [坑 7:io_context::stop() 不等于安全退出](#坑 7:io_context::stop() 不等于安全退出)
    • 总结:协程安全编程检查清单

坑 1:co_await 后 this 悬挂------对象已析构

现象:压测时低概率崩溃,堆栈指向 TcpServer 的 accept 循环,访问了已释放的内存。

最小复现

cpp 复制代码
// ❌ 危险的写法
Awaitable<void> TcpServer::acceptLoop()
{
    while (running_)
    {
        auto socket = co_await acceptor_.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>> 作为生命周期令牌。

cpp 复制代码
// TcpServer.h --- 定义生命周期标志
std::shared_ptr<std::atomic<bool>> alive_;

// 构造时:创建标志,默认 true
alive_ = std::make_shared<std::atomic<bool>>(true);

// 析构时:置 false
alive_->store(false);

为什么用 shared_ptr 包装?因为协程帧需要持有标志的引用计数。如果 alive_ 是 TcpServer 的普通成员,TcpServer 析构后 alive_ 也被销毁------去读它同样是 use-after-free。shared_ptr 保证只要有人持有引用计数,标志本身就一直活着。

使用模式 1------accept 循环中的双重检查

cpp 复制代码
while (running_.load() && alive_->load())
{
    tcp::socket socket = co_await acceptor_.async_accept(use_awaitable);

    // co_await 恢复后再次检查------这是关键
    if (!alive_->load())
    {
        break;  // TcpServer 已析构,安全退出
    }
    createConnection(std::move(socket));
}

使用模式 2------连接关闭回调中的守卫

cpp 复制代码
auto aliveFlag = alive_;  // 闭包捕获 shared_ptr(引用计数 +1)
auto* self = this;
conn->onClose(
    [aliveFlag, self](const TcpConnection::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

最小复现

cpp 复制代码
// ❌ 编译错误------C++ 标准禁止
try
{
    co_await conn->execute(sql);
}
catch (...)
{
    co_await conn->rollback();  // 编译器拒绝
    throw;
}

根因 :C++ 标准 [dcl.fct.def.coroutine] p6 明确禁止在 catch 块内使用 co_await。原因是 co_await 可能挂起并恢复协程,而 catch 块依赖于栈展开的状态------恢复后这个状态可能无效。

解决方案exception_ptr 中转模式------catch 只捕获、不处理,异步清理放到 catch 外面。

Hical 的 DbMiddleware 是这个模式的最佳示范:

cpp 复制代码
// DbMiddleware.h --- 洋葱模型的异步回滚
auto conn = co_await pool->acquire();
req.setAttribute(DbConnectionPool::hConnKey, conn);

if (opts.autoTransaction)
    co_await conn->beginTransaction();

std::exception_ptr eptr;  // 异常中转
HttpResponse res;
try
{
    res = co_await next(req);  // 执行业务逻辑
    if (opts.autoTransaction && conn->inTransaction())
        co_await conn->commit();
}
catch (...)
{
    eptr = std::current_exception();  // 只捕获,不在这里 co_await
}

// ✅ catch 外面------可以 co_await
if (eptr && conn->inTransaction())
{
    try { co_await conn->rollback(); }
    catch (...) { }  // rollback 本身失败也处理不了
}

pool->release(std::move(conn));  // 归还连接

if (eptr) std::rethrow_exception(eptr);  // 重新抛出原始异常
co_return res;

流程图

复制代码
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 端------挂起等待

cpp 复制代码
// DbConnectionPool.cpp --- 池满时协程挂起
struct Waiter
{
    std::shared_ptr<boost::asio::steady_timer> timer;
    std::shared_ptr<std::shared_ptr<DbConnection>> result;  // 堆分配结果槽
};

// 创建 waiter
auto timer = std::make_shared<steady_timer>(m_ioCtx, m_config.acquireTimeout);
auto result = std::make_shared<std::shared_ptr<DbConnection>>();
m_waiters.push_back({timer, result});
lock.unlock();  // 释放锁,让其他协程可以 release

// co_await 只挂起协程,不阻塞线程
boost::system::error_code ec;
co_await timer->async_wait(redirect_error(use_awaitable, ec));

if (*result)
{
    co_return std::move(*result);  // release 已转交连接
}
throw std::runtime_error("DbConnectionPool: acquire timeout");

release 端------唤醒等待者

cpp 复制代码
// DbConnectionPool.cpp --- 连接归还时唤醒
void DbConnectionPool::release(std::shared_ptr<DbConnection> conn)
{
    std::lock_guard lock(m_mutex);

    if (!m_waiters.empty())
    {
        auto waiter = std::move(m_waiters.front());
        m_waiters.pop_front();
        *(waiter.result) = std::move(conn);  // 转交连接
        waiter.timer->cancel();              // cancel 唤醒 co_await
        return;
    }
    // 无等待者,归入空闲池
    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

后台写盘线程在循环中等待数据到达。停止时需要:

  1. 唤醒正在等待的线程
  2. 让线程处理完剩余数据
  3. 线程安全地退出

jthread + stop_token 完美匹配:

cpp 复制代码
// AsyncFileSink.cpp
m_bgThread = std::jthread(
    [this](std::stop_token stopToken)  // 接收 stop_token
    {
        backgroundLoop(std::move(stopToken));
    });

void AsyncFileSink::backgroundLoop(std::stop_token stopToken)
{
    while (true)
    {
        std::unique_lock<std::mutex> lock(m_bufMutex);

        // stop_token 直接集成到 condition_variable_any
        // 停止请求到来时自动唤醒------无需额外 notify
        m_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 是多余的:

cpp 复制代码
// EventLoopPool.cpp
void EventLoopPool::start()
{
    for (auto& loop : loops_)
    {
        auto* ptr = loop.get();
        threads_.emplace_back([ptr]() {
            ptr->run();  // io_context::run()
        });
    }
}

void EventLoopPool::stop()
{
    for (auto& loop : loops_)
        loop->stop();                          // io_context::stop()
    for (auto& thread : threads_)
        if (thread.joinable()) thread.join();  // 显式 join
    threads_.clear();
}

决策矩阵

条件 选择
线程自己需要接收停止信号(stop_token std::jthread
停止信号来自外部机制(io_context::stop、原子标志) std::thread + 显式 join
不确定 倾向 std::thread------避免传递错误语义

经验 :不要无脑把所有 thread 换成 jthread。如果不用 stop_tokenjthread 只是多了个自动 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 串行化:

cpp 复制代码
// TcpServer.cpp --- 连接移除必须在 accept 线程
void TcpServer::removeConnection(const TcpConnection::Ptr& conn)
{
    baseLoop_->dispatch([this, conn]()
    {
        connections_.erase(conn);  // 在 accept 线程执行,无竞争
    });
}

中间件链的 build() 预构建------运行时零分配零锁:

cpp 复制代码
// Middleware.cpp --- 从后向前构建洋葱链
MiddlewareNext MiddlewarePipeline::buildChainFrom(
    const std::vector<MiddlewareHandler>& middlewares,
    MiddlewareNext finalHandler)
{
    MiddlewareNext current = std::move(finalHandler);
    for (int i = static_cast<int>(middlewares.size()) - 1; i >= 0; --i)
    {
        auto mw = middlewares[i];
        current = [mw = std::move(mw), next = std::move(current)]
                  (HttpRequest& r) -> Awaitable<HttpResponse>
        {
            co_return co_await mw(r, next);
        };
    }
    return current;
}

// 启动时一次性构建,运行时直接调用缓存的链
pipeline.build(routerHandler);

// 运行时------无锁执行
auto response = co_await pipeline.execute(req);

经验:1:1 模型不等于完全无锁。共享状态仍需保护,但策略可以更轻量------能用"启动前初始化 + 运行时只读"就不用锁,能用 dispatch 串行化就不用 mutex。


坑 6:detached 协程的异常黑洞

现象:一个协程里的异常被静默吞掉,没有任何日志输出,问题排查了很久。

根因boost::asio::detached 意味着"不关心结果"------包括异常。未捕获的异常在 detached 协程中行为不一致(不同 Asio 版本和编译器有差异),最坏情况是直接 std::terminate(),最好情况是静默吞掉。

cpp 复制代码
// ❌ 异常黑洞
boost::asio::co_spawn(io_ctx,
    []() -> Awaitable<void>
    {
        throw std::runtime_error("oops");  // 去哪了?
    },
    boost::asio::detached);

解决方案

cpp 复制代码
// ✅ 始终提供异常处理器
boost::asio::co_spawn(io_ctx,
    []() -> Awaitable<void>
    {
        throw std::runtime_error("oops");
    },
    [](std::exception_ptr eptr)
    {
        if (eptr)
        {
            try { std::rethrow_exception(eptr); }
            catch (const std::exception& e) {
                HICAL_LOG_ERROR("coroutine failed: {}", e.what());
            }
        }
    });

// 或者:协程内部自行 try-catch 所有异常
boost::asio::co_spawn(io_ctx,
    []() -> Awaitable<void>
    {
        try
        {
            co_await riskyOperation();
        }
        catch (const std::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() 返回。但:

  1. 正在执行的 handler 会跑完------stop 不是中断

  2. 挂起的协程不会被通知------它们还在等 I/O 完成

  3. pending 的 async 操作不会被 cancel------需要显式 cancel

    io_context::stop()

    ├─ 正在执行的 handler → 继续执行到结束 ← 可能访问即将析构的对象
    ├─ 挂起的协程 → 仍在等待 I/O ← 需要 cancel socket/timer
    └─ run() → 返回

解决方案:stop 之前先 cancel 所有活跃资源:

cpp 复制代码
// 正确的关闭顺序
void TcpServer::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_context
    for (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 前后保持一致。


下篇预告

在第二篇中,我们将面对三平台编译差异的修罗场:

  1. Concepts 约束检查 --- 同一份 concept 代码在 GCC、Clang、MSVC 上的行为差异
  2. __VA_OPT__ 递归宏 --- MSVC 需要 /Zc:preprocessor 才能正常工作
  3. PMR allocator 传播 --- 嵌套容器的分配器在不同标准库实现下的行为不一致

敬请期待!


hical --- 基于 C++20/26 的现代高性能 Web 框架 | GitHub

相关推荐
春夜喜雨1 小时前
类型定义的使用差异using/typedef/define/constexpr
c++·typedef·using·constexpr·类型定义·define·常量声明
赏金术士1 小时前
Kotlin 从入门到进阶 之面向对象 OOP 模块(三)
开发语言·网络·kotlin
西西弟1 小时前
网络编程基础之TCP多线程并发服务器
服务器·网络·网络协议·tcp/ip
智者知已应修善业1 小时前
【51单片机流水灯中断嵌套,低优先级中断完成后如何返回主程序】2023-10-15
c++·经验分享·笔记·算法·51单片机
lihongli0001 小时前
关于c++中锁的种类与使用
java·开发语言·c++
凤凰院凶涛QAQ1 小时前
《C++转Java快速入手系列》继承与多态篇
java·c++
凯勒姆1 小时前
华为设备软考网工模板
服务器·网络·华为
whxnchy2 小时前
UDP多端口负载均衡实战
c++
H Journey3 小时前
网络编程:Boost.Asio实现跨平台的TCP服务器
服务器·网络·tcp/ip·boost.asio