Hical 性能优化全记录

优化背景 Hical 是我写的 C++20/26 Web 框架,跑 Hello World 压测时起初只有 27K QPS,而同类框架(Cinatra 165K、Drogon 170K)差了将近一个数量级。目标很明确:追平 Cinatra/Drogon 的水平。 整个优化过程分 6 个阶段,不是拍脑袋乱改,每一步都是 perf + 火焰图定位瓶颈 → 想方案 → 写代码 → 跑压测验证 的循环。能看到数字变化才算数。 阶段 1:协程帧削减(v2.5.1-v2.5.2) 发现问题 perf 火焰图第一个大头:14.5% CPU 在 scheduler::wake_one_thread_and_unlock + pthread_cond_signal。 一开始以为是跨线程调度问题,仔细一看不是——是 Boost.Asio scheduler 每次 co_await resume 都要走的内部调度流程太重了。一个 Hello World 请求居然走了 4 个协程帧: 1 2 3 4 5 handleSession: co_await async_read → 帧 1(必需,I/O 等待) co_await router_.dispatch() → 帧 2(Router 本身是协程) co_await handler(req) → 帧 3(同步 handler 被包装成协程,不必要!) co_await async_write → 帧 4(必需,I/O 等待) 帧 1 和 4 是真正的 I/O 等待不可消除,但帧 2 和 3 完全是浪费——一个同步的 return HttpResponse("Hello") 被裹了两层协程。 ...

May 22, 2026 · 9 min · 1794 words

深入学习 Boost.Asio(三):协程进阶与实战项目

系列导航:入门篇 | 进阶篇 | 实战篇 前置知识 阅读本篇前,请确保已掌握: 入门篇:io_context、异步操作生命周期、定时器 进阶篇:协程 Echo Server、多线程模型、strand 1. 协程进阶技巧 1.1 co_spawn 的第三个参数 co_spawn 的第三个参数决定了协程完成后的行为: 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 36 37 38 39 40 41 42 #include <boost/asio.hpp> #include <boost/asio/co_spawn.hpp> #include <boost/asio/detached.hpp> #include <boost/asio/use_awaitable.hpp> using boost::asio::awaitable; using boost::asio::use_awaitable; awaitable<int> compute() { co_return 42; } awaitable<void> mayFail() { throw std::runtime_error("oops"); co_return; } void examples(boost::asio::io_context& ioCtx) { // 方式1:detached —— 忽略返回值和异常 // 适用:独立运行的协程(如连接处理) boost::asio::co_spawn(ioCtx, compute(), boost::asio::detached); // 方式2:回调 —— 协程完成时执行回调 // 适用:需要捕获协程异常或获取返回值 boost::asio::co_spawn(ioCtx, mayFail(), [](std::exception_ptr e) { if (e) { try { std::rethrow_exception(e); } catch (const std::exception& ex) { std::cerr << "协程异常: " << ex.what() << "\n"; } } }); // 方式3:use_awaitable —— 在协程中等待另一个协程 // 适用:父子协程关系 // (需要在协程内使用) } // 方式3 完整示例 awaitable<void> parent(boost::asio::io_context& ioCtx) { // 等待子协程完成并获取返回值 int result = co_await boost::asio::co_spawn( ioCtx, compute(), boost::asio::use_awaitable); std::cout << "子协程返回: " << result << "\n"; // 42 } 1.2 超时控制 生产环境中,你不能无限等待一个操作完成。Asio 提供了 awaitable_operators 实现竞争式等待: ...

May 21, 2026 · 10 min · 1926 words

深入学习 Boost.Asio(二):TCP 编程与多线程模型

系列导航:入门篇 | 进阶篇 | 实战篇 前置知识 阅读本篇前,请确保已理解 入门篇 中的以下概念: io_context 的作用和 run() 执行流程 异步操作的生命周期(发起 → 完成 → handler 执行) post/dispatch 的区别 1. TCP 编程:三步演进 我们通过构建一个 Echo Server(收到什么就回什么),从最简单的同步版本逐步演进到生产级协程版本。 1.1 第一步:同步阻塞版 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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 // echo_server_sync.cpp // 编译: g++ -std=c++20 echo_server_sync.cpp -lboost_system -lpthread -o echo // 测试: 另开终端 nc localhost 9999,输入文字会回显 #include <boost/asio.hpp> #include <iostream> using boost::asio::ip::tcp; int main() { boost::asio::io_context ioCtx; // 创建 acceptor:监听 TCP 连接 // 参数:io_context, 绑定地址(IPv4, 端口9999) tcp::acceptor acceptor(ioCtx, tcp::endpoint(tcp::v4(), 9999)); std::cout << "同步 Echo Server 监听端口 9999\n"; while (true) { // accept() 阻塞,直到有客户端连接 tcp::socket socket(ioCtx); acceptor.accept(socket); std::cout << "客户端连接: " << socket.remote_endpoint().address().to_string() << ":" << socket.remote_endpoint().port() << "\n"; // 处理这个连接(阻塞:处理期间无法接受新连接!) boost::system::error_code ec; char buf[1024]; while (true) { // read_some:读取可用的数据(可能只有一部分) size_t n = socket.read_some(boost::asio::buffer(buf), ec); if (ec == boost::asio::error::eof) { std::cout << "客户端断开\n"; break; } if (ec) throw boost::system::system_error(ec); // 将收到的数据原样写回 boost::asio::write(socket, boost::asio::buffer(buf, n)); } } return 0; } 问题:同一时刻只能服务一个客户端。当客户端 A 连接后,客户端 B 必须等 A 断开才能被接受。 ...

May 20, 2026 · 10 min · 2026 words

深入学习 Boost.Asio(一):从原理到 io_context

系列导航:入门篇 | 进阶篇 | 实战篇 引言:为什么需要异步 I/O? 假设你在写一个聊天服务器,同时连接 1000 个用户。如果用传统的"一个线程处理一个连接"模型: 1 2 3 4 线程1: read(socket_1) ← 阻塞等待用户1输入... 线程2: read(socket_2) ← 阻塞等待用户2输入... ... 线程1000: read(socket_1000) ← 阻塞等待用户1000输入... 问题:1000 个线程各自阻塞在 read() 上,每个线程占用 ~1MB 栈内存(合计 ~1GB),还有大量的上下文切换开销。这就是经典的 C10K 问题。 异步 I/O 的解决思路:用 1 个线程(或少量线程)管理所有连接,操作系统在数据就绪时通知我们: 1 2 3 4 5 6 单线程事件循环: ┌→ 等待事件(epoll/IOCP) │ ├─ socket_7 可读 → 处理用户7的消息 │ ├─ socket_42 可读 → 处理用户42的消息 │ └─ socket_100 可写 → 继续发送给用户100 └─ 回到等待 Boost.Asio 就是 C++ 中实现这一模型的工业级库。本篇将带你理解它的底层原理和核心组件。 ...

May 18, 2026 · 10 min · 1990 words

Hical 生产部署实践:从编译优化到 Kubernetes 容器化

Hical 生产部署实践:从编译优化到容器化 框架开发完了,测试也通过了——然后呢?“本地跑得好好的"和"线上稳定运行"之间,隔着编译优化、进程管理、反向代理、监控告警、容器编排一整套工程实践。这篇文章把 Hical 从开发环境搬到生产环境的完整链路走一遍,每个环节都给出可直接复用的配置模板。 目录 Hical 生产部署实践:从编译优化到容器化 目录 一、编译优化:榨干最后一点性能 1.1 Release 基础参数 1.2 LTO(链接时优化) 1.3 PGO(Profile-Guided Optimization) 1.4 静态链接 vs 动态链接 二、进程管理:别让服务裸奔 2.1 systemd 服务配置 2.2 信号处理与 Graceful Shutdown 2.3 多线程与多 acceptor(SO_REUSEPORT) 三、反向代理:Nginx 挡在前面 3.1 HTTP 反向代理 3.2 WebSocket 代理 3.3 SSL 终止策略 四、监控与可观测性 4.1 Prometheus 指标暴露 4.2 日志接入 ELK / Loki 4.3 健康检查端点 五、容器化部署 5.1 多阶段 Dockerfile 5.2 docker-compose 完整示例 5.3 Kubernetes 部署参考 六、性能调优检查清单 系统级 Hical 应用级 PMR 内存池 数据库连接池 日志系统 调优流程 一、编译优化:榨干最后一点性能 1.1 Release 基础参数 开发阶段用 Debug 方便调试,上线必须切 Release。区别不只是 -O2,还有 assert 消除、NDEBUG 定义(Hical 的 HICAL_LOG_TRACE 宏在 NDEBUG 下编译期完全消除): ...

May 17, 2026 · 15 min · 2992 words

Heaptrack:找出 C++ 程序中的无效内存分配

Heaptrack:找出 C++ 程序中的无效内存分配 你的火焰图上 malloc/free 占了 8% CPU。你知道分配太频繁了,但——是哪个函数在疯狂 new?每次 new 了多少字节?有没有更好的办法? 故事:每秒 17000 次 malloc,但只有 41 次是浪费的 对我的 C++20/26 Web 框架(Hical)做 Heaptrack 分析时发现:136K QPS 下每秒 17457 次堆分配,但临时分配(分配后很快释放)只有 41 次/秒——说明 PMR 内存池策略生效了。 但第一版代码没有 PMR 时,临时分配高达 13 万次/秒。Heaptrack 精确告诉了我哪些 std::string 和 std::vector 是罪魁祸首,逐个消灭后内存分配开销从 8% 降到 < 0.1%。 这篇教你用 Heaptrack 做同样的事——精确定位哪个函数在做无效分配,然后干掉它。 一、Heaptrack 是什么 Heaptrack 是一个堆内存分配追踪器,记录程序运行期间的每一次 malloc/new/free/delete,告诉你: 总共分配了多少次?多少字节? 哪个函数分配最多?(完整调用栈) 峰值内存使用在哪个时间点? 有没有泄漏(分配了但从未释放)? 临时分配有多少?(分配后很快释放——这是优化首要目标) 对比 Valgrind Massif Heaptrack Valgrind –tool=massif 性能开销 2~5x 减速 20~50x 减速 数据粒度 每次分配的完整调用栈 定期快照 GUI heaptrack_gui(丰富) ms_print(文本) 适用场景 日常分析(推荐) 极精确内存画像 一句话:Heaptrack 是 Valgrind Massif 的现代替代品,快 10 倍,信息更全。 ...

May 15, 2026 · 5 min · 873 words

perf + 火焰图:5 分钟定位 C++ 程序的 CPU 瓶颈

perf + 火焰图:5 分钟定位 C++ 程序的 CPU 瓶颈 你的服务器 CPU 跑满了,QPS 却上不去。top 告诉你"忙",但不告诉你忙在哪。怎么办? 故事:从 27K 到 136K QPS 我开发了一个 C++20/26 Web 框架(Hical),第一次压测只有 27K QPS,而同场景下 Drogon 和 Cinatra 都在 160K+。CPU 使用率 100%,top 没用,gdb 打断点太慢。 最终靠 perf record + 火焰图,5 分钟定位到瓶颈不在我的框架代码(仅占 2% CPU),而在 Boost.Asio 的调度层——跨线程 epoll_ctl 和 per-request timer 合计吃了 27% CPU。 优化后 QPS 从 27K → 136K。 这篇文章把我整套分析流程分享出来。不需要你用过 Hical,任何 C++ 服务器程序都适用。 一、工具安装(2 分钟搞定) 1 2 3 4 5 6 7 8 9 # perf(必须匹配内核版本) sudo apt install -y linux-tools-$(uname -r) linux-tools-generic # FlameGraph 脚本(Brendan Gregg 出品) git clone --depth 1 https://github.com/brendangregg/FlameGraph.git ~/FlameGraph # 放开 perf 权限(否则只能看到自己的进程) sudo sysctl -w kernel.perf_event_paranoid=-1 sudo sysctl -w kernel.kptr_restrict=0 验证: ...

May 15, 2026 · 5 min · 971 words

缓存行对 C++ 性能的影响有多大?实测告诉你

缓存行对 C++ 性能的影响有多大?实测告诉你 面试题:“遍历 vector 比遍历 list 快多少倍?"——答案不是 2 倍,是 10~100 倍。原因只有一个字:缓存。 故事:为什么 vector 存 20 个 HTTP 头比 unordered_map 还快 开发 Hical Web 框架时,我面临一个选择:HTTP 请求头用什么容器存? 直觉说 unordered_map<string, string> 查找 O(1),肯定比 vector<pair<string, string>> 的 O(n) 快。但实测结果打脸——vector 线性扫描 20 个头部,比 unordered_map 哈希查找还快 40%。 原因就是 cache line。这篇文章讲清楚这件事。 一、CPU 缓存:被忽视的性能悬崖 1.1 速度鸿沟 你的程序跑在 CPU 上,但数据存在内存里。两者之间有一道巨大的速度鸿沟: 1 2 3 4 5 6 7 8 9 10 11 ┌──────────┐ │ CPU 寄存器│ ~0.3 ns (1 cycle) ├──────────┤ │ L1 Cache │ ~1 ns (3-4 cycles) 32-48 KB / 核 ├──────────┤ │ L2 Cache │ ~4 ns (10-12 cycles) 256 KB-1 MB / 核 ├──────────┤ │ L3 Cache │ ~12 ns (30-40 cycles) 8-32 MB / 共享 ├──────────┤ │ 主内存 │ ~60-100 ns (150-300 cycles) └──────────┘ 关键数字:L1 和主内存的延迟差 100 倍。 ...

May 15, 2026 · 6 min · 1203 words

C++ 性能分析全景指南:从工具链到方法论

C++ 性能分析全景指南:从工具链到方法论 不要凭直觉猜瓶颈——人的直觉在性能问题上错误率极高。先量测,再优化。 写在前面 性能优化是 C++ 程序员的核心竞争力之一。但"性能优化"这四个字太大了——从微架构级的 cache line 对齐,到宏观的算法复杂度选择,中间跨越了多个抽象层次。 这篇文章不是某个工具的使用教程,而是试图建立一套完整的性能分析知识框架:遇到性能问题时,你该用什么工具、看什么指标、按什么思路排查。全文分为九个部分: 核心思维 CPU Profiling 内存分析 编译优化分析 Benchmark 编写 并发与锁分析 Sanitizer 全家桶 优化决策方法论 工具选择与学习路线 一、核心思维 1.1 性能问题的三种类型 所有性能问题,本质上只有三类: 类型 表现 典型原因 CPU-bound CPU 利用率高,但吞吐上不去 算法复杂度高、分支预测失败、指令级并行度低 Memory-bound CPU 利用率不高(在等数据),IPC 低 缓存未命中、TLB miss、false sharing、频繁堆分配 I/O-bound CPU 几乎空闲,程序却很慢 磁盘读写、网络等待、锁竞争(广义 I/O) 判断当前程序属于哪一类,是性能分析的第一步。用错了工具,你会在错误的方向上浪费大量时间。 1.2 Amdahl 定律的启示 优化一个占总耗时 5% 的函数,即使你把它优化到 0,整体也只快 5%。但优化一个占 60% 的函数,哪怕只快 20%,整体就快 12%。 永远先找大头。这就是为什么 profiling 必须走在优化前面。 1.3 量测的四条铁律 在接近生产环境的条件下量测——Debug 模式的热点分布和 Release 完全不同 量测时关闭无关进程——CPU 频率调节(turbo boost / power saving)会干扰结果 多次量测取统计值——单次运行的噪声太大,至少跑 3 次取中位数 量测前后只改一个变量——否则你不知道是哪个改动起了作用 二、CPU Profiling CPU 剖析是性能分析的基础。根据实现方式不同,分为采样式和插桩式两大类。 ...

May 12, 2026 · 16 min · 3208 words

连接级 Atomic 时间戳超时的实现决策

起因 最初 Hical 的空闲超时实现就是传统做法:每个 HTTP 请求/每次 keep-alive 读等待都注册一个 steady_timer,读完成后取消,超时则关闭连接。实现上用的是 shared_ptr<function> 自引用环做回调链续期——每连接 2 次堆分配(shared_ptr 控制块 + function 对象),且每次续期都要重新构造回调。 v2.5.2 压测到 132K QPS 时,做热路径Review代码发现这个 timer 机制的问题: 每请求 2 次 epoll_ctl(注册 + 取消 timer) shared_ptr<function> 自引用环本身就有堆分配开销 140K QPS 下整体约产生 100 万次 epoll_ctl/sec,38% CPU 花在内核 _raw_spin_unlock_irqrestore(TCP spin_lock),而用户态框架代码只占不到 5% 瓶颈已经从用户态转移到内核态,减少进内核的次数成为核心策略。空闲超时的 timer 是明确可以砍掉的——30-60s 的超时精度要求本来就极低。 改良过程 分两步走: 第一步:先把 shared_ptr<function> 回调链改为独立协程 idleTimerLoop,消除自引用环和 2 次堆分配。这一步还是 per-connection 一个 timer 协程,只是实现更干净了。 第二步:发现即便使用协程,per-connection timer 仍然意味着每次 timer 到期时要走 scheduler 调度 + epoll_ctl。最终演化为"TcpServer 统一扫描"的设计——整个 server 只需要一个扫描协程,连接侧只写一个 atomic 值。 ...

May 12, 2026 · 2 min · 314 words