Hical 框架开发心得:七个深刻教训

引言

Hical 是一个基于 Boost.Asio 的现代 C++20/26 高性能 Web 框架,采用原生 HTTP/WebSocket 网络栈(picohttpparser + 自研 WebSocket),从第一行代码到现在的 45+ 测试文件、3 层内存池、协程化数据库中间件、自研日志系统、OpenAPI 自动生成、WsHub 广播管理器、QPS 从 27K 到 159K 的优化历程,一路走来踩了不少坑,也收获了很多。

这篇文章不讲 API 用法,也不讲架构教程——那些在其他文章里都有。这篇只聊开发过程中的真实体会:哪些决策事后证明是对的,哪些看似优雅的方案差点把自己埋了,以及最终选择背后的取舍逻辑。


目录


一、C++20 协程是双刃剑

1.1 协程让异步代码变清晰了……吗?

在引入协程之前,一个 HTTP 请求的处理链是嵌套回调:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 回调地狱版本
void handleRequest(tcp::socket& socket)
{
    socket.async_read_some(buffer,
        [&](error_code ec, size_t n)
        {
            if (ec) return;
            parseRequest(buffer, n,
                [&](HttpRequest req)
                {
                    processRoute(req,
                        [&](HttpResponse res)
                        {
                            async_write(socket, res.serialize(),
                                [&](error_code ec2, size_t) { /*...*/ });
                        });
                });
        });
}

用协程改写后确实清爽很多:

1
2
3
4
5
6
7
8
9
// 协程版本
Awaitable<void> handleRequest(tcp::socket& socket)
{
    auto [ec, n] = co_await socket.async_read_some(buffer, use_awaitable);
    if (ec) co_return;
    auto req = parseRequest(buffer, n);
    auto res = co_await processRoute(req);   // 路由处理也可以是协程
    co_await async_write(socket, res.serialize(), use_awaitable);
}

到这一步,协程完全是正面的。但随着框架复杂度上升,问题开始浮现。

1.2 co_await 后 this 可能已经死了

这是开发 Hical 过程中遇到的最危险的问题。考虑一个简化的 accept 循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 危险的写法
Awaitable<void> TcpServer::acceptLoop()
{
    while (running_)
    {
        auto socket = co_await acceptor_.async_accept(use_awaitable);
        // ⚠️ 如果在等待期间 TcpServer 被析构,
        //    这里的 this 已经是悬空指针!
        this->createConnection(std::move(socket));  // use-after-free
    }
}

问题在于:co_await 会挂起协程,但协程帧的生命周期与 TcpServer 对象是分离的。如果某个线程析构了 TcpServer,而协程帧还在 io_context 的队列里等着恢复——恢复时 this 就是野指针。

Hical 的解决方案是引入 alive_ 哨兵

1
2
3
4
5
6
7
8
// TcpServer.h — 生命周期标志
std::shared_ptr<std::atomic<bool>> alive_;

// TcpServer 构造函数
, alive_(std::make_shared<std::atomic<bool>>(true))

// TcpServer 析构函数
alive_->store(false);

关键在于:用 shared_ptr 包装原子布尔,让协程帧持有引用计数。即使 TcpServer 已析构,原子布尔本身还活着,可以安全检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// TcpServer.cpp — accept 循环中的双重检查
while (running_.load() && alive_->load())
{
    tcp::socket socket = co_await acceptor_.async_accept(use_awaitable);

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

同样的模式也用在连接关闭回调中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// TcpServer.cpp — 连接关闭回调中的守卫
auto aliveFlag = alive_;        // 闭包捕获 shared_ptr
auto* self = this;
conn->onClose(
    [aliveFlag, self](const TcpConnection::Ptr& c)
    {
        // 仅当 TcpServer 仍存活时才访问成员
        if (aliveFlag->load())
        {
            self->removeConnection(c);
        }
    });

教训:在协程世界里,不能假设 co_await 前后 this 的有效性。每次 co_await 都是一个潜在的生命周期断裂点。

1.3 io_context 析构时的协程帧:成员声明顺序陷阱

v2.6.1 在 MSYS2/Windows CI 上遭遇了一个诡异的 SegFault:IntegrationTest.LargeBody 测试本体通过,但进程退出时崩溃。

根因是 C++ 成员析构顺序与协程帧生命周期的交互:

1
2
3
4
5
6
7
8
// HttpServer.h — 修复前的声明顺序(简化)
class HttpServer
{
    AsioEventLoop baseLoop_;                    // 持有 io_context(第 205 行)
    // ... 中间若干成员 ...
    std::atomic<size_t> activeConnections_ {0}; // 第 235 行
    std::atomic<bool> draining_ {false};        // 第 247 行
};

handleSession 协程内部有一个 RAII guard:

1
2
3
4
5
6
7
8
9
struct ConnectionCounter {
    std::atomic<size_t>& count;   // 引用 HttpServer::activeConnections_
    std::atomic<bool>& draining;  // 引用 HttpServer::draining_
    HttpServer& server;
    ~ConnectionCounter() {
        if (count.fetch_sub(1) == 1 && draining.load())
            server.stopAllLoops();
    }
} connCounter {activeConnections_, draining_, *this};

析构时序:C++ 按声明逆序析构成员,所以 draining_activeConnections_ 先于 baseLoop_(io_context)被析构。而 io_context 析构时会销毁所有悬挂的协程帧,此时 ConnectionCounter 的析构函数访问的两个 atomic 已经是被释放的内存——use-after-free。

为什么只在 Windows IOCP 上触发?

Linux epoll 下,async_write 完成后回调在同一次 run() 迭代中同步 dispatch,协程几乎总是在 stop() 前已自然退出。但 Windows IOCP 的 completion notification 需要从 I/O 完成端口 dequeue——ioContext_.stop()run() 返回,IOCP 队列中未被 dequeue 的事件被丢弃,协程帧永远无法恢复,悬挂到 io_context 析构才被强制销毁。

v2.6.0 的 scatter-gather 一次性提交 1MB 给 IOCP(相比旧版 Beast 的多次小 write),进一步放大了这个 completion 延迟窗口。

修复:将被协程 RAII guard 引用的成员移到 baseLoop_ 之前声明:

1
2
3
4
// 修复后:activeConnections_ 和 draining_ 比 io_context 活得更久
std::atomic<size_t> activeConnections_ {0};  // 最后析构
std::atomic<bool> draining_ {false};         // 倒数第二
AsioEventLoop baseLoop_;                     // 析构时销毁协程帧 → 安全访问上面两个

教训:协程帧中的 RAII guard 引用了宿主类的哪些成员,这些成员就必须比 io_context 活得更久。成员声明顺序不是格式问题,而是正确性问题。

1.4 co_spawn(detached) 的悬空引用陷阱

v2.6.1 在 Windows CI 上又遇到了一个偶发 SegFault——这次是 IntegrationTest.KeepAlive。测试本体通过,进程退出时崩溃。重跑 CI 可能通过,典型的竞态特征。

根因与 1.3 节属于同一类问题(协程帧析构时访问已销毁的对象),但发生在不同层级:这次是分离协程持有调用者局部变量的裸引用

Hical 的连接级空闲超时用一个独立协程 idleTimerLoop 实现,通过 co_spawn(..., detached) 分离启动:

1
2
3
4
5
6
7
8
9
// handleSession 内部
std::optional<boost::asio::steady_timer> deadline;
deadline.emplace(socket.get_executor());

// 分离启动 timer 协程
co_spawn(socket.get_executor(),
         idleTimerLoop(*deadline, socket, alive, lastActive, timeoutMs),
         //            ^^^^^^^^^ 裸引用!
         boost::asio::detached);

idleTimerLoop 的签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static Awaitable<void> idleTimerLoop(boost::asio::steady_timer& timer,  // 裸引用
                                     tcp::socket& socket, ...)
{
    while (alive->load())
    {
        timer.expires_after(std::chrono::milliseconds(timeoutMs));
        co_await timer.async_wait(...);
        // ...
    }
}

问题在于:handleSessionidleTimerLoop 虽然运行在同一个单线程 io_context 上(运行时无并发),但它们的协程帧生命周期是独立的。当 keep-alive 连接正常关闭时:

  1. handleSession 退出 → 协程帧析构 → deadlineoptional<steady_timer>)被销毁
  2. idleTimerLoop 的协程帧仍悬挂在 io_context 中,持有 timer& 裸引用(指向已销毁的 deadline)
  3. ~io_context() 清理悬挂协程帧 → 访问悬空引用 → SegFault

为什么只在 Windows 偶发? Linux epoll 下 cancel() 后 completion 在同一 run() 迭代内被同步 dispatch,idleTimerLoop 通常在 timer 还存活时就已退出。Windows IOCP 的 cancel completion 需要从 I/O 完成端口 dequeue,存在延迟窗口——若 io_context::stop() 在 dequeue 之前执行,协程帧悬挂到析构阶段。

修复:将 deadlineoptional<steady_timer> 改为 shared_ptr<steady_timer>idleTimerLoop 通过 shared_ptr 持有所有权:

1
2
3
4
5
6
7
// 修复后
auto deadline = std::make_shared<boost::asio::steady_timer>(socket.get_executor());

co_spawn(socket.get_executor(),
         idleTimerLoop(deadline, socket, alive, lastActive, timeoutMs),
         //            ^^^^^^^^ shared_ptr,延长生命周期
         boost::asio::detached);

idleTimerLoop 内部解引用使用:

1
2
3
4
5
static Awaitable<void> idleTimerLoop(std::shared_ptr<boost::asio::steady_timer> pTimer, ...)
{
    auto& timer = *pTimer;  // 内部仍用引用,逻辑零变动
    // ...
}

额外开销:每连接 1 次 make_shared<steady_timer>(~100 ns),相比 TCP 连接建立的 ~10-50 μs 可忽略。

教训co_spawn(..., detached) 启动的分离协程,其生命周期独立于调用者。不得持有调用者栈上(或协程帧上)对象的裸引用——必须通过 shared_ptr 共享所有权,或通过 atomic 标志协调退出顺序。

1.5 异常传播:catch 里不能 co_await

C++20 协程有一个容易被忽视的限制:catch 块内不能使用 co_await。这在需要异步回滚的场景下尤其棘手:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌ 编译错误:catch 块内不允许 co_await
try
{
    co_await conn->execute(sql);
}
catch (...)
{
    co_await conn->rollback();  // 编译器拒绝
    throw;
}

Hical 的 DbMiddleware 用 exception_ptr 绕过了这个限制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// DbMiddleware.h — 洋葱模型的异步回滚
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();  // 捕获但不处理
}

// 在 catch 外 co_await 回滚——这是合法的
if (eptr && conn->inTransaction())
{
    try
    {
        co_await conn->rollback();
    }
    catch (...) { }
}

if (eptr) std::rethrow_exception(eptr);
co_return res;

思路是:catch 块只负责捕获异常指针,真正的异步回滚操作放在 catch 外面执行。

1.6 收获:协程不是银弹

协程消除了回调地狱,但引入了新的复杂性:

  1. 协程帧生命周期与对象生命周期分离——必须用哨兵标志或 shared_ptr<this> 保护
  2. co_spawn(detached) 的分离协程不得持有调用者栈上对象的裸引用——必须用 shared_ptr 共享所有权
  3. 成员声明顺序决定正确性——协程 RAII guard 引用的成员必须比 io_context 活得更久
  4. catch 内不能 co_await——需要用 exception_ptr 中转
  5. 调试困难——协程帧在 io_context 队列中,断点打在 co_await 处经常命不中
  6. 错误传播路径更隐蔽——一个未捕获的异常会直接终止 detached 协程,没有 stack trace

经验法则:在每个 co_await 之后,都假设世界可能已经变了。检查对象存活性、检查连接状态、检查取消标志。


二、PMR 三层内存池——收益大但陷阱多

2.1 为什么要三层

Web 框架的内存分配有明显的层次特征:

生命周期特征适合的池类型
全局跨线程共享,低频synchronized_pool_resource(带锁)
线程级单线程独占,高频unsynchronized_pool_resource(无锁)
请求级请求内创建,请求结束一次性释放monotonic_buffer_resource(只分配不回收)

Hical 的三层 PMR 正是按这个思路设计的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// MemoryPool.h — 三层架构
class MemoryPool
{
    // 追踪层(包装 new_delete_resource 作为最终上游)
    TrackedResource trackedResource_;

    // 第 1 层:全局同步池(上游为 trackedResource_)
    std::pmr::synchronized_pool_resource globalPool_;

    // 第 2 层:线程本地池(每个线程独享一个 unsynchronized_pool_resource)
    mutable std::mutex threadPoolsMutex_;
    std::vector<std::unique_ptr<ThreadPoolEntry>> threadPools_;

    // 第 3 层:请求级单调池(按需创建)
    // → createRequestPool() 返回 monotonic_buffer_resource
};

追踪层用无锁原子计数做统计,峰值更新用 CAS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// MemoryPool.h — TrackedResource 的分配统计
void* do_allocate(size_t bytes, size_t alignment) override
{
    void* p = upstream_->allocate(bytes, alignment);
    totalAllocations_.fetch_add(1, std::memory_order_relaxed);
    auto current = currentBytes_.fetch_add(bytes, std::memory_order_relaxed) + bytes;
    // 无锁 CAS 更新峰值
    auto peak = peakBytes_.load(std::memory_order_relaxed);
    while (current > peak &&
           !peakBytes_.compare_exchange_weak(peak, current, std::memory_order_relaxed))
    { }
    return p;
}

2.2 踩坑:upstream 选错导致跨线程竞争

最大的坑出现在请求级单调缓冲区的 upstream 选择上。

1
2
3
4
请求级 monotonic_buffer_resource
    └─ upstream 应该是?
        ✅ 线程本地池(unsynchronized,无锁,同线程释放)
        ❌ 全局池(synchronized,需要加锁,且会在别的线程释放)

一开始图省事,让 monotonic_buffer_resource 的 upstream 直接指向全局同步池。看起来能工作——直到压测时出现了低概率崩溃。

根因:monotonic_buffer_resource 在析构时会把所有从 upstream 申请的大块内存还回去。如果 upstream 是全局同步池,这个"还回去"的操作发生在当前线程,但全局池内部的 bucket 结构可能正被另一个线程操作。虽然 synchronized_pool_resource 理论上线程安全,但在某些标准库实现中,跨线程 deallocate 的路径和同线程不同,性能差异巨大,且在高竞争下暴露了实现 bug。

修复:让请求级池的 upstream 指向当前线程的 unsynchronized_pool_resource。请求在哪个线程处理,就从哪个线程的池分配和释放,彻底消除跨线程竞争。

2.3 踩坑:allocator 忘了传播

PMR 最大的人因陷阱是分配器传播

1
2
3
4
5
// ❌ 容器忘了传 allocator,退化为 new/delete
std::vector<std::string> headers;

// ✅ 必须显式传播
std::pmr::vector<std::pmr::string> headers(&requestPool);

标准库的 PMR 容器不会自动"继承"父容器的分配器。如果你在 pmr::vector 里放了普通 std::string,那些 string 的堆分配完全绕过了 PMR。

更隐蔽的情况是 boost::json::object

1
2
3
4
5
// Boost.JSON 的 pmr 支持
boost::json::monotonic_resource jsonPool;
auto obj = boost::json::parse(body, &jsonPool);
// obj 内部的字符串分配都走 jsonPool
// 但如果你从 obj 中 .as_string() 拷贝出来,新 string 不再走 PMR

2.4 收获:PMR 的收益在高并发场景才显现

在低 QPS 场景(< 1000 req/s),PMR 三层池和默认 new/delete 的性能差距可以忽略不计。PMR 真正发力是在 高并发 + 小对象频繁分配 的场景——此时线程本地池消除了 malloc 的全局锁竞争,请求级单调池消除了碎片化。

经验法则:

  • 先用默认分配器把功能做对
  • 性能剖析确认分配是瓶颈后再引入 PMR
  • 引入 PMR 后要确保 allocator 传播链完整,否则白忙一场

三、模板 + Concepts 比虚函数继承更适合网络框架

3.1 GenericConnection 的零成本分流

Hical 需要同时支持 SSL 和明文连接。传统做法是虚函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 传统虚函数方式
class Connection
{
public:
    virtual void shutdown() = 0;
    virtual awaitable<size_t> read(buffer&) = 0;
};

class PlainConnection : public Connection { /* tcp::socket */ };
class SslConnection : public Connection   { /* ssl::stream<tcp::socket> */ };

问题是:每次读写都要经过虚函数调用。对于网络 I/O 这种热路径,虚调用开销虽小但无谓。

Hical 的方案是模板 + if constexpr

1
2
3
4
5
6
7
8
9
// GenericConnection.h — 编译期 SSL 检测
template <typename T>
inline constexpr bool hIsSslStream = IsSslStream<T>::value;

template <typename SocketType>
class GenericConnection : public TcpConnection
{
    static constexpr bool isSsl() { return hIsSslStream<SocketType>; }
};

在需要分化处理的地方用 if constexpr

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// GenericConnection.h — shutdown 的编译期分化
template <typename SocketType>
void GenericConnection<SocketType>::shutdownInLoop()
{
    if constexpr (hIsSslStream<SocketType>)
    {
        // SSL 连接:先 TLS close_notify,再 TCP shutdown
        auto self = sharedThis();
        boost::asio::co_spawn(
            socketExecutor(),
            [self]() -> boost::asio::awaitable<void>
            {
                try
                {
                    co_await self->socket_.async_shutdown(use_awaitable);
                }
                catch (const boost::system::system_error&) { }
                boost::system::error_code ec;
                auto& sock = self->lowestLayerSocket();
                if (sock.is_open())
                {
                    sock.shutdown(tcp::socket::shutdown_send, ec);
                }
            },
            boost::asio::detached);
    }
    else
    {
        // 普通 TCP:直接 shutdown
        auto& sock = lowestLayerSocket();
        if (!sock.is_open()) return;
        boost::system::error_code ec;
        sock.shutdown(tcp::socket::shutdown_send, ec);
    }
}

同样的模式贯穿了 lowestLayerSocket()socketExecutor()connectEstablished()sslHandshake() 等方法——编译期就确定了分支,运行时零开销。

3.2 NetworkBackend concept:可替换但不多态

为了让框架理论上可以替换底层网络库(虽然目前只有 Asio 后端),Hical 用 C++20 Concepts 定义了后端约束:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Concepts.h — 网络后端约束
template <typename T>
concept NetworkBackend =
    requires {
        typename T::EventLoopType;
        typename T::ConnectionType;
        typename T::TimerType;
    } && EventLoopLike<typename T::EventLoopType>
      && TcpConnectionLike<typename T::ConnectionType>
      && TimerLike<typename T::TimerType>;

// AsioBackend 满足约束
struct AsioBackend
{
    using EventLoopType = AsioEventLoop;
    using ConnectionType = TcpConnection;
    using TimerType = AsioTimer;
};

这和虚函数继承的区别在于:约束在编译期检查,使用时零开销。如果未来有人想基于 io_uring 写一个新后端,只需要满足 NetworkBackend concept,编译器会在实例化时检查所有接口是否齐全。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// EventLoopLike concept 的部分约束
template <typename T>
concept EventLoopLike = requires(T loop, std::function<void()> func, double delay) {
    { loop.run() } -> std::same_as<void>;
    { loop.stop() } -> std::same_as<void>;
    { loop.post(func) } -> std::same_as<void>;
    { loop.dispatch(func) } -> std::same_as<void>;
    { loop.runAfter(delay, func) } -> std::convertible_to<uint64_t>;
    { loop.allocator() } -> std::same_as<std::pmr::polymorphic_allocator<std::byte>>;
};

3.3 收获:编译期分支 > 运行时分支

对于网络框架这种 I/O 密集型场景,if constexpr + 模板实例化的组合比虚函数继承更好:

  1. 零运行时开销:不需要的分支在编译期被完全剔除
  2. 更好的内联:编译器能看到完整的调用链,内联优化空间更大
  3. 类型安全:concept 在编译期就能检查接口完整性,报错信息清晰
  4. 代码复用:同一个 GenericConnection 类同时服务 SSL 和明文,不用维护两份代码

代价是编译时间更长、错误信息更晦涩(虽然 concepts 已经比 SFINAE 好很多了),以及对团队的 C++ 模板功底要求较高。


四、自研日志系统的价值

4.1 为什么不用 spdlog

先说结论:如果你在写应用项目,直接用 spdlog,不要自研

但 Hical 是一个框架,有几个需求是外部日志库难以满足的:

  1. trace-id 贯穿请求链:Hical 的 LogMiddleware 在请求入口生成 trace-id,注入 HttpRequest 的 attribute,后续所有日志自动携带——这需要与中间件洋葱模型深度集成
  2. 运行时动态调级LogAdmin 注册 HTTP 端点,可以在不重启服务的情况下调整日志级别(含按通道独立调整)——这需要与 Router 集成
  3. 通道隔离:访问日志、审计日志、业务日志分流到不同的 Sink(文件/stderr/网络),每个通道独立配置级别和格式——spdlog 的 named logger 能做类似的事,但与 Hical 的中间件模型整合不够自然
  4. C++20 std::format:spdlog 底层用 fmt 库,而 Hical 坚持用标准库的 std::format,避免引入额外依赖

4.2 六层架构的设计决策

Hical 日志系统的层次:

1
2
3
4
5
6
LogRecord(结构化数据)
    → LogFormatter(格式化为文本/JSON)
        → LogSink(输出到 stderr/文件/异步缓冲)
            → Logger(单例调度器,COW 分发)
                → LogChannel(命名通道,独立配置)
                    → LogMiddleware(HTTP 集成,trace-id)

其中最关键的设计决策是 Logger 的 COW(Copy-on-Write)Sink 分发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Log.cpp — COW 快照分发
void Logger::emit(const LogRecord& record)
{
    // 锁内仅拷贝 shared_ptr(1 次 atomic_inc),不拷贝 vector
    std::shared_ptr<const std::vector<std::shared_ptr<LogSink>>> sinksSnap;
    {
        std::lock_guard<std::mutex> lock(m_mutex);
        sinksSnap = m_sinks;
    }

    // 锁外分发——无论 Sink 有多少个、写盘有多慢,都不阻塞其他线程的 addSink/setSink
    for (const auto& sink : *sinksSnap)
    {
        if (record.level >= sink->sinkLevel())
        {
            sink->write(formattedLine);
        }
    }
}

添加 Sink 时:

1
2
3
4
5
6
7
8
void Logger::addSink(std::shared_ptr<LogSink> sink)
{
    std::lock_guard<std::mutex> lock(m_mutex);
    // 拷贝到新 vector,追加后原子替换
    auto newSinks = std::make_shared<std::vector<std::shared_ptr<LogSink>>>(*m_sinks);
    newSinks->push_back(std::move(sink));
    m_sinks = std::move(newSinks);  // 原子替换 shared_ptr
}

这个设计的好处:读路径(日志记录)几乎无锁。锁只保护 shared_ptr 的拷贝(一次原子操作),格式化和 I/O 都在锁外完成。写路径(addSink/setSink)虽然需要拷贝整个 vector,但这种操作极少发生。

4.3 两个关键的性能优化

FixedBuffer:栈上 4KB 缓冲替代 ostringstream

流式日志 API(HICAL_LOG_INFO_STREAM << val)的底层用栈上 FixedBuffer<4096> 而非 std::ostringstream

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// FixedBuffer.h — 栈上缓冲 + 溢出自动 fallback
template <size_t N = 4096>
class FixedBuffer
{
    char m_stackBuf[N] {};    // 栈上 4KB
    size_t m_used {0};
    bool m_overflowed {false};
    std::string m_heapBuf;    // 溢出时才用堆

    void append(const char* data, size_t len)
    {
        if (!m_overflowed)
        {
            if (m_used + len <= N)
            {
                std::memcpy(m_stackBuf + m_used, data, len);
                m_used += len;
                return;  // 99% 的日志走这条路径——零堆分配
            }
            m_heapBuf.assign(m_stackBuf, m_used);
            m_overflowed = true;
        }
        m_heapBuf.append(data, len);
    }
};

数值格式化用 std::to_chars 直写缓冲区,避免 locale 开销:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
template <typename T>
FixedBuffer& formatInteger(T val)
{
    char tmp[32];
    auto [ptr, ec] = std::to_chars(tmp, tmp + 32, val);
    if (ec == std::errc {})
    {
        append(tmp, static_cast<size_t>(ptr - tmp));
    }
    return *this;
}

thread_local 缓存避免重复系统调用

每条日志都需要时间戳和线程 ID。Hical 用 thread_local 缓存避免反复调用 localtime_rstd::this_thread::get_id(),同一秒内的日志共享同一个 struct tm

4.4 收获:核心框架值得自研,应用项目直接用 spdlog

自研日志系统的代价是 17 个源文件的维护负担(Log.h/cpp、LogRecord.h、LogFormatter.h/cpp、LogSink.h/cpp、LogFile.h/cpp、AsyncFileSink.h/cpp、FixedBuffer.h、LogChannel.h/cpp、LogMiddleware.h/cpp、LogAdmin.h/cpp)。

但收获也很明确:

  • 框架深度集成:trace-id、通道分流、运行时调级与 HTTP 中间件无缝配合
  • 零外部依赖:不依赖 fmt、spdlog 或任何第三方日志库
  • 安全特性:日志注入防御(sanitizeForText 转义 \n/\r/ESC)、LogAdmin 无认证默认 403 防"审计致盲"攻击

决策参考:

  • 你在写框架,且日志需要与框架的 HTTP/中间件/路由深度集成 → 自研
  • 你在写应用,只需要写日志到文件 → spdlog

五、双轨反射——为未来留后路

5.1 问题:C++26 还没来,但 API 要现在设计

Hical 需要 JSON 序列化和路由注册两个反射场景。理想情况下用 C++26 原生反射:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// C++26 原生反射版本(未来)
struct User
{
    [[hical::json_name("user_name")]]
    std::string name;

    [[hical::json_required]]
    int age;
};
// toJson/fromJson 自动工作,不需要任何宏

但现实是没有编译器完整支持 P2996。等标准落地再写框架?用户等不了。完全用宏?未来迁移成本高。

Hical 的选择是双轨制HICAL_HAS_REFLECTION 编译开关切换两条路径,用户 API 保持一致。

5.2 宏回退层的实现策略

C++20 宏回退层的核心挑战是:如何让宏语法接近未来的属性语法,同时保持编译期类型安全。

Hical 的 HICAL_JSON 宏支持装饰器混写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 用户 API(C++20 宏回退)
struct ApiResponse
{
    std::string requestId;
    int statusCode;
    std::string message;
    std::string traceId;

    HICAL_JSON(ApiResponse,
        REQUIRED_ALIAS(requestId, "request_id"),  // 必填 + 别名
        REQUIRED(statusCode),                      // 必填
        ALIAS(message, "status_message"),           // 别名
        HICAL_IGNORE(traceId))                     // 忽略
};

实现的关键技巧是括号检测 + 标签派发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// MetaJson.h — 装饰器是带括号的 tuple
#define ALIAS(field, alias)          (hical_alias_, field, alias)
#define REQUIRED(field)              (hical_required_, field)
#define REQUIRED_ALIAS(field, alias) (hical_required_alias_, field, alias)

// 括号检测:区分裸字段 name 和装饰器 ALIAS(name, "n")
#define HICAL_IS_PAREN_(x) HICAL_IS_PAREN_CHECK_(HICAL_IS_PAREN_PROBE_ x)

// 分派:裸字段走 LEAF_0,带括号装饰器走 LEAF_1
#define HICAL_JSON_FIELD_(T, arg) \
    HICAL_JSON_PASTE_(HICAL_JSON_LEAF_, HICAL_IS_PAREN_(arg))(T, arg)

标签派发把装饰器展开为 (tag, field, args...),然后通过 token paste 路由到对应的处理器:

1
2
3
4
5
// 标签处理器
#define HICAL_JSON_TAG_hical_alias_(T, field, alias)      \
    HICAL_JSON_MAKE_FIELD_(T, field, alias, &T::field)
#define HICAL_JSON_TAG_hical_required_(T, field)           \
    HICAL_JSON_MAKE_FIELD_(T, field, #field, &T::field, true, false)

编译期字段校验确保不会拼错字段名:

1
2
3
4
5
6
7
8
#define HICAL_JSON_MAKE_FIELD_(T, field, ...)       \
    ([]()                                            \
    {                                                \
        static_assert(                               \
            requires { std::declval<T>().field; },   \
            "HICAL_JSON: field '" #field "' does not exist in " #T); \
        return ::hical::meta::detail::makeField<T>(__VA_ARGS__); \
    }())

字段数量不受限制,通过 __VA_OPT__ 递归展开 + 多层 EXPAND 宏支持最多 243 个字段:

1
2
3
4
5
#define HICAL_JSON_FOR_EACH_(macro, T, a, ...) \
    macro(T, a) __VA_OPT__(, HICAL_JSON_FE_AGAIN_ HICAL_JSON_PARENS_(macro, T, __VA_ARGS__))

// 5 层 EXPAND 支持 3^5 = 243 个字段
#define HICAL_JSON_EXPAND_(...) HICAL_JSON_EXP4_(HICAL_JSON_EXP4_(__VA_ARGS__))

5.3 收获:用宏模拟未来语言特性

双轨制的核心价值在于:

  1. 用户 API 不变toJson(obj) / fromJson<T>(json) / req.readJson<T>() 在两条轨道下签名一致
  2. 迁移成本趋近于零:当编译器支持 P2996 后,用户只需:
    • HICAL_JSON(Type, field1, ALIAS(field2, "key")) 替换为结构体属性标注
    • 打开 HICAL_ENABLE_REFLECTION=ON
    • 删除宏调用
  3. 编译期安全static_assert + requires 在 C++20 宏路径下也能捕获字段名拼写错误

这种"用宏模拟未来语言特性,保持 API 接口一致"的策略,适用于所有"语言特性快来了但还没来"的场景。


六、移除 Boost.Beast——火焰图驱动的依赖清退

6.1 Beast 到底慢在哪

Hical 从 v1.0.0 起就使用 Boost.Beast 做 HTTP 解析/序列化和 WebSocket 支持。Beast 是一个优秀的库,但在 v2.5.2 的火焰图分析中,它成了用户态的主要瓶颈:

组件函数热点CPU 占比
HTTP 解析basic_parser::put + parse_fields0.63%
Header 堆分配basic_fields::new_element,每个头部一次 new0.95%
响应序列化serializer::next + write_op + scatter-gather 重模板1.9%
合计Beast 相关 CPU 占用~3.5%

3.5% 看起来不大,但在内核 TCP 栈已占 65% 的情况下,用户态可优化空间只有 ~35%。Beast 独占了其中 10% 的可优化空间。

更关键的是间接成本:Beast 的重模板设计导致编译极慢、二进制膨胀严重,而且 basic_fields(链表实现)阻止了零拷贝优化。

6.2 picohttpparser + 零拷贝请求

v2.6.0 用 picohttpparser(H2O 项目提取的 ~1500 行 C 解析器)替代了 Beast 的 HTTP parser。核心设计是零拷贝

1
2
3
4
5
6
7
// NativeRequest — 所有字段都是 readBuf 上的视图
struct NativeRequest {
    std::string_view method;    // → readBuf 中的 "GET"
    std::string_view target;    // → readBuf 中的 "/api/users?page=1"
    RequestHeaders headers;     // 栈上 array<Entry, 64>,string_view → readBuf
    std::string body;           // Body 单独拥有(可能跨多次 read)
};

与 Beast 的关键区别:

维度NativeRequestBeast request
Header 存储栈上数组(零堆分配)链表(每 header 一次 new)
数据所有权string_view 引用 readBuf独立 string 拷贝
缓存友好连续内存指针追踪

火焰图占比从 0.63% → 0.06%,降低 90%。

响应序列化同样极度精简——头部序列化到栈上 FixedBuffer<512>,小响应(API JSON 通常 <100B)头+体合并后单次 async_write,大响应用 scatter-gather 避免 body 拷贝。整个序列化逻辑约 50 行,编译后几百字节机器码,而 Beast 的 serializer 模板实例化产物是千行量级。

6.3 自研 WebSocket 栈的取舍

Beast WebSocket 的移除比 HTTP 更有挑战——WebSocket 协议本身就复杂(帧格式、分片、masking、压缩扩展),需要完整重新实现 RFC 6455。

自研栈分四个模块:

  • WsFrame.h:帧解析/构造,batch 4 字节 XOR unmask 优化
  • WsHandshake.h:升级握手协议,SHA1 + Base64 Accept Key 计算
  • WsDeflate.h/cpp:permessage-deflate 压缩,Pimpl 封装 zlib(使用方不需要 #include <zlib.h>
  • WebSocketSession:完整重写为 raw socket 实现,支持消息分片重组 + 控制帧穿插

踩坑经历:最容易忽略的是协议安全校验——客户端帧必须 masked、控制帧不允许分片且 payload ≤ 125B、RSV2/RSV3 在未协商扩展时必须为 0、消息总大小要限制(zip bomb 防护)。这些在使用 Beast 时是"免费的",自研后每一条都要自己实现和测试。

所有实现都隔离在 HttpSessionImpl.cpp 编译防火墙内——修改 Router、Middleware、业务 handler 不触发 HTTP/WS 栈重编译。

6.4 收获:数据驱动的依赖决策

v2.6.0 的"去 Beast"配合 SO_REUSEPORT 多 acceptor + 连接级原子超时,最终效果:

  • QPS:27K → 159K(+489%),与 Cinatra/Drogon 持平
  • 框架层 CPU:5.5% → 2.5%(-55%)
  • 编译依赖:移除 boost/beast/ 整个目录
  • 新增依赖:仅 picohttpparser(vendored)+ zlib(系统库)

经验法则:

  1. 先量化再动手:火焰图确认 Beast 占用 3.5% 后再决定替换,不是凭"感觉重"就开干
  2. 自研 ≠ 重新发明轮子:HTTP 解析用成熟的 picohttpparser(被 H2O、Rust hyper 等验证过),只自研协议层无法避免的部分
  3. 安全不能偷懒:第三方库的安全校验是"隐性资产",自研后每条 RFC 规则都要自己实现和测试
  4. 编译隔离是必须的:Pimpl + 编译防火墙让使用者完全无感知,这是大规模依赖替换的前提

七、WsHub 广播管理器——从"能用"到"好用"的 WebSocket 架构

7.1 问题:裸连接管理的困境

v2.6.0 实现了完整的 RFC 6455 WebSocket 栈,但只提供了底层能力——WebSocketSession 是单连接级别的 API。业务层要做"聊天室广播"或"游戏房间同步"这种场景,必须自己维护连接注册表、房间分组、生命周期清理。

典型的业务代码长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ❌ 业务层自行管理连接(v2.6.0 时代)
std::mutex g_mutex;
std::unordered_map<uint64_t, std::weak_ptr<WebSocketSession>> g_connections;
std::unordered_map<std::string, std::unordered_set<uint64_t>> g_rooms;

void broadcastToRoom(const std::string& room, std::string_view msg)
{
    std::lock_guard lock(g_mutex);
    for (auto id : g_rooms[room])
    {
        if (auto sp = g_connections[id].lock())
        {
            // ⚠️ 这里直接 co_await send() 是错的——
            //    1) 锁内 co_await 会阻塞所有其他操作
            //    2) sp 可能在另一个线程的 io_context 上
            //    3) send 和 heartbeat ping 可能并发 async_write
        }
    }
}

这段代码至少有三个隐患:锁粒度过粗、跨线程写入不安全、写操作并发。这些问题本质上属于框架应该解决的基础设施问题。

7.2 WsHub 的核心设计

v2.6.2 引入了 WsHub 广播管理器,核心设计原则是读写分离 + 跨线程安全投递

1
2
3
4
5
6
7
8
9
// WsHub.h — 核心数据结构
class WsHub
{
    mutable std::shared_mutex m_mutex;
    std::unordered_map<WsConnectionId, ConnectionEntry> m_connections;
    std::unordered_map<std::string, std::vector<RoomMember>,
                       StringHash, StringEqual> m_rooms;  // 透明哈希
    std::atomic<WsConnectionId> m_nextId {1};
};

shared_mutex 实现读写分离:add()/remove()/join()/leave() 操作需要 unique_lock(写锁),而 broadcast()/sendTo() 只需 shared_lock(读锁)。在典型的聊天室场景中,广播频率远高于加入/退出频率,读写分离的收益明显。

广播操作的关键在于跨线程安全写入:每个 WebSocketSession 绑定在特定的 io_context 线程上(SO_REUSEPORT 多 acceptor 模型),从 Hub 线程直接调用 send() 是跨线程操作——违反 Asio 的线程安全契约(后记中 TSan 揪出的同类问题)。

Hical 的解决方案是 coSpawn 投递到目标连接的 executor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// WsHub.cpp — 广播的跨线程投递
void WsHub::broadcastImpl(std::string_view room, std::string_view payload,
                          WsConnectionId exclude, bool isBinary)
{
    auto msgPtr = std::make_shared<std::string>(payload);

    std::shared_lock lock(m_mutex);
    auto roomIt = m_rooms.find(room);
    if (roomIt == m_rooms.end()) return;

    for (const auto& member : roomIt->second)
    {
        if (member.id == exclude) continue;
        auto sp = member.session.lock();
        if (sp && sp->isOpen())
        {
            // 投递到连接所属的 io_context 线程执行
            coSpawn(sp->socket().get_executor(),
                    [sp, msgPtr, isBinary]() -> Awaitable<void>
                    {
                        if (sp->isOpen())
                        {
                            if (isBinary)
                                co_await sp->sendBinary(*msgPtr);
                            else
                                co_await sp->send(*msgPtr);
                        }
                    });
        }
    }
}

两个值得注意的细节:

  1. weak_ptr 存储:Hub 不延长连接生命周期。连接断开后 weak_ptr::lock() 返回 nullptr,广播时自动跳过。用户必须在 onDisconnect 回调中调用 remove(id) 清理条目
  2. shared_ptr<string> 共享消息:N 个目标连接共享同一份消息内存,避免 N 次字符串拷贝。但单目标 sendTo() 直接 move string 进 lambda,省去 shared_ptr 控制块分配

7.3 写串行化:被忽视的并发陷阱

v2.6.2 同时解决了一个 WebSocket 层面更底层的问题:Ping 和消息的并发写入

WebSocket 心跳用独立协程定期发送 Ping 帧,而业务消息通过 send()/broadcast() 发送。两者最终都调用 boost::asio::async_write。问题是:两个协程可能在同一时刻对同一个 socket 执行 async_write——Asio 不允许同一个 socket 上有多个并发写操作。

v2.6.2 引入了基于 steady_timer 的写串行化机制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// WebSocket.cpp — 写串行化
Awaitable<void> WebSocketSession::acquireWrite()
{
    while (m_writePending)
    {
        // 等待前一个写完成(timer cancel = 就绪信号)
        boost::system::error_code ec;
        co_await m_writeReady->async_wait(
            boost::asio::redirect_error(boost::asio::use_awaitable, ec));
    }
    m_writePending = true;
}

void WebSocketSession::releaseWrite()
{
    m_writePending = false;
    m_writeReady->cancel_one();  // 唤醒等待的协程
}

sendFrame() 的实现用 RAII 确保异常安全:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Awaitable<void> WebSocketSession::sendFrame(WsOpcode opcode, std::string_view payload,
                                            bool fin, bool rsv1)
{
    co_await acquireWrite();

    struct WriteGuard
    {
        WebSocketSession& self;
        ~WriteGuard() { self.releaseWrite(); }
    } guard {*this};

    auto frame = buildWsFrame(opcode, payload, fin, rsv1);
    co_await boost::asio::async_write(m_socket, boost::asio::buffer(frame),
                                      boost::asio::use_awaitable);
}

这个设计利用了 Asio 的 steady_timer 作为协程级信号量——cancel_one() 唤醒等待方,比 condition_variable 更轻量(和 DbConnectionPool 用的是同一个模式)。

7.4 收获:框架应该管理连接生命周期

v2.6.2 的 WsHub 验证了一条框架设计原则:凡是业务层容易写错的基础设施代码,都应该由框架提供

连接注册表、房间分组、跨线程投递、写串行化——这些单独拿出来都不复杂,但组合在一起很容易出错。尤其是跨线程写入和并发 async_write 这两个问题,在单线程开发环境下完全不会暴露,部署到多线程生产环境才偶发崩溃。

经验法则:

  • 跨线程操作 Asio 对象必须投递到目标 executor——这条规则在 WsHub 广播、Timer 取消、Acceptor 关闭中反复出现
  • 同一 socket 上的并发写操作需要串行化——用 steady_timer 做协程信号量,比裸 mutex 更契合协程模型
  • weak_ptr 是管理非拥有引用的正确工具——Hub 不应该延长连接生命周期,否则断开的连接无法被真正释放

后记:ThreadSanitizer CI 揪出的隐藏竞态(v2.6)

上面七个章节都是在开发中"踩坑→修复"的主动过程。但有些竞态条件,手动测试几乎不可能触发——它们需要特定的线程交错时序,可能在生产环境跑几个月才偶发一次 crash。

v2.6 引入了手动触发的 TSan(ThreadSanitizer)CI,首次运行就揪出 4 处数据竞争:

Boost.Asio 对象的跨线程操作

这是最深刻的一条教训。Boost.Asio 的 io_context 本身是线程安全的(多线程 run() 没问题),但挂在它上面的 I/O 对象(timer、socket、acceptor)不是。框架里有三处违反了这个规则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 错误:从外部线程直接 cancel timer
void AsioTimer::cancel() {
    if (!cancelled_.exchange(true))
        timer_.cancel();  // ← io_context 线程正在 async_wait,竞争!
}

// 错误:从外部线程直接关闭 acceptor
void TcpServer::stop() {
    acceptor_.close(ec);  // ← io_context 线程正在 async_accept,竞争!
}

修复方式是把操作 post 到对象所属的 io_context 线程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 修复:cancel 投递到 executor 线程
void AsioTimer::cancel() {
    if (!cancelled_.exchange(true))
        boost::asio::post(timer_.get_executor(),
                          [self = shared_from_this()]() { self->timer_.cancel(); });
}

// 修复:acceptor 关闭同步等待
void TcpServer::stop() {
    std::promise<void> done;
    auto future = done.get_future();
    boost::asio::post(baseLoop_->getIoContext(),
                      [this, &done]() { acceptor_.close(ec); done.set_value(); });
    future.get();
}

stop() 的并发调用:门卫模式

AsioEventLoop::stop() 中的 workGuard_.reset()unique_ptr::reset()——非线程安全。而在某些场景下(HttpServer::stop()ConnectionCounter 析构同时触发 stopAllLoops()),两个线程会并发调用它。

修复很简单:用已有的 quit_ 原子标志做一次性门卫:

1
2
3
4
5
6
void AsioEventLoop::stop() {
    if (quit_.exchange(true))  // 第二个调用者直接返回
        return;
    workGuard_.reset();
    ioContext_.stop();
}

教训

  1. 手动测试几乎不可能发现数据竞争——TSan 是唯一可靠的检测手段
  2. Asio 对象 ≠ io_contextio_context 线程安全,但它的子对象不是。这是一个容易被忽略的区分
  3. sleep_for 不是同步原语——测试中 sleep 后操作共享对象,TSan 正确报告缺少 happens-before 关系
  4. CI 中加入 TSan 的 ROI 极高——4 处竞态在第一次运行就全部暴露,修复成本极低(总共改了 ~30 行代码)

总结:七条核心原则

回顾整个 Hical 的开发过程(开源至今已迭代到 v2.6.2),提炼出七条核心原则:

  1. 协程不免费:它消除了回调地狱,但引入了生命周期管理的新复杂性。每个 co_await 都是断裂点,必须配合 RAII 哨兵;协程帧中 RAII guard 引用的外部成员必须比 io_context 活得更久(成员声明顺序 = 正确性);co_spawn(detached) 的分离协程不得持有调用者栈上对象的裸引用(必须用 shared_ptr 共享所有权)
  2. PMR 是优化手段,不是默认选择:先用默认分配器把功能做对,性能剖析确认瓶颈后再引入,且必须确保 allocator 传播链完整
  3. 编译期能做的事不留到运行时if constexpr > 虚函数,concepts > RTTI,模板实例化 > 运行时分支
  4. 自研要有明确的理由:日志系统自研是因为需要与 HTTP 中间件深度集成,如果只是"写个日志到文件"完全不值得
  5. 为未来设计 API,用当前技术实现:双轨反射证明了这条路是可行的——宏回退层的 API 设计对齐未来的语言特性,迁移时用户代码几乎不改
  6. 依赖清退要数据驱动:移除 Boost.Beast 不是凭感觉,而是火焰图量化确认 3.5% CPU 开销后的理性决策。自研替代时,成熟组件(picohttpparser)直接复用,只自研不得不自研的部分
  7. 业务层容易写错的代码应由框架提供:连接注册、房间分组、跨线程投递、写串行化——这些单独不复杂,但组合在一起极易出错。WsHub 的经验证明,框架多做一点,业务少踩一个坑

这些原则不仅适用于 Web 框架开发,也适用于任何需要在性能、可维护性和前瞻性之间做权衡的 C++ 项目。


有兴趣可查看 Hical 框架源码地址:github.com/Hical61/Hical