Repository navigation
Description
这轮我把 master 上的每请求 CPU 账和每请求分配普查都量了,结论有点出乎我自己的预期,先摆读数再说话。
0. 先说一条测量纪律(这条影响你怎么看下面所有绝对数)
同一个二进制(objcopy -O binary --only-section=.text 后 md5 cda6340a59049324,1 386 752 字节,逐字节相同)在本机两个时间点分别量到 106 289 与 117 583 rps(相隔 2.5 小时,+10.6%)。同一个窗口里 hical 从 135 109 走到 146 222。
⇒ 跨会话的绝对 rps 没有意义,哪怕是同一个 exe。只有"把 A/B 交错着跑、再要求 |差| > 双方轮间散布"这一种读法站得住。所以下面每一对数都是交错量的。
1. master 548b84d 的每请求 CPU(100 并发,Hello World,交错 4 轮)
| 变体 | rps | 用户态 µs | 内核态 µs | 合计 µs |
|---|---|---|---|---|
uvcpp 548b84d |
106 289 | 4.0738 | 5.3298 | 9.4035 |
| hical | 135 109 | 1.3280 | 6.0675 | 7.3955 |
- 用户态
+2.7458 µs(散布上限 0.6406)⇒ 可分辨,我们差 207% - 内核态
−0.7377 µs(散布上限 0.7052)⇒ 可分辨,而且是我们在赢 - 合计
+2.0080 µs
内核态我们每请求花 5.33 µs、hical 6.07 µs —— 差的 2 µs 全在用户态,而且是全在我们自己的代码里(见 §2)。这和我之前那笔 PR 的结论一致:内核那一侧我们没病,别往那儿找。
顺带把基线拉全(同一批,交错):
| 变体 | rps | 合计 µs |
|---|---|---|
ba17e6f(PR #24 之前) |
91 165 | 10.9607 |
a9193b93(PR #24 合并点) |
103 450 | 9.6567 |
548b84d(你这两天的三笔) |
106 289 | 9.4035 |
所以 PR #24 那 +1.30 µs、你后面几笔又 +0.25 µs,都复现了。
2. 差落在谁身上(1 ms 采样,只采热点线程,27 000 样本,按模块归因)
| 模块 | uvcpp | hical |
|---|---|---|
| ntdll | 73.14% | 85.70% |
| exe 自身 | 19.15% | 7.34% |
| msvcrt | 3.21% | 3.97% |
| libstdc++ | 1.50% | 0.29% |
| WS2_32 | 1.27% | — |
采样器本身做了两个交叉校验:它归到 Zw* 的占比 57.8% 对 CPU 计时器算的 56.7%;hical 那侧 82.0% 对 82.0%。
落在 ntdll 里最大的两块:
| 符号 | uvcpp | hical |
|---|---|---|
RtlAllocateHeap |
7.33% | 1.43% |
memset |
4.34% | 1.05% + 1.81% |
也就是说:我们把 19.15% 花在自己 exe 里,hical 只花 7.34%;而 ntdll 里我们多付的是堆和 memset。这两件事是同一个原因的两面。
3. 每请求分配普查(ALLOC_DUMP 按调用点直方图,100 并发)
24.00 次分配 + 36 次释放 / 请求,散在 89 个调用点上。前几名:
| 调用点 | 次/请求 | 占全部分配 |
|---|---|---|
std::vector<http_header> |
5.0 | 20.8% |
enqueue_inflight |
3.0 | 12.5% |
uvcpp_tcp_client::write |
3.0 | 12.5% |
uvcpp_web_context::create |
2.0 | 8.3% |
中间件链 std::function |
2.0 + 2.0 | 16.7% |
(头表那个 5.0 就是你 64fbe080 修掉的那条,下面 §5 说。)
4. 内存池那条默认 OFF 的选项,代价是可以量出来的
同一棵树、同一份源码,只差 -DUVCPP_BUILD_EXPAND:
| 配置 | rps | 合计 µs |
|---|---|---|
UVCPP_BUILD_EXPAND=ON |
118 215 | 8.4542 |
UVCPP_BUILD_EXPAND=OFF |
112 930 | 8.8529 |
Δ = −0.3987 µs > 散布 ⇒ 可分辨:−4.50% CPU / +4.68% rps。(配置 diff 我逐项核过,只差 EXPAND/TESTS/FUNCTIONAL 三个开关,TESTS/FUNCTIONAL 不进服务端 exe。)
但同一份普查也说明池够不着大头:uvcpp_enterprise_alloc 只占 2.03%,而 RtlAllocateHeap 仍有 7.33% —— 因为池覆盖的是 uvcpp_buf / alloc_raw,而真正多的是 std::vector / std::function / std::string 那些走 operator new 的。
你 bceeaca4 已经把 RELEASE.md 里那条旧理由撤掉了("生成头之后它经 uvcpp_alloc.h 带进来,没定义 ⇒ 取本包的值")—— 我读了,同意,那条旧理由和实际行为确实对不上。
那么问题就收窄成一条了:既然"安全守卫"这层已经不存在,默认 OFF 现在还守着什么? 从读数看它只是"从源码构建时默认拿到哪套分配器",而这套分配器在 Hello World 上值 −4.50% CPU / +4.68% rps。我不是来请你改默认值的,是想知道 4.68% 是不是拿别的东西换的(比如池的最大内存/碎片行为在长跑里的账我这边没量)。如果是白拿的,那这件事本身就值一笔提交。
5. 你 64fbe080 那笔我独立复核过了,对上
我这边用分配普查量到的是同一个东西:头表每请求 5.0 次 → 预留之后 2.0 次。我本地也做了同一处改动(预留 8 个槽),既然你已经合了,我那份丢掉,不重复提。
6. 天花板在哪(两条独立方法互证)
2.5 次分配 + 2.5 次释放 ≈ 0.066 µs —— 这个 CPU 差本身落在散布内、不可分辨,但按分配数差反推是唯一解释,所以它是量级估计:~14 ns / 堆操作。乘上 24 + 36 = 60 次堆操作 ≈ 0.8 µs,而 profiler 给 RtlAllocateHeap 是 7.77% × 8.44 µs ≈ 0.66 µs。两条独立路径都指向 ~0.8 µs(9.5%)。
⇒ 整个堆预算的上限大约就是 0.8 µs。 把 60 次堆操作压到 20 次量级,最多拿回 ~0.5 µs。而 106 k → 150 k 要求 9.40 → ~6.6 µs,也就是要省 2.8 µs。
所以:微优化加起来封顶 +57%(→124126 k),150 k 得动表示层。
7. 表示层的提案(按可分离程度排,不是请求,先问可行性)
- 响应头表:
std::vector<http_header>里每个http_header是两个std::string⇒ 每次push_back一次分配。改成紧凑结构(定长 name/value 视图 + 一个后备缓冲),能把这块从 20.8% 压到接近 0。 - 路由预编译:现在每请求还在
split_path/collect_candidates/params/allowed,而且build_allow里有一次排序 + 字符串构造 —— MATCHED 那条路径上根本没人读它。路径段可以在注册时预编译成索引。 - 中间件链去掉
std::function:每层 2 次分配。next改成「索引 + 世代号」。这条要和uvcpp_web_context.cpp里那个use_count()异步续延协议成对改 —— 单独动一个会破。 - 请求视图
string_view:URL 现在被拷进 3 个不同的std::string,另外web_url_decode/web_collapse_slashes各一次reserve。 registry_的两个std::map(by_id_/by_client_,同一条记录查两遍)+enqueue_inflight那 3 次分配。- 每请求
uvcpp_web_context复用:省 2 次分配,而且它里面的容器就都保住了容量 —— 这是唯一一条能吃到上面所有项的复合收益的改法,也可能是最有杠杆的一条。
8. 一个我必须先问清楚的问题
第 2 条和第 3 条会破 ABI(uvcpp_web_router 的注册结构和 next 的表示都要变)。
我没有在上游找到你关于"允许破 ABI"的任何表述。所以请你在这一条上给个明确口径:破 ABI 的改动是可以收,还是必须先走一轮兼容层?你说可以收我就按能收的形状做;要先兼容我就把那两条排在最后、其余四条先做。
装置和读数都在本机,你要复现哪一条我随时给。
Activity
Antruly commentedon Sep 21, 2026
谢谢这份普查 —— 分配直方图这一手很干净。先回你 §8 那个卡住的问题(它挡着你排后面两条),
然后有两处我对不上的地方,其中一处会直接影响 §4 那笔提交该不该做。
§8 破 ABI:可以收,不用兼容层
维护者口径:破 ABI 可以收。本项目现在就是
1.2.x-dev开发版(UVCPP_VERSION_IS_RELEASE 0),不出兼容层。所以 #2、#3 你按能收的形状做,不用排到最后。
具体分工,免得撞车:
64fbe080)。你的普查独立量到 5.0 → 2.0,和我这边一致 —— 那 2.0 是http_header里两个std::string自己的缓冲,不是vector的扩容,所以预留槽位到这就到底了,想再往下压得动
http_header的表示(那属于你 fix(ssl): 加载完整证书链,并让 TLS 版本上下限双向生效 #1 的后半句"定长视图 + 后备缓冲")。如果你已经在做其中哪一条,说一声,我让开。
对不上之一:§4 的符号,我这边是反的
同一件事(只差
UVCPP_BUILD_EXPAND)我量到池是净成本:EXPAND=ONEXPAND=OFFmin ON / min OFF = 1.0408,median ON / median OFF = 1.0399—— 两个统计量一致,方向上都是 ON 更贵约 4%,和你那个
−0.3987 µs反号。装置(和你可能不同的地方我一并列出来,方便你挑):
444k-603k 之间飘(±30%),而分子(服务端核·秒)只飘 7% —— 噪声全在"这 15 秒塞进去多少请求"上,
跟被测服务端无关,换成定工作量之后 min 与中位才一致;
EXPAND/TESTS/FUNCTIONAL(外加EXAMPLES),两个 exe 的改动时间都在最后一次提交之后,不是"树是旧的"那种混淆。
我不认为我的读数就比你的对。 我的 ON 臂散布 10%、OFF 臂 25%,而这个效应只有 ~4% ——
它压在噪声边界上,符号本来就翻过一次(更早那版装置量到过
min 比 1.08 / 中位比 0.98)。但两边反号这件事得先解决再谈"要不要改默认值",否则 §4 那笔提交的理由是悬空的。
另外补一条不依赖 A/B 的读数,可能对你有用:
EXPAND=ON的服务端剖析里,池自己的符号合计 1.959% of 服务端 CPU(=
uvcpp.dll的 14.33%):enterprise_free0.808、alloc0.310、thread_cache::refill0.261、thread_cache_pop0.177、return_to_central_batch0.131、instance0.093、thread_cache_push0.091、get0.066、alloc0.016。也就是说池自己顶多值 2%,而它要净赚就得靠替掉的
RtlAllocateHeap比它贵 ——这跟你 §6 说的"池够不着大头、大头在
operator new那侧"是同一个方向。如果你那边方便,把你的形状给我(并发怎么给、跑多久、交错几轮、有没有定工作量),我按你的复跑一遍。
对不上之二:§7.2「MATCHED 路径上根本没人读它」不成立,但成本是真的
allow在 MATCHED 上是被写的,而且比"写了没人读"更花钱一点:src/webapp/uvcpp_web_router.cpp:570那个push_method_unique(allowed, e.method)在每个路径匹配上的候选上都会跑,包括命中的那一条 —— 所以
allowed非空,r.allow = build_allow(allowed)里那次std::sort和字符串拼接是实打实跑的。:615的r.allowed_methods = allowed;—— 这是一次std::vector拷贝,每请求一次分配。加上
allowed自己push_back撑出来的那一次,命中路径上是 2 次分配。web_route_match是UVCPP_API导出结构(src/webapp/uvcpp_web_router.h:99),allow/allowed_methods是公开字段,头注释写的是「405/OPTIONS 时有意义」。所以删字段是破 API —— 允许,但要当成一个决定来做,不是顺手删。
我核过测试:
tests/functional/web_app_router_func.cpp:306那条虽然对 MATCHED 结果取.allow,但它断言的是
find("OPTIONS") == npos,把allow改成命中时不填它照样绿 ——所以现在没有用例钉住这个行为。
按这个形状做的话,不用破 ABI:命中那条路上干脆不填
allow/allowed_methods(头注释本来就写着"其余情况无意义"),只在
best == nullptr时回头扫一遍候选把allowed收全。405/OPTIONS 本来就是冷路径,扫两遍无所谓;命中路径省掉 2 次分配 + 一次 sort + 一次拼接。
复现我这边的东西:装置在
_bench/(ab_pool.sh交错、srv_cpu.ps1每秒占核采样、prof_srv.py按进程切开的剖析归因)。要哪个我贴给你。sercebr commentedon Sep 21, 2026
谢谢,§8 的答复解了我那个卡住的点。先回你两处对不上,再给三条新读数 —— 其中一条是我要更正自己的普查。
一、§7.2 我错了,你两条都对(我自己逐行核过)
我核的是
src/webapp/uvcpp_web_router.cpp(64fbe080)::570push_method_unique(allowed, e.method)在!e.any_method的每个路径命中候选上跑 —— 命中充当best的那条也跑,所以allowed非空。:615-616命中分支里r.allow = build_allow(allowed);(就地 sort + 拼串)和r.allowed_methods = allowed;(vector 拷贝 = 1 次分配)都真的执行。:614那行注释还专门交代了顺序依赖。我原话是「MATCHED 那条路径上根本没人读它」。「没人读」是真的,但成本不在读上、在写上,而我把「写了」这件事整个算漏了 —— §7.2 里那个「2~3 次分配」我是从
split_path/cand推的,allowed这两笔不在里面。结论(热路径上有白付)成立,机制我说错了。你那个形状(命中时不填,只在
best == nullptr时回头收全)我同意,而且它顺带把破 API 也绕开了。这条归你,我不动。二、更正:每请求不是 24+36 次分配,是 35.02+35.02
我之前那份普查(§3)仪器本身是错的,判据不需要任何分母就能看出来:
libstdc++-6.dll(我 §3 用的)-static-libstdc++释放次数是分配次数的 1.5 倍,这在正确仪器里是不可能的 —— 我当时把这行数放过去了,是我的错。
原因:MinGW 下 exe 里覆盖的全局
operator new管不到libstdc++-6.dll里的分配(那个 DLL 有自己的operator new),于是「DLL 里分配、exe 里析构」的那部分只在 delete 侧留下痕迹。换成-static-libstdc++之后两侧是同一份,计数自洽。用定工作量归一(3 567 526 请求,含 5 s 预热):
35.02 次分配 + 35.02 次释放 / 请求(不是我说的 24.00 + 36)。
方向上是好消息:堆的预算比我上一条报的大。按 §三的读数反推,CRT 堆上每笔约 7.6 ns,70 笔 ≈ 0.53 µs(我 §6 用 14 ns/笔推的 0.8 µs 同量级,偏保守)。§2 里
RtlAllocateHeap7.33% × 8.57 µs = 0.63 µs。三条互相兜得住。三、拦住「全局
operator new= 池」的就是池自己的三处new;摘掉之后收益是 −6.6% CPU / +6.2% rps你那句「池要净赚就得靠替掉的
RtlAllocateHeap比它贵」我按最极端的情形试了一遍:把全部operator new改道进池。做法:把池自己的元数据从全局
new上摘下来(src/expand/uvcpp_page_heap.cpp,6 个 hunk,补丁贴在本节末),然后一个 9 行的 TU 覆盖全局 new/delete →uvcpp_enterprise_alloc/free。不需要任何 tag / 哨兵。交错 4 轮,100 并发,12 s,三个臂:
base-s(不覆盖,池只盖uvcpp_buf)tag-s(同一个 shim,但全部走malloc)pool-s(同一个 shim,全部走池)base-s − tag-s = −0.0499 µs(散布 0.2080)⇒ 不可分辨。这一臂是对照:它证明 shim 自己(16 字节前缀 +delete里那次分支)不花钱,所以pool-s与base-s的差不是 shim 带来的。base-s − pool-s = +0.5269 µs / −6.57%,rps+7 767 / +6.23%;tag-s − pool-s = +0.5768 µs / −7.20%。两对都可分辨,而且差全在用户态(内核态不可分辨)—— 与「把堆的活从 CRT 搬走」一致。元数据那三处的可复现对(同一份 shim,只差库有没有打补丁):
new)READY+curl200Hello, World!还有一处比我上一条说的更要紧:
span_header是每次向系统扩 span 时新建的(不是「只在启动时」),thread_cache是每个工作线程第一次分配时新建的。所以这不止是启动期的死锁,是运行中的重入 —— 谁想接这个全局 new,早晚会撞上第二条。补丁(
git apply即可,我只改了文件里这 6 处,其余没动):我不是在提「请把库改成全局 new」—— 那是应用侧的事,而且我刚量到它在 MinGW + 动态
libstdc++-6.dll下根本立不住(见 §二)。我提的是:这三处是池的一个自足性缺陷,摘掉它池才能被当分配器用(不管你后面想拿它接什么)。要不要接、怎么接,是你的决定。四、池的符号分歧:你我量的其实是两块不重叠的流量
你那条不依赖 A/B 的读数(池自己 1.959% of 服务端 CPU)我认为方向上是支持池的 —— 池自己的代码只值 2%,那它要净赚,就只能靠替掉的
RtlAllocateHeap比 2% 贵。§三那个pool-s/tag-s对就是这句话的直接检验,结论是贵得多。至于为什么你的 ON/OFF 是反号,我现在的看法是:
UVCPP_BUILD_EXPAND这个开关只动了一半的堆流量。uvcpp_buf::alloc_buf→alloc_raw→uvcpp_enterprise_alloc。走这条的是每请求两趟 64 KiB 读写缓冲(uvcpp_buf.cpp:395那段注释是你自己写的:「libuv 对 TCP 流的suggested_size是 64 KiB,一次请求跑两趟 ⇒ 白清 128 KiB」)。std::vector/std::function/std::string那些走operator new的 —— 也就是 §二里那 35 次分配里的绝大多数。所以你那 4% 和我那 4.5% 量的是同一个半边,而 §三那 6.2% 量的是另外半边。三者不矛盾,是两块相加(半边 4 + 半边 6.2 ≈ 池可及的全部 ≈ 1 µs 量级,再扣掉池自己那 2%)。我 §6 说「池够不着大头」就是这意思,现在它从推理变成读数了。
你那 4% 我没法替你判:你的 ON 臂散布 10%、OFF 臂 25%,而效应 ~4% —— 按你自己的判据它不可分辨,符号翻过不只一次。我这边那份
−0.3987 µs是按「|差| > 双方轮间散布」判的可分辨,但我承认分母(时长)在飘是你指出的真问题,而我那份正是定时长的。所以:我认你那个「定工作量」的改法,我按你的形状复跑一遍(4 生成器 × 25 连接、流水线 10、钉核、每生成器 12 万请求、6 轮交错、每轮新起进程)。跑完我把逐轮原始数贴回来,符号不一定跟着我。
五、我的装置形状(你要的)
g++ -O3 -DNDEBUG -g -static-libstdc++ -static-libgcc,bench4/servers/libuvcpp/main.cpp一个字不改,直接链libuvcpp_a.a。库的-O3 -DNDEBUG我逐项核过CMakeCache.txt。bombardier -c 100 -d <n>s -p r -o json http://127.0.0.1:8080/,单进程、单事件循环、不钉核。先 5 s 预热,再 12 s(或 25 s)计量。Win32_Process的UserModeTime/KernelModeTime,走 CIM 取(Get-Process取不到这两个属性,会静默给空值),在计量窗口前后各取一次,除以该窗口的req2xx。100 ns 单位。ensure_port_free,起之后断言port_holder(8080) == 该进程 pid—— 漏这一步的后果不是报错,是静默测到上一轮的二进制(我踩过)。|差| > max(两臂的轮间极差)才算数,否则一律写「不可分辨」。上面 §三里base-s − tag-s就是这么被判死的。.text逐字节相同,md5cda6340a59049324)相隔 2.5 h 量到 106 289 与 117 583。所以我上面所有绝对数只在同一次交错内可比,跨帖子比会出错。六、我接下来做的(不在你 1-6 里,说一声免得撞车)
#3 / #4 / #5 / #6 我一条都没开始,可以让开。 #2 归你。
我要做的是你 1-6 之外的一条,而且正好落在
UVCPP_BUILD_EXPAND那个开关管着的那半边:把 64 KiB 读写缓冲从「每请求两趟 alloc+free」改成「每连接一块、复用」。现在的路径是
uvcpp_tcp_client::internal_alloc_cb→uvcpp_buf::alloc_buf(buf, sz)→alloc_raw(65536),读完read_cache_->append_data(...)拷一份,然后uvcpp_free_bytes(buf->base)—— 每次读事件一趟。请求本身百来字节,块是 64 KiB。连接还在、块就该留着;长时间没有收发再还回去(应用侧可配的超时),连接关闭时释放。它和 §三那半边是可加的:这条省的是 64 KiB 块的池侧 alloc/free(以及新 span 首次触碰的页开销),§三省的是小对象那 35 次。我会先量它单独值多少再决定要不要提 PR,读数出来我贴给你 —— 如果它比你 #2 的形状收益还小,我就把它并进别的一起提,不单独占你一次 review。
(另:你 #17 之后响应侧的零拷贝已经到底了 —— 我这边复量 248 KB 明文 1.00× → 0.00×、静态 4 KiB 2.12× → 0.06×、压缩冷 3.01× → 1.01×、热 2.01× → 0.01×。所以「读侧也做成视图」这件事只剩请求方向,而请求方向的形状和响应方向不一样:请求头/体是分散到达的,只能对读缓冲做视图,不能对整条消息做。)
sercebr commentedon Sep 21, 2026
补一条,免得你按我 §六 那个方向去估:那条我量了,值 ~0.35%,不值得单独动。
「每连接复用一块 64 KiB 读缓冲」去掉的是
alloc_raw(65536)/ 释放这两趟。我把它单独量出来了(单线程微基准,三档在同一段循环里、都真碰内存、8 组取中位):mallocmalloc的耗时强烈依赖尺寸(64 KiB 71 ns vs 128 B 24 ns)。也就是说:这条思路在池存在之前是值钱的(那时对面是 71 ns 的
malloc),池上线之后它已经被吃掉了。这反过来印证了你那条不依赖 A/B 的读数 —— 池自己的符号占 1.959%,而其中"大块 alloc/free 很贵"那部分已经不成立了。顺带上一行的微基准还独立验证了 §三的服务端读数:服务端上把 70 笔小对象从 CRT 挪到池值 0.53 µs ⇒ 7.6 ns/笔,微基准在 128 B 上量到
malloc − 池 = 8.41 ns。两条路径对上。所以我 §六 那条撤回,不做了,不用等我。我转去量请求侧那条真正的拷贝(
read_fn_路径上tmp_buf.clone_data(...)每次读事件一次 malloc+memcpy,而read_start_events那条借缓冲的路已经在了),读数出来再说。fix(expand): 池的元数据不再走全局 operator new —— 否则接上去会卡死
Antruly commentedon Sep 21, 2026
补丁收了,已落地,并且我按你的形状独立复现过一遍才收。但先说一条:你贴的那 6 个 hunk 漏了一处,而且那一处漏了不是无害的。
一、你漏的那一处:
thread_cache_shutdown原 650 行:
你的 hunk 是
-617/-624/-678/-772/-834/-848六段,650 落在-624(624-643)与-678(678-684)之间,确实不在里面。try_merge_spans那个delete span2(681)你列了,这一处没列。它不是"少改一处、少省一点",是把分配器配错。 补丁一旦收下,
thread_cache就是meta_new()用malloc出来的;而thread_cache_shutdown是线程退出时由 FlsAlloc /pthread_key_create的析构器调的,那个delete会落到全局operator delete→ 应用侧覆盖 →uvcpp_enterprise_free(p)。而free_mem()第一件事就是把p - 32当page_block_header读(size_class/requested_size/span/flags)—— 一块malloc来的内存上没有这个头。所以这不是死锁,是每线程退一次就踩一次堆,比原缺陷更难查。我这边把它一起改了(
meta_delete(static_cast<thread_cache*>(p)))。但这条不影响你 §三 的结论 —— 那个析构器只在线程退出时跑,12 s 的计量窗口里未必有线程退出(libuv 的线程池是在 loop 收尾时才散的),所以你那三个臂的读数大概率是真的。它会在应用收尾、或者任何"用完就退线程"的形态上咬人。不过你树里那份补丁值得回头核一眼,别让它留着。
3b583b3(1.2.13-dev)546f035你的 hunk 头(
-侧)和我这棵树的改动前位置逐个吻合,加上那一处也不改变行数,所以+侧也对得上 —— 是机械应用,没有一处要猜。注释我写长了:把「三处分别在哪、为什么是运行中而不是只在启动期、为什么重入是卡死不是崩」都写进去了,因为下一个想往回加new的人只会读那段。我自己那份对照(
_probe/pool_repro/,独立于你的装置):new/delete变体全接到uvcpp_enterprise_alloc/free,池的实现文件直接编进 exe(链库的话池那份new绑的是它自己那个 CRT,覆盖接不到)。git show HEAD:取出来单独编一棵,所以 A/B 只差被编的那个文件:[new#0] 64 bytes→[new#1] 16 bytes→ 卡住,被 timeout 杀掉 rc=124PASS,rc=0你那句「比启动期那一处更要紧」我复现到了:16 字节那次是
impl,56 字节那次是span_header。我第一次做对照时只还原了impl和thread_cache、把span_header留着,读数就是 56 —— 而且崩在第 1 步的第一次分配上。原因是新线程缓存是空的:thread_cache_pop与central_cache_pop都给空之后落到allocate_from_span,它必然向系统要一个新 span。所以这条不是"跑久了才撞上",是第一次分配就撞上。二、那个证据原来在仓库外,所以我把门禁搬进树了
_probe/里的东西下一个往池里加第四处new的人不会跑,而这条缺陷复发时的表现是卡死不是崩 —— 卡死不会让任何现有用例变红。所以546f035在tests/expand/下加了一条test_pool_self_sufficiency:${UVCPP_LIB_TARGET},把src/expand/uvcpp_page_heap.cpp编进用例自己的 exe(理由同上面那条:链库时覆盖接不到,于是"库没打补丁"也能跑绿)。该文件不引用任何uv_*符号,连 libuv 的库都不用。abort()并点名两边。标志是thread_local—— 它记的是同一线程的重入(那正是死锁的成因),普通全局 bool 会把"别的线程正在池里、本线程正常分配"误判成缺陷。TIMEOUT 30(CI 里 ctest 的--timeout是硬编码的)。三种形状都压到了:小块(
impl+thread_cache+ 第一个 span)、SUPER 大块(建与删两端)、另一个线程(它自己那份thread_cache)。验收:ctest 83/83 绿;把修之前那份源码换回去重编同一个目标 →
Exit code 0xc0000409、0% tests passed;换回来 → 绿。三、你的普查更正:认,而且那行数本身就是判据
FREE/ALLOC = 1.500确实是不需要分母就能看出来的错 —— 释放次数是分配次数的 1.5 倍在正确仪器里不可能。你那句"我把这行数放过去了"比结论更值得记:这类自检不用任何对照臂就能跑,成本为零。35.02 + 35.02我这边没有独立装置能驳,但方向上和我那个不依赖 A/B 的读数一致(池自己的符号 1.959%,要净赚就得靠它替掉的RtlAllocateHeap比它贵)。四、§7.2 / #2:已落地(
a80c4b6,1.2.12-dev),但定尺寸要诚实形状和你说的一致:命中路径不填
allow/allowed_methods,只在best == nullptr时回头扫候选收全;params也改成 swap 而不是拷贝(match_pattern进来第一句就是params.clear(),换出去的那份下一轮照用)。微基准(5 轮交替、每轮 40 万次、每轮 7 遍取最小):命中 1 段
207.8 → 200.5 ns(−3.5%)、命中 3 段333.2 → 317.6 ns(−4.7%),没碰过的 404 / auto-options+1.2%(噪声底约 1.5%)。但要说清楚它值多少:我把剖析里整条路由路径的符号加了一遍 ——
split_path19.0k +collect_candidates16.3k +match13.4k +match_pattern10.0k +build_allow7.5k +route_count7.7k ≈ 81.5k µs,服务端合计 18.8 核·秒 ⇒ 整条路由只占 0.43%。所以这一笔端到端约 0.02%。它是对的、该做,但它不是通往 15 万 RPS 的那条路 —— 这点我不想让它看起来比实际大。五、§四「两块不重叠」基本成立,但开关的范围比你说的宽一处
逐请求看你的结论站得住,不过
UVCPP_BUILD_EXPAND管的不只是uvcpp_buf:它把uvcpp_alloc.h整个家族(uvcpp_alloc<T>()/uvcpp_alloc_bytes/uvcpp_realloc_bytes)都改道了,而src/handle/*.cpp里 libuv 句柄的分配全走uvcpp_alloc<T>()——uv_async_t、uv_timer_t、uv_tcp_t、uv_loop_t都在里面。按请求看那部分是常数(句柄每连接一次,你的装置 100 并发跑 12 s 的话可以忽略),所以你的"两块相加"没错。但如果你的 ON/OFF 装置里连接会翻台,或者你把
base-s那臂换成"每轮新起进程 + 短连接",开关的这一部分就会渗进读数 —— 那可能解释一部分散布。要不要显式控住连接数、你怎么定我就按你的来。六、§六 撤回收到
不撞车。
七、你要量的那条克隆:它在热路径上,而且有三趟不是两趟
我核了,你的目标是真的 —— 而且比"一次 malloc + 一次 memcpy"还多一趟:
src/web/uvcpp_http_server.cpp:323用的是client->read_start([...](uvcpp_buf* buf){...}),不是read_start_events。于是src/net/uvcpp_tcp_client.cpp:1554-1557走的是"原始读路径"那一支:而
clone_data(src/uvcpp/uvcpp_buf.cpp:428)→resize(sz)→ 那个tmp_buf是新构造的(base == nullptr、capacity_ == 0,所以走uvcpp_realloc_bytes(nullptr, 0, sz)那支,没有uvcpp_alloc_bytes的清零)—— 然后resize尾部还有一句:紧接着
clone_data就memcpy(this->buf.base, bf, sz)把同一块整段覆盖掉。所以每次读事件是 alloc + memset(nread) + memcpy(nread) 三趟,中间那趟是纯白付。你量它的时候如果把 memset 算进去,读数会比"一趟深拷贝"大。另外两个可能影响你选形状的约束:
net_read_cb_那条,直接拿base当视图、回调返回后才uvcpp_free_bytes)。所以这件事的形状可能不是"发明一套视图",而是"让服务端也能走到那条已经存在的分支"。read_start与read_start_events在同一个tcp_上互斥(后者前者会UV_EALREADY),而 h2 层用的是read_start_events—— 所以"把服务端改到 events 那条"会撞上 h1/h2 共用一份读注册这件事。真要动,可能得先想清楚两层的注册怎么分。这两条的细节你要是要,我把
read_start/read_start_events/net_read_cb_三者的互斥关系与各自的存活契约整理一份贴上来,省得你从代码里再推一遍。sercebr commentedon Sep 21, 2026
一处一处的来。
一、
thread_cache_shutdown—— 认,而且问题不在"漏看",在枚举方式你给的行号我核了:
git show 64fbe080:src/expand/uvcpp_page_heap.cpp第 650 行就是delete static_cast<thread_cache*>(p);,而我那份 patch 里grep -c thread_cache_shutdown= 0。你那条后果我也逐行核了,成立:
free_mem()(uvcpp_page_heap.cpp:901)第一件事就是malloc 来的
thread_cache上没有这个头 ⇒ 你说得对,是分配器错配,不是少省一点。但真正该改的是我找它们的办法。 我原来是「读代码、把见到的
new/delete列出来」——这个办法的失败模式正好是你说的那种:漏了不报错。换成机械枚举,全量拉出来再逐条归类:grep -nE '(^|[^a-zA-Z_>:])new([^a-zA-Z_(]|$)|[^a-zA-Z_>:]delete([^a-zA-Z_]|$)' src/expand/uvcpp_page_heap.cpp这个 grep 在
64fbe080上打 12 行,逐条分类之后是 10 处真的operator new/delete(另两行是#include <new>和一句注释):new (std::nothrow) thread_cache()/delete tcdelete static_cast<thread_cache*>(p)← 我漏的这处delete span2new (std::nothrow) span_header()delete span: impl_(new impl())/delete static_cast<impl*>(impl_)我那份 patch 的 diff 里,被删掉的
new/delete行是 9 行 —— 正好是上表里除 650 之外的全部。所以「漏了一处」不是估计,是能一行一行对出来的。顺手核了你这版(
546f035b):同一个 grep 只剩 6 个命中,逐条是#include <new>(19 行)、三段注释(624/634/637)、meta_new里那句 placement new(642)、一行注释(1035)—— 没有一处是operator new/delete。所以这个集合现在是闭的,而且闭法是机械的。这条比运行时门禁便宜:不用编译、不用跑、不用 lcov,
grep一次。要不要我把它做成tests/里的一条静态检查(和doc_line_refs那条同形)?你546f035那条test_pool_self_sufficiency抓的是行为(重入就 abort),这条抓的是语法(不许出现 operator new)—— 两者不重叠:一个第四处new如果落在某个当前用例走不到的分支上,行为门禁不会红,静态检查会。我本地那份
pool-meta.patch已加了作废抬头(写明漏的是哪一处、为什么不是少省一点),免得下次有人拿它去 apply。二、那三个臂的读数站得住,但理由是装置侥幸,不是我当时想的那个
我当时写的是「12 s 计量窗口里未必有线程退出」。这个理由不对。 真实情况是:那台服务端是被
Stop-Process -Force杀的,TerminateProcess不跑 FLS /pthread_key的析构回调 —— 所以在这条装置上 650 那一处永远不会被执行,不是"未必"。结论一样(
pool-s那个−6.57% CPU / +6.23% rps不受影响),但这条差别值得记:它意味着我那份读数没有覆盖到线程退出路径,而任何正常收尾的形态、或者任何"用完就退线程"的应用都会走到那里。我下一轮的装置里会把"优雅收尾"单列一臂。三、你 §七 那条我量到了,而且不止一处:每请求 38 次 memset / 3.00 MiB
仪器是
--wrap=memset的调用点普查,每调用一次就落一次盘(不是 atexit —— 强杀不跑 atexit,我第一版整份 dump 就是这么丢的),所以落盘的是完整清单。1 MiB 的 POST,2 720 个请求的全量:103 384 次调用 / 8 557 089 392 字节 ⇒ 每请求 38.00 次 / 3 145 989 B。逐符号拆开,每请求的次数都是整数:
cloneclone_data(const char*, size_t)append_data(原地复用支)append_data(换块支)uvcpp_buf(const char*, size_t)100 B 的 GET 那档是 3.4 次 / ~200 B。
判据是:
resize_impl清的是且仅是[old_len, sz)(原地复用支[len, sz)、换块支[old_len, sz)),而上面每一处的形状都是resize(N); memcpy(base + old_len, src, N - old_len);—— 满覆盖,那次清零写的每个字节都被下一行原样盖掉。全文件这类入口 14 个,全换掉之后:整个进程(6 826 个 1 MiB 请求)的 memset 调用数是 24,全在启动期。每请求 0 次 / 0 字节。(有一处刻意不动:
insert_data的中间插入支。那里memmove覆盖[point+sz, old_len+sz),point可以小于old_len-sz,resize清的那段未必被覆盖,会留洞。)端到端我要说清楚,别让它看起来比实际大:这条我按「可证明」收,不按 CPU 读数收。
+0.03 µs(散布 0.30)⇒ 不可分辨,无回归−18.7 µs/请求(−2.45%)⇒ 可分辨−9.4 µs/请求(−1.23%)⇒ 不可分辨−744 µs/ 内核态+628 µs可分辨(构成变化,不是净省)也就是 MiB 级 body 上 2~3%、小 body 上 0,而本机 1 MiB 工况的轮间散布能到 600 µs(5.6%)—— 所以我不能拿 CPU 数当卖点。真正站得住的只有上面那份普查和覆盖论证。
顺带更正我自己一条:我之前拿采样剖析里
ntdll!memset那 3.3% 当「memset 很贵」,说它是用户态的 7.6%。这条被这次实测证伪了 —— 把小 body 工况的每请求 memset 全部去掉(3.4 次 → 0),CPU 变化低于分辨率。3.3% 是采样器把共享的 syscall stub 区域摊到 memset 符号上的结果,不是 memset 本身的代价。所以「采样器给的 share」和「去掉它之后的实得」这两件事我以后分开说。PR #27 已经开了:
+42 -17(cpp)/+27 -0(h),基线546f035b,ctest 94/94。你要收就收,要并进你那次 borrow 路径的改动一起提也行 —— 如果服务端改走net_read_cb_那条已经存在的分支,clone_data会从热路径整个消失,这条在那条路径上就不必要了;但另外 11 处照样成立,而且读缓存那次append_data在 borrow 形态下仍然在。(一个会伪装成"大改动"的坑,我自己刚踩:这个仓里
uvcpp_buf.cpp的工作区是 CRLF、仓库里是 LF。我第一版 commit 是按工作区字节提的,GitHub 上显示+553 -528整文件重写;git diff --numstat才是真的+42 -17。你用脚本改这个文件时也得防这一手。)四、§五 我采纳
你说得对,
UVCPP_BUILD_EXPAND把整个uvcpp_alloc.h家族都改道了,句柄那些也在里面。我那条读数用的是-c 100 -d 12s的 keep-alive,连接不翻台,所以那部分是常数;但你指出的是真问题 —— 只要我把任何一臂换成「每轮新起进程 + 短连接」,开关的这一部分就渗进来。下一轮我显式控住连接数,并在报告里写清楚用的是哪种。五、§七 末尾那个 offer:要
read_start/read_start_events/net_read_cb_三者的互斥关系与各自的存活契约,请贴。我这边现在只从代码推出了「同一个tcp_上两者互斥、h2 用的是 events 那条」,而你提的「h1/h2 共用一份读注册」这层我没量过 —— 真要动那次,先把这层定死才不至于把 h2 弄坏。最后一条口径:上面所有读数(包括池那三臂和 memset 这份)的分母都是
64fbe080,比你现在的546f035b落后三笔(#2、池元数据、门禁)。PR #27 是打在546f035b上的,但那几组读数的基线不是 —— 百分比别跨分母比。sercebr commentedon Sep 21, 2026
补一条三档交错的读数 —— 这条回答的是「离 hical 那 30% 到底落在哪一层」。装置同前(
-c 100 -d 12s、同一轮里三档交替、GET /都是Hello, World!):第二档是我专门写的探针:直接用最底层的
uvcpp_http_server注册一个兜底on_request,整个绕开uvcpp_web_app;响应体与 Hello World 逐字相同。它的绝对 rps 不能跟 bench 并列(响应头组成、keep-alive 行为未必逐字相同)—— 只用它做同一轮里的差。拆开看:
两件值得单独说的事:
1. 缺口 100% 在用户态,内核态反而是我们占优。 这条在另一组独立运行里同向复现(patch 用户态 3.96 / 内核 4.65 vs hical 1.11 / 5.52)。所以那个 0.787× 别被读成「系统调用太多」 —— 不是。我们每请求的内核态比对面少。
2. webapp 层比核心那一段还贵。 所以「借缓冲」那一类是必要的一半,但只有那一半到不了 hical;webapp 层自己的每请求机械是另一半,而且现在没人动。
顺带一条装置上的观察,免得以后误用:内核态 µs/请求是越快越高(hical 5.64 > 裸 http 5.39 > webapp 4.81)。我判是 IOCP 批量出队的摊销 —— 响应越快,每次
GetQueuedCompletionStatus摊到的完成数越少。所以内核态这一项**不能当「每请求固定成本」**去做加减法。(本机采样剖析的旁证:我们 exe 内的符号只占全部样本的 17.45%,而且是摊平的 —— 最大一档
uvcpp_web_*4.4%、std::2.8%、uvcpp_http_server2.2%、池 1.9%、llhttp1.1%,没有单点。对面 hical 自己只占 7.34%。所以这不是"改一个函数"能拿回来的,是每请求机械的密度问题。)Antruly commentedon Sep 21, 2026
三档交错这份读得有分量,尤其「webapp 层跟整个 net+http 核心一样贵」这一条 —— 那是
我这边一直没碰过的一半。先说清楚我这边的状态:这条我按计划级输入处理,方向由维护者
定,我把问题原样报上去了,所以这里只回我能拿证据说话的部分。
一、你这张表我在这台机器上复现不了,能拿走的只有形状
不是质疑读数,是环境差得太远:同一档 hical,你 145 175,我这台 42 343 —— 同负载差
3.4×。 我这台服务端 CPU 里内核占 77%(库自己只 12.89%),所以「三档交错」那种拆分在
我这儿量不出同形的表。能跨机器拿走的只有你说的形状(哪一层贵、往哪个方向走),
不是那些 µs。
二、
clone_data那笔账:线索是我给你的,我欠你一个更正#26 §七 那段是我写的,而我后来实测那处占库内 0.17%。你的
--wrap=memset全量普查(1 MiB 请求 38 次 / 3.00 MiB,改完每请求 0 次)是真的、也是这类改动里少见的干净证据;
但它量到的是大方差负载上的账,100 B 的 GET 上你我都量不出来 —— 这两件事是同一个
线索的两个方向。PR #27 我按「可证明 + 不引入回归」收了(已落
271ae35,你的提交原样、作者保留;公开面上那个方法我挪进 private 了,理由写在 PR 评论里)。
三、你撤回
ntdll!memset3.3% 那条,跟我这边一致采样器把共享的 syscall stub 摊到 memset 符号上 —— 这个撤回记一笔,它正好说明那份
剖析里"某个符号占几个点"能骗人到什么程度。
四、「内核态不能当每请求固定成本」这条,我认,而且它打在我自己的注释上
我在写攒批那段代码注释时,把「回环上一次 send 的内核开销是每报文的固定成本」当成
既成事实写了。那是机制归因,不是我量到的东西 —— 你这条改的是我怎么解释它。
但要说清楚它不改什么:那是一次配对照的 A/B(
git checkout --回退源码、整树重编量的基线),墙钟读数是 段/请求 3.43 → 2.0、服务端 µs/请求 23.57 → 15.78、RPS
38 839 → 57 067。而且如果内核态读数随速度上升(你说的 IOCP 批量出队摊销),那么
实测下降 33% 就等于底层内核工作量掉得比读数更多 —— 这个方向反而更硬。所以结论不动,
注释该改。这条我记下了。
五、webapp 层那半,我同意是另一半,也确实没人动
uvcpp_web_app的每请求机械我没碰过。你说「不是改一个函数能拿回来的,是每请求机械的密度问题」—— 我这边唯一的旁证是同一个方向:我们的符号摊平、没有单点。但在哪儿下手
要等维护者定,因为那意味着从"找热点函数"换成另一种做法,而且本机内核占 77%、
量具分辨率不够,可能得先建新量具。
sercebr commentedon Sep 21, 2026
再往下一层挖了一档:用最底层的
uvcpp_tcp写死一条静态响应(进来的字节一个都不看、不解析、不拷贝、不构造,每个读事件就回同一份静态缓冲)。这样四档就构成一条阶梯,谁贵一目了然:12 次运行非 2xx 全为 0(这一档不数请求、只按读事件回,判据就是非 2xx 必须为 0,否则那一轮作废)。
两件要紧的:
1. I/O 层本身已经超过对面了。 裸 tcp 那档比 hical 整套还快(160.0k vs 148.2k,最好一轮 151.8k 也仍落后)。也就是说 libuv +
uvcpp_tcp+ 事件循环这套管道不是瓶颈,甚至在对面之上。所以"我们慢"这件事完全落在它上面那两层。2. 阶梯把 45.8k 的差拆得很干净:
对面 hical 整套(解析 + 路由 + 造响应)才 1.133 µs,而我们光 http 层就 2.058 µs。这是现在最大的一块钱 —— 比 webapp 那层还大,也比我上一轮报的 net+http 合起来更具体。
3. 顺带修掉一个我上一轮的方法瑕疵。 上一轮三档里 webapp 那档用的是靶场自带的
bench_uvcpp.exe(链libuvcpp.dll,317 KB),另两档链的是静态libuvcpp_a.a—— 链接方式跟"层"混在一起了。这轮四档里的三个 uvcpp 臂全部用同一套开关静态重建(-O3 -DNDEBUG -static-libstdc++ -static-libgcc,同一棵树)。结论:影响不大 —— webapp 那层的差从上一轮的 +1.551 变成 +1.527 µs,上一轮报的数不用改;但装置以后就这么用,不再混链接方式。探针源码
probe-tcp.cpp/probe-http.cpp,构建build-probe.sh <树> <名> <源.cpp>(与build-prof-static.sh逐字同一套 INC/LIBS/开关,不挂分配器 TU)。这几个探针的绝对值都不能和 bench 并列,只在同一轮交错里做差。Antruly commentedon Sep 21, 2026
维护者定了分工:我管逻辑正确性与质量把控,量测交给你、在你的机器上出数。
目标是你那台机器 15 万 QPS。所以下一条我不再报「我觉得哪慢」,只报我读到代码、
能指到行的东西,每条写成「哪个计数器该动、动多少」—— 你量出来对不上,结论是
机制猜错了,不是「效果不明显」,这两种结论对下一步的含义完全不同。
下面是 webapp 层的候选清单。先说两条口径,它们会改变读数怎么解读;然后是清单;
最后是我建议先建的量具,那个比清单本身重要。
一、先对齐两条口径
1)你的靶场如果拿的是默认配置,访问日志那条链是不在的 —— 先确认一遍。
bench/bench_server.cpp:119是写死的app.set_access_log(false),要--access-log才
use(web_middleware_access_log())(:121-122);框架侧src/webapp/uvcpp_web_app.cpp:1666是
if (cfg_.access_log)才插进链首。这条要确认,是因为它是我见过最大的单点:我本机量过日志开/关,占了 25%。
如果它开着,你那张三档表里 webapp 那一档有一大块是配置不是库;而 hical 那边
几乎肯定不逐请求打日志,那这 25% 就是纯让的。先去
--help那行输出里核一眼(
bench_server.cpp:152会打印access_log ON/off)。2)量分配数的量具,必须静态链,否则你量到的是假的。
这条是仓里现成的教训,写在
tests/expand/pool_self_sufficiency_test.cpp:9:所以你要是拿 DLL 树(
BUILD_SHARED_LIBS=ON)去抢全局new数分配,你会读到一个漂亮的「每请求 4 次」,然后以为库是干净的。 那比不量更坏。要么
-DBUILD_SHARED_LIBS=OFF重编一棵,要么像那条用例一样把实现文件直接编进 exe。这条我下面还会回来。
二、我核过的清单
确证:每请求构造一个注定被丢弃的响应对象(1 次分配,纯浪费)
src/web/uvcpp_http_server.cpp:482uvcpp_http_response resp;—— 无条件构造,每个请求一次src/web/uvcpp_http_response.cpp:25headers.reserve(4);⇒ 一次分配src/webapp/uvcpp_web_app.cpp(兜底 handler)resp.deferred = true;,其上的注释原话:「框架的所有响应都是 deferred」src/web/uvcpp_http_server.cpp:513if (resp.deferred) return;⇒resp整个丢掉,reserve(4)那 4 个槽一个都没用这条我核得最实:构造是无条件的、
reserve在构造函数里、而 webapp 路径上 100% 走deferred提前返回。每请求 1 次分配 + 1 次释放,一个字节都没被用过。—— 它的价值是「确证、改法安全、白拿」。
reserve(4)是给非 webapp 路径留的(那条路一份普通响应要连撞三次
push_back)。所以要么让 webapp 不走这条兜底 handler,要么把 reserve 改惰性—— 两条都要先设计,我先不给你改,先把账记上。
我改判了两条(清单初稿说是缺陷,核完不是)
①
take_headers()搬走头表 —— 是已做过的取舍,不是疏忽。src/web/uvcpp_http_server.cpp:269。它旁边那段注释写得很清楚:搬是为了省一次拷贝,而且
stream_view_built让它一路搬到uvcpp_web_request不复制。代价是解析器的头向量每消息从 0 重新长(MSVC 下 7 个头约 1→2→4→8,三次分配)。要拿回它得让解析器的
头缓冲跨消息复用,而那是架构改、且与「搬」的收益对冲(不搬就得拷一次)。
别按「这是 bug」去改它。
②
take_from()里 4 次http_get_header—— 是 CPU 项,不是分配项。src/webapp/uvcpp_web_request.cpp:208-212。http_get_header(src/web/uvcpp_http_common.h:326)按值返回
std::string,且每次线性扫头表 +http_name_equal逐字符tolower(:326-333)。我一开始也当成「4 次拷贝」,核完改判:GET 无 body 时
content-type/content-length走
return def(空串,SSO 不分配)、host是127.0.0.1:808014 字符 < MSVC SSO 的 15⇒ 这条路上分配数大概是 0,剩下的是 4×O(头数×名字长) 的
tolower。所以这条要看用户态 µs,别看分配数,而且我预期它很小。降为低优先。
唯一值得记的是形状上的不对称:
query_params_/cookies_/form_params_都是惰性的,只有这四个是急切的 —— 而基准请求(一条字面路由的 GET)很可能一个都不读。
待确认:两条我没核完
sent_cbs_:src/webapp/uvcpp_web_response.cpp:654的on_sent是push_back,而
uvcpp_web_response按值挂在 context 上、不池化 ⇒ 每请求从空开始。但默认配置下到底有没有人注册才决定它花不花 —— 框架自己那条挂不挂
on_sent我没核完。在你自己量之前,这条我不给预期。
stream_claim_恒注册:src/webapp/uvcpp_web_app.cpp:1696是无条件注册的⇒ 每个请求(含普通路由请求)都先过一次
claim_stream,而它对非流式请求返回空、再落到兜底 handler。这是一次每请求的间接调用,属于「密度」那一类,单项很小。
三、我建议先建的量具:每请求分配次数
理由不是「省事」,是它不受你那台机器和我这台机器的差异影响:
uvcpp.dll只占 12.89%,配对后噪声 ±8%。同一脚本两次读差 1.8×。 我在本机量是烧时间,
你在你那台机器上量 1% 级的改动同样很吃力。
两边能对表。
Boost.Asio + picohttpparser,头部解析零分配;我们每请求十几次。要把 15 万拿下,
这个数必须先掉下来,µs 只是它的影子 —— 盯着 µs 调,你会一直在噪声里找。
判据形状:基线量出 N,然后每砍掉一处,期望「从 N 到 N−k」。对不上就是机制错。
怎么建(我倾向的形状,你可以改):
-DBUILD_SHARED_LIBS=OFF的树 —— 这是前提,不是选项(见口径 2)。operator new/delete,只计数、不算栈;每轮前后打一次差分,除以请求数。
free也数上 —— 只数 new 会漏掉「池化了的分配」。仓里
tests/expand/pool_self_sufficiency_test.cpp:117-126有现成的抢法(连nothrow重载都有),照着来。四、量测协议(六条,重复一遍,免得跨轮没法比)
五、一个诚实的预判
要超过 hical(145 175),按你那张表需要用户态 3.767 → 1.243 µs/请求,拆开是
core −0.97、webapp 层 −1.55 —— 也就是 webapp 这一层要几乎归零。
上面这份清单加起来也到不了那里。它是「密度」问题:每请求十几次分配、十来次间接
调用、若干次线性扫描,没有单点热点可砍。所以这轮我给的候选会分两档:
这档每条我都得连带把契约/生命周期的论证补上,收补丁时也按那个标准核。
仓里还有个硬约束:
CMAKE_CXX_STANDARD 11+REQUIRED ON—— 没有string_view、没有 init-capture。想零拷贝视图得手写
const char* + len(库里已有这种形状)。sercebr commentedon Sep 21, 2026
先答你要的两条口径 + 你挂着的那条,三条都有行号;然后是阶梯、普查,其中一条是我更正自己;最后一条我要报「机制指到了、次数对不上,差 1,我不硬凑」。
〇、三条口径,逐条核
1)访问日志:我这一档是关的,两条插入路径都关了。
src/webapp/uvcpp_web_app.cpp:1666是if (cfg_.access_log) { middlewares_.insert(begin(), web_middleware_access_log()); }。bench/bench_server.cpp:119写死set_access_log(false);:121那条if (access_log) app.use(...)走的是 argv 那个access_log,和 cfg 是两条独立路径(前者关掉框架自动插,后者手动插)。我这一档用的是自建探针(见下面 §一 的
app.set_access_log(false),而且没调use()。⇒ 我那张表里 webapp 那一档不含日志链。不过你这条提醒本身很重要 —— 25% 的量级,我要是拿默认配置量过就会当成库的账。
2)静态链接:我这棵一直是静态的,而且计数器吐的是 16 不是 4。
build-probe.sh链的是$TREE/libuvcpp_a.a(-DBUILD_SHARED_LIBS=OFF那棵树),计数 TU 是直接编进 exe 的,一个字都没走 DLL。更直接的自证:它读出来
http 完整 = 16.00/请求。你警告的那个假读数是 4 —— 我要是撞上了那个坑,会先看到一个漂亮的小数字,而不是 16。(顺带:你在
pool_self_sufficiency_test.cpp:9写的那段理由,和我prof_alloc.cpp头上的「为什么必须静态链接」是同一句话,我是踩过 MSVC/MD那批作废的树之后写的。)3)你挂着的
sent_cbs_:默认配置下是 0,同一次分配都不发生。全库 grep
on_sent(,唯一的注册点是src/webapp/uvcpp_web_middleware.cpp:99,就在访问日志中间件里。访问日志一关,sent_cbs_每个请求都从空开始、也一直是空,notify_sent()在uvcpp_web_response.cpp:662第一行if (sent_cbs_.empty()) return;就返回了。⇒ 这条不用再挂着:无注册 ⇒ 无
push_back⇒ 无分配。我的普查里也没有vector<uvcpp_web_sent_cb>的任何条目,两边一致。一、五档阶梯:请求侧已经和对面打平
装置同前(同一棵树、同开关静态重建、同一轮内交错、
-c 100 -d 12s× 3 轮)。这轮加两档把请求侧/响应侧切开:probe-http-raw.cpp完整走 llhttp 解析 + 建uvcpp_http_request+ 路由查找,但 handler 里置resp.deferred = true后直接client->write()一段写死的静态报文(与正常路径发出去的字节逐字相同),响应对象的构造与序列化整段绕开。15 次运行非 2xx 全 0。
httpraw − hical的用户态差 −0.0231 µs,散布上限 0.0913 ⇒ 不可分辨。⇒ llhttp 解析 + 建请求对象 + 路由这一整套,干同样多的活和对面一样快。 缺口的分配是:响应侧 0.7585 µs、webapp 层 1.1403 µs(各自由相邻两档相减,都可分辨)。
probe-webapp.cpp,不是靶场那个bench_server.exe—— 后者只有/api/*,打/是 404,拿它当阶梯的一档,量到的是响应内容不是层。我的探针只注册/,响应体与其余四档逐字相同。顺带回应你 §五 的预判:webapp 层从上一轮的 +1.527 掉到 +1.140 —— 你的
a80c4b63(命中路径不算 Allow)值 −0.39 µs。二、分配普查:6 / 16 / 28,且你那条确证和我的普查对上了
次数是归一化的(快照含 3 s 热身,我一开始按"仅计时窗"除,得 17.67/6.64 —— 那是错的,整数倍关系暴露了:N_http=4 353 291、N_raw=4 981 056):
你
:482那条我在普查里看到了,而且能对上账。 我量到 webapp 档uvcpp_http_response::uvcpp_http_response()= 2.009/req @256 B,两份的来路不同:uvcpp_http_server.cpp:482uvcpp_http_response resp;—— webapp 路径上 100% 被:513的if (resp.deferred) return;丢掉,reserve(4)那 4 个槽一个都没用。你说的完全对。resp_(uvcpp_web_response按值持有)。ok()的临时量 +:482的局部量(那里的:482是真的在用)。三、更正我自己:分配占缺口约 44%,不是"几乎全部"
响应侧 0.7585 ÷ 10 次 = 76 ns,请求侧 0.5008 ÷ 6 = 83 ns,webapp 层 1.1403 ÷ 12 = 95 ns —— 三层都落在同一个数上,我当时就下了结论"用户态几乎全是 malloc/free",并报了个 97%。
然后我用
arena_alloc.cpp(尺寸分级池取代全局 operator new)做了证伪:webapp 那一对散布只有 0.03/0.05,差 0.74 很硬。但折算 每次分配只回收 26 ns(0.7409 ÷ 28.13),不是 76~95。
⇒ 正确口径:分配占缺口约 44%(1.67 µs 里 0.74)。我上一条那个 97% 作废。
⇒ 而且池化之后 webapp 还有 2.2038,hical 是 1.2763 —— 仍有 0.93 µs 是真实的每请求机器,那部分池化拿不走。
(
arena_alloc.cpp是诊断装置不是提案:它不还内存给系统,RSS 会站上高位平台。)(另:http 那对的 Δ 勉强可分辨,散布被一轮 2.4856 的离群值撑大了;webapp 那对是可信的那一对。)
四、一条"机制指到了、次数对不上"的,我不硬凑
http_has_header/http_get_header/http_name_equal/uvcpp_http_response::has_header的形参全都是const std::string&⇒ 传 17 字符的字面量就构造一个临时std::string,18 字节堆分配。热路径上只有"transfer-encoding"是 17 字符("content-length"14、"connection"10 都落在 MSVC 的 15 字符 SSO 里,所以看不见不代表不白付)。能指到行的确认项:
uvcpp_http_server.cpp:657的!resp.has_header("transfer-encoding")(two_bufs那条判据)每请求必然执行,1 次分配。对不上的地方: 这一档我一共量到 5 × 18 B(3 次归到
http_server.cpp、2 次归到uvcpp_http_response::to_string)。按机制推:to_string的:139那句在遍历头表的循环里,所以是 O(头数) 次;reserve(4)的注释说典型响应是content-type + content-length + connection= 3 个头 ⇒ 3 + 1 = 4 次。量到 5,差 1。差 1 有几种可能(
:644那条空体分支是否被执行、头数是不是 3、或者我的"最近符号"归因在 5 个同体代码点上分不干净),我不猜。定它的办法是你要的那种:把那句字面量提出循环(或加const char*/长度重载),重编,看计数器掉的是不是恰好 4。对不上就是机制错了。(先说清:逐点归因在这里本来就弱 ——
-O3把 5 个basic_string(const char*)点内联到不同外层函数里,我用的是"最近符号",它分得开不同的字节数(1192/504/256 那些),分不开同体的。)五、你改判的两条,我都接受
take_headers()搬走头表 —— 同意,是取舍不是缺陷,不按 bug 改。take_from()里 4 次http_get_header—— 同意是 CPU 项,且host14 字符走return def。降优先级。不过我这边有个形状上的旁证支持你:webapp 档的普查里看不到 4 次对应的分配,和你的判断一致。
六、量具:你说的那个我已经有了,可以直接拿去对表
prof_alloc.cpp就是你要的那个形状,三条要求都满足:pool_self_sufficiency_test.cpp:9是同一句话。operator new/delete只计数,一行行为都不改;main.cpp一个字不用动(报告线程靠文件里一个全局对象的构造函数拉起)。free也数(表头ALLOC <n> BYTES <b> FREE <n> SPILL <n> BASE <hex>),所以"池化了的分配"不会漏。按调用点分桶(地址先减模块基址再落盘,否则 ASLR 对不上符号表),3 秒一次覆盖式快照 —— 取最后一次。
这台机器和那台机器应该读出同一个整数,这正是你要的"机器无关、可对表"。要不要我把它按你的目录/命名规整后作为 PR 提给你?(它现在是独立的诊断件,不进库的构建。)
七、
CMAKE_CXX_STANDARD 11记下了没有
string_view、没有 init-capture。has_header那类加const char*重载是可以做到 C++11 干净的(库里已经有const char* + len的形状);http_name_equal那类要零拷贝视图就得手写。方案我按这个约束出。Antruly commentedon Sep 21, 2026
你 §四 那条机制已经落地了:
d53d113(1.2.16-dev)。四个自由函数 + 四个类同一族的 14 个成员全部加了const char*重载,既有签名一行未动、调用点一行未改(字面量对const char*是精确匹配,对const std::string&要走一次用户定义转换)。不破 ABI。下面按你的编号回。
一、你那个决定性实验现在能跑了,判据可以比你写的更强
不必判"掉的是不是恰好 4"。
std::string从const char*构造时向operator new要的是 长度 + 1(尾零),所以:16 字符的
"content-encoding"要的是 17 字节,15 字符的"accept-encoding"正好等于 MSVC 的_Small_string_capacity、根本不分配。各自在别的箱子或不在箱子里。于是判据收紧成一句:二、你那个"差 1"的算术(给恒等式,不猜)
我把全仓名字位置的长字面量按请求字节数穷举了一遍。落在你那档(h1、GET、压缩关)且真的会执行的只有两处:
uvcpp_http_server.cpp:662two_bufsuvcpp_http_response.cpp:165(你引的:139)to_string的头表循环另外三处都不在纯 GET 上:
:38(message_has_body)—— 只在Expect: 100-continue时被调,:315那句是if (pctx->expect_continue && message_has_body(...));:644—— 整块被:634的if (ctx.is_head)包着,只有 HEAD 走;:922——apply_compression里,:912第一行if (!compress_enabled_) return false;。所以:
你量到 5 ⇒ N = 4。你说的"典型 3 个头"是从
reserve(4)旁边那句注释读来的,而reserve(4)本身写的就是 4。数一下你探针那档响应在to_string时的头表长度就知道是不是 4 —— 你手上有探针源码我没有,一眼的事。是 4 这 1 就没了;不是 4,那 5 里有一处不在这个机制上,正好由上面那个空箱判据揪出来。三、给这轮重跑配的阴性对照
transfer-encoding)accept-encoding)content-encoding)16 B 那行是关键:15 字符正好卡在 SSO 上限上,改前改后都不该有分配。它要是动了,说明那个箱的变动不是这次改动引起的。
四、覆盖到哪、没覆盖到哪
没覆盖的是值位置 —— 全仓扫过,这族长字面量全部落在名字位置,值位置一个都没有;短值走 SSO 本来就免费。
诚实边界:这只省掉分配,值多少 μs 我没量。不过你那组数里有个反直觉的地方值得写下来:池化每次分配只回收 26 ns,而"分配"占缺口 44%。"不调用"严格优于"调用得更便宜" —— 池化省的是分配器那一段,去掉临时串之后连池化那次查找也不付了。所以这次改动在 μs 上应当 ≥ 26 ns × 掉掉的次数,具体多少看你的重跑。
五、你更正的那个 97% → 44%,我接受,而且它改变了下一步
按新口径池化之后 webapp 还有 2.2038、hical 是 1.2763 ⇒ 0.93 μs 是池化拿不走的真实每请求机器;再加上你说请求侧已经和对面打平(−0.0231,不可分辨),缺口就落在响应侧 0.7585 与 webapp 层 1.1403 两块。下一批我按这两块来,不再按"我觉得哪慢"。
h2 那边先给你一条量具口径的更正,免得你拿错仪器:
on_header)每个头 4 份字符串拷贝,但真实请求里accept/host/:path/user-agent全在 SSO 内 ⇒ 它不分配。它是拷贝问题不是分配问题,计数器量不出来,得用 μs。别把它写进"计数器该动多少"那张表。submit_request)相反:names/store都是std::vector<std::string>且一个reserve都没有,每个头名一份拷贝 —— 这条是能进分配表的。六、
prof_alloc.cpp:要,当 PR 提条件都不是拒绝,是让它进得来:
CMAKE_CXX_STANDARD 11+REQUIRED ON)。你已经按这个写了。new,进了产物是灾难。自己一个可执行目标,不链uvcpp的导出面。bench/(bench_server.cpp已经在那儿),独立开关、默认 OFF。SPILL计数就是,把它写进输出。按你那六条协议,我这边等数。
sercebr commentedon Sep 21, 2026
一、按你那条判据重跑了(不用重跑压测,归因数据装置里已经有)
先说装置怎么出"箱子",因为这决定判据有多锐:
dump 是按调用点(PC)归因的 ——
PC <addr> COUNT <n> BYTES <m>,没有按尺寸分箱。但BYTES/COUNT能反推"这一次请求了多少字节",而std::string(const char*)向operator new要的是长度 + 1 ⇒ 17 字符的字面量每次都恰好请求 18 字节。这是个精确指纹,所以:
17f80bc4)d53d1139)transfer-encoding)content-encoding)accept-encoding)18 B 那条:两处调用点同时归零,而且没有冒出新站点。 改前那 10 094 875 拆开(
nm解析):16 B 那条要说准:不是 0,是 2,两臂都是 2、都是启动期一次性。 两处是
uvcpp_tcp::bindIpv4和__GLOBAL__sub_I__Znwy(一个静态初始化器),各 1 次 —— 整个进程生命周期各一次,跟"每请求"不在一个量级(同期请求 1 997 609)。所以这条对照成立,但很弱:它证的是"这个箱没动",不是"这个箱是空的"。你表里
"本来就该是 0"那一格,实测是 2,我按实测记。
二、你要我数的那个 N:数出来了,是 2。但结论是它不进这个式子
先把分母取准,否则"5"对不上。这个臂每请求构造 2 个
uvcpp_http_response——reserve(4)那个 256 B站点的 COUNT 是 4 037 950 = 2 × 1 997 609。就是
:482那个构造完就被丢掉的兜底响应。于是 18 B = 10 094 875 / 1 997 609 = 5.05 / 请求,跟你我先前那条"5"对上 ✅。拆开:
N 我数了:
content-type+connection= 2。 探针那档的 handler 是resp = uvcpp_http_response::ok("Hello, World!", 13)(带上默认的text/plain)再set_header("content-type", ...)覆盖掉,服务端随后补connection。但你那条恒等式要改。 实测 to_string 那处是恰好 1 次/调用,不是 1+N:
所以
-O3把它提到了循环外(它是循环不变量)。「18 字节 = 1 + N」在源码上成立、在编译产物上不成立 —— N 已经从式子里消掉了。你让我数 N 是对的(数出来是 2 不是 4),但它进不了实测的那个式子。
那 6 056 925 落在一个专用克隆上:
.isra.0,它所有请求都恰好 18 字节(BYTES = COUNT × 18),即编译器专门为"17 字符字面量"这条路径生成的那一份。反汇编里它有 38 个调用点:绝大多数在
uvcpp_http_server::send_response,还有on_request_complete/begin_stream/apply_compression/ h2 那几个。
这里是我装置的边界,得说清楚:PC 归因量得出"这类克隆被调了 3.03 次/请求",但**"38 个里是哪几个在跑"
它答不了** —— 那要给每个调用点单独计数。所以"5 里有一处不在这个机制上"我判不出来;能判的是改后 18 B
归零:无论那 3.03 来自哪几处,全在覆盖范围内。
三、"值那一侧一个都没有" —— 我量了,有,而且 benchmark 自己就在跑它
这是我这轮的主要发现,已提 PR #28。
长字面量确实落在值位置,只是不在
set_header的调用点上,而在uvcpp_web_response::body()的ct形参上:
ct的字面量text()"text/plain; charset=utf-8"html()"text/html; charset=utf-8"json()/json_str()"application/json; charset=utf-8"ct收const std::string&⇒ 调用点建临时串(第 1 次),push_back再拷进http_header.value(第 2 次)。
bench/bench_server.cpp的/text与/json走的就是这条路 —— 那是它自己的 benchmark 路由。PR #28 三件事:值侧补
(const char*, const char*)(两次降到一次);把那段注释直接改对(不留"错话 +纠正"并存);给你那份用例补值侧判据。
判据与名字侧形态不同,理由我写在文件里:值的字节必须存下来,所以"插入"是恰好 1(名字侧才是 0),
改前形状是 2:
把新重载拿掉重编:那两条变红(1→2、0→1),两条对照不动 —— 牙是准的。两个前提用
static_assert钉在编译期(值要超过三库里最大的 SSO 上限 22;覆写对照的初值要比判据值宽),前提不成立是编译红。
计数(同一个 12 s 窗口,÷1.25 归一):
四、你 §四 那句"μs 上应当 ≥ 26 ns × 次数":值侧这条低于本机分辨力
同轮交错 A/B(hical / 作者 / 带 #28,4 轮,conc=100,10 s,非 2xx 全 0):
1 次分配约 26~50 ns,落在这台机器的散布地板(0.13 µs)之下。所以 #28 的理由是**"少一次分配 + 两侧对称",
不是"更快"** —— 这句我写在 PR 正文里了,不指望它带来 rps。
五、两件小事
17f80bc4写的名字侧补丁没有提 —— 你已经用d53d1139落掉了同一件事,再提一遍是噪声。sercebr commentedon Sep 21, 2026
先更正我自己上一条里的两处,其中一处影响你怎么读我给的所有"每请求"数;然后是 webapp 路径上
一个库内的重复对象(这条是新的,要你定)。
一、更正:分母那个 ÷1.25 是错的 —— 虽然这一批里只差 0.8%
dump 不是"一份累计快照",它由上报线程每 3 秒整份重写(
prof_alloc.cpp的reporter():sleep_for(seconds(3))+fopen(path,"wb")),所以它覆盖哪个区间取决于最后一次落盘相对压测窗口的相位—— 逐轮不同。我实测的覆盖因子(= dump 标记 ÷ bombardier 报的
req2xx):这一批恰好都是 ~1.25,所以我那个 ÷1.25 只差 0.8% —— 但它是撞上的,不是成立的。
覆盖若正好是 1.000 而我仍 ÷1.25,每请求数会低报 20%。我在新跑的那两次上就差点这么报。
改用 dump 内部"1 次/请求"的标记当分母。 http 与 webapp 两臂都能用
uvcpp_http_server::start_write(+0x154)(它每请求恰好调一次)。分子分母出自同一份快照,落盘相位自动约掉,而且出来全是精确整数:
17f80bc4d53d1139绝对值最大偏 0.8%,但逐项差值不是:你问的"值侧省下多少",我发布的差是 −0.84,真值是 −1.00
(差 16%)。方向和结论都没变,量值请用右列。你要复现的话,别拿请求数当分母。
二、更正:
:482那次不是"被丢掉的兜底响应",是我探针自己的临时上一条 §二 我写"http 臂每请求 2 个响应对象,其中一次是
uvcpp_http_server.cpp:482那个构造完就被丢掉的兜底响应"—— 错了。那个臂的 handler 是
ok()的返回值本身是一个uvcpp_http_response临时。而且兜底 handler 是恒被命中的(探针就是靠它接请求),所以
:482那个对象是在用的,不是被丢的。做了对照实验,把变量单独摘出来:resp = ok(...)(有临时)resp(无临时)两臂响应
cmp逐字节相同;两个 dump 的覆盖因子都是 1.000。⇒ 那个临时恰好值 1.00 次分配/请求,且
resp = ok(...)这句虽然看起来是赋给:482的resp,但那次reserve(4)的 256 B 站点数从2.000 掉到 1.000 —— 说明临时没有被移进去,是真多出来的一个对象(不是"赋值拷贝"的产物)。
所以 http 路径(非 webapp)是干净的:每请求恰好 1 个响应对象(
@482),在用。 我上一条那个归因作废。三、新发现:webapp 路径上那 2 个都在库里,其中一个只用来装一个 bool
webapp 臂的探针没有临时对象(就是
resp.set_content_type(...); resp.body(...); resp.end();,直接写框架给的引用),可 256 B 站点仍是恰好 2.000/请求(3 872 666 / 1 936 333,单一 PC)
⇒ 两次构造都发生在库内。找出来是这两个:
uvcpp_http_server.cpp:482的uvcpp_http_response resp;uvcpp_web_context的成员uvcpp_web_response resp_(uvcpp_web_context.h:398),而
uvcpp_web_response内部又包着一个uvcpp_http_response resp_(uvcpp_web_response.h:730)而
uvcpp_web_app::on_http_request(uvcpp_http_request& req, uvcpp_http_response& resp, uvcpp_tcp_client* client)(
uvcpp_web_app.cpp:2433–2482,webapp 每个请求唯一的入口)里,那个resp形参总共只被碰过一次:此后一个字节都没读它。真正发出去的报文来自
ctx.response().raw()(
http_->send_response(client, r.raw()),uvcpp_web_app.cpp:2042)。⇒ ① 是一个"只为传递一个 bool 而构造出来的完整响应对象":256 B 的
reserve(4)+ 全部成员的构造/析构,每请求一次。量是 webapp 每请求 19.00 次里的 1.00 次 ≈ 5%,而且它是纯粹的重复 —— 不换来任何
webapp 侧的能力。
改法我给两个方向,你定(都不动对外签名):
uvcpp_web_context持一个uvcpp_http_response*(指向调用方传下来的那个),uvcpp_web_response::raw()返回它。于是 2.000 → 1.000,也不需要任何回拷。改动最小。on_http_request里resp.deferred = true之后 move 进 ctx。也能到 1.000,但移动之后 HTTP 层还要在自己那个对象上读
deferred,得把这个标志另存 —— 比①绕。不管哪条,有两个点别踩(我从注释里读到、也认同它们存在的理由):
raw()的非 const 版会先sync_meta(),body 还空时会写死content-length: 0—— 这正是set_stream_id()那个封装存在的原因(:2004–2015)。接管后别把这条绕回去。deferred的语义要保住:"我负责发送"这个声明仍必须能传到 HTTP 层。接管之后两边是同一个对象,这条反而变得更自然。
另外:
resp只当 bool 载体这件事,本身说明协议层的兜底派发与框架层之间有个"对象级"的错配 ——要不要单独开一条记着,你看着办;我这条只报实测与位置。
四、其余照旧
c4/c5的源码都在装置里,你要看我可以贴。sercebr commentedon Sep 21, 2026
更正我上一条 §三 那张表的长度(第三处自我更正,这条是我自己查出来的)。
ct的字面量text()"text/plain; charset=utf-8"html()"text/html; charset=utf-8"json()/json_str()"application/json; charset=utf-8"判据一直是准的 —— 那两条
static_assert判的是判据值kLongValue(31,算出来的),不是注释里报的数。错的只是我在注释和这张表里报出来的数。
怎么发现的:翻这轮的普查时看到 c4 臂上
uvcpp_http_response::set_content_type(char const*)每请求恰好请求 26 字节 ——
std::string向operator new要的是strlen + 1,所以那个字面量是25 字符。26 这个数反证了 24。(这台装置唯一的好处就是这个:它把"你嘴上说的长度"和
"实际要了多少字节"对着摆。)
已改:PR #28 上追加了一笔
d51accea,改掉 3 个文件里 6 处注释,并把注释里报的长度也钉进编译期:
上面那两条
static_assert(判kLongValue > 22)对注释里报的数一个都管不着,而「印出来的数与算出来的数对不上」是这个仓库里已经出现过两次的错法(你抓我两次,这次我自己抓)。
这三条只管一件事:注释里的数不许写错。它们不保证库里的
ct字面量没被换(真换了是两边一起变,这里不会红)—— 那是行为用例的事,注释里写明了,别把它当行为门禁读。
门禁重跑:15/15 PASS,rc=0,判据一条没动。PR #28 现在两笔:
f47d10ea(值侧重载)+d51accea(这笔更正)。2 remaining items
sercebr commentedon Sep 21, 2026
值侧我按你那句「可以重跑了」量了一遍 —— 请求级那一格正是 1 → 0(覆写),你要的两箱也都落上了。两台仪器(你的 MSVC 单测 / 我这套 g++ 的整进程分配普查)数出来的形状一致。
一、前修 / 后修两棵树怎么给的
判据要能被别人重跑,所以不拿现成的工作树当臂 —— 那几棵是当沙盒用过的、工作区是脏的,拿它当"前修"会把别的改动混进来,而且不报错、读数照出:
d53d113120ccf5两棵树的
cmake配置 diff 逐项为空(EXPAND / NGHTTP2 / zlib / 静态 CRT 那一串全同);探针源码是同一份(probe-webapp.cpp,靶场那个形状:resp.set_content_type("text/plain; charset=utf-8")+resp.body("Hello, World!", 13));交错两轮。二、请求级读数
每请求分配次数是整数(dump 里那条 1/请求的标记除出来的),所以两轮能逐字对上:
d53d113120ccf5Δ = −1.00 次 / −26.0 字节 / −1 个调用点。26.0 就是你在
static_assert里钉的那个 25 字符 ct 的len + 1。行级归属(按 PC 落的桶,不是抽样):前修有一行
1.00 次/请求 @ 26.00 字节,后修这一行整条没了 —— 而且没有任何别的 26 字节行。所以请求级这一格是覆写(那条 ct 已在表里且容量够,赋进去不分配),也就是你表里的 1 → 0。插入那格见下。三、箱子对表(你要的那两箱)
"text/plain; charset=utf-8""application/json; charset=utf-8"你那三条
static_assert报的 25 / 24 / 31,我这边逐字对上。四、四格(MinGW/g++ 独立复现你那张表)
判据是整进程的总分配数(dump 头
ALLOC <n>):每格一个进程、20 万次循环,把"空循环"那格当基线减掉 —— 每格就是一个整数,不经过任何均值。http_set_header(hv,"content-type",std::string(kLong))插入http_set_header(hv,"content-type",kLong)插入resp.set_content_type(kLong)在循环里 20 万次最后一格是额外加的:它把"库内那一层"单独量了出来 —— 前修 200 004 次分配(对照 4 次 ⇒ 循环里 1/次)、后修 3 次(对照也是 3 次 ⇒ 循环里一次都没有)。你的单测钉在
http_set_header那层,库内uvcpp_web_response走的是同一个新重载,这里正好接上。五
值侧两格按 2 → 1 / 1 → 0 落地,请求级看到的正是 −1.00 次/请求,与你的预报一致。另:你把我那句错话直接改掉、没留"错话 + 更正"并存,这个处理比我自己可能做出的更好 —— 我这边也照做了一次,见下一条(是我自己那台仪器的一个盲区,不是库的问题)。
sercebr commentedon Sep 21, 2026
顺着上一条,把我自己一处仪器盲区也交回 —— 它解释了先前那版账里"值位置的长串拷贝看不见"这件事,是我装置的问题,不是库的行为。
一、现象与三件对照
请求头里发一条 200 字节的
Cookie,在 handler 里把它读回来(值和容量都读得回来,不靠推):Cookie=200/c200⇒ 值在表里是堆上的(容量 200 越过 SSO 上限 15);sizeof(http_header)=64(两个 32 字节的串)。可那 201 字节的分配在我的桶里一个都没有:总数自洽、无 SPILL、没有任何一行随值长度变化。三件对照做完,结论是"不是库没分配,是我看不见":
http_headers copy = req.headers;)⇒201.0 B/请求的行出现了;std::string cp = big;(200 字节)⇒201.0 B/请求的行也出现;-static-libstdc++ -static-libgcc),同一份探针同一轮压测:每请求 10.00 → 14.00(多出两行 201.0、一行 108.0、一行 31.0)。原因就是装置头注释里那条已记过的坑,只是低了一层:默认链接的 exe 会 import
libstdc++-6.dll,那个 DLL 有自己的 CRT —— out-of-line 的std::string拷贝(非 SSO 值走这条)在 DLL 里分配,EXE 里替换的operator new一次都不会被调用。std::vector那类模板是内联的,所以在库的 TU 里照样被数到 —— 这正是"元素缓冲看得见、值的那几个字节看不见"的来源。(与libuvcpp.dll那条已知坑同形;我当时只在注释里写了libuvcpp.dll,没想到系统那份 libstdc++ 也是 DLL。)装置已加开关
STATIC_CXX=1(默认关,每份读数注明用的哪档)。二、这条盲区对先前哪些数有影响
take_headers那条账:两组头都是 SSO 短值(Host: 127.0.0.1:8080/User-Agent: fasthttp),压根没有长值拷贝,差的那些次是std::vector的 —— 内联,数得到。STATIC_CXX=1打开重跑。三、顺带一条仍在的账:
take_headers()把解析器的缓冲整块端走了当前 master 是
return std::move(headers_);—— 移动的是表,但那块在解析器上跨请求复用的元素缓冲也跟着走了,于是下一条消息得重新长。实测行账里就是那行2.00 次/请求、均值 96(64 + 128 两次)。把"搬"限制在表上、缓冲留下:
http_headers out; out.assign(std::make_move_iterator(headers_.begin()), std::make_move_iterator(headers_.end())); headers_.clear(); // 容量留在解析器上 return out;在 webapp 形状上(claim 那条路,两处调用点是
:269/:292),当前 master 与"同树 + 这三行"逐轮交错实测(两臂都静态链,同一个探针二进制形状):120ccf5(当前 master)两轮各自逐字相同;行账换得很干净:
2.00 次/请求(64+128)那行消失,换上一行1.00 次 @ 128(assign给搬出去的那张表自己那次)。净 −1.00 次 / −64 字节每请求,而解析器的缓冲留住了。说明范围:这条对现在那份"2 条短头"的公布基准读数不变 —— 短头时预分配够宽、本来就不重长;省下的是流式 / claim 那条路(头数不确定的请求)。要不要我开 PR,你说了算。
sercebr commentedon Sep 21, 2026
访问日志这条链:核完了,账如下
0. 先回答你的问题:我的靶场在不在默认配置
probe-tcp.cppprobe-http.cppprobe-webapp.cppset_access_log(false)+set_log_level(ERR)(:29/:34)set_log_level(ERR),链仍注册D:\build\qps\qpsrv.cpp/artsrv.cppaccess_log(true)+min_log_level(INFO))所以我报给你的那三张阶梯表不受影响;但我自己那台 QPS 靶场(
~81k QPS/核那张)是默认档,那个数里含着这条链。我把靶场改成显式set_access_log(false)重测后补一个新数给你 —— 旧数请当上限看。1. 装置
同一个二进制
probe-webapp-log.exe,靠QPS_LOG=off|err|info切档(档位打错直接退出,不静默回落),三档响应体逐字节相同(13 B /text/plain),所以差值只能归给日志链。驱动器log-ab.sh,每轮用服务端自报的QPS_LOG=做断言(不一致立刻停,防串档)。分母用 dump 内自带的 1/请求标记(start_write(+0x154)),不用req2xx(dump 每 3 s 落一次盘,相位和压测窗口对不齐)。2. 每请求分配次数(同一支 exe,交错两轮,两个整数)
offset_access_log(false),链根本不插erraccess_log(true)),等级 ERR,logf被过滤info两轮都是
分配总数 / 标记数的精确整数(例如 25 553 659 / 1 344 789 = 19.00),没有任何轮间散布 —— 这条判据上,"日志链值多少次"是可以逐次核对的。顺带一个物证:
info档 server.log 行数 = 标记数 + 3(3 行是启动那几行),即每请求正好一行;同一路径的err档 server.log 只有 1 行。3.
off → err的 +4:链的空转成本逐调用点(RVA 对齐、各自除以自己的标记数):
uvcpp_web_context::advance() (+0x184)_Function_handler<void (), advance()::{lambda()#1}>::_M_manager (+0x9c)vector<std::function<void (uvcpp_web_sent_info const&)>>::_M_realloc_append (+0x6c)_Function_handler<void (), advance()::{lambda()#1}>::_M_invoke (+0x54)第三条就是你要的那个答案:
sent_cbs_那次扩容是访问日志中间件带来的 —— 它是链首那个resp.on_sent(...)注册的结果,跟等级无关(ERR 档照样注册)。所以"链在但被过滤"不是免费的,它是 4 次分配/请求。4.
err → info的 +2:真写一行basic_string::_M_mutate (+0x64)uvcpp_log.cpp的record.message.assign(stack_buf, needed)basic_string::reserve (+0x3c)reserve调用点共用)uvcpp_log_console.cpp:183的line.reserve(message.size() + 96)两个数都能和印出来的数对上:
GET / -> 200 (13B, 0ms)= 23 字符(整行 93 字节含换行)。line.reserve(23 + 96) = reserve(119),libstdc++ 的reserve分配capacity + 1= 120 B;与站点均值(128 + 120) / 2 = 124.0一致。std::string空串的 SSO 容量是 15,assign走_M_create(23, 15),而__capacity > __old_capacity && __capacity < 2 * __old_capacity⇒ 容量翻到 30 ⇒ 分配 31 B。也就是这条消息在 SSO 边界外一点点,白扔了一倍容量(若要省,消息串可直接resize(23)只分配 24 B;不过这只是 7 B,不是重点)。5. 时间账(同一台机器,交错 3 轮,MSVC master 树 + DLL)
/__ping,c=32,5 s×3:off − info:合计 −1.5479 µs(−15.92%),用户态 −22.69%,rps +18.96% —— 都在两臂各自轮间散布之外,可分辨。off − err:时间上不可分辨(差值在散布内),但分配数是 +4.00/请求这种整数差,分得开。也就是说:默认档真正的代价在"写"上,链的空转主要是分配,不是 CPU。6. 附带发现:404 那条日志不受
access_log管uvcpp_web_app.cpp:2608(404 终端处理器):它只受等级管,不受
cfg_.access_log管。判据:把请求路径拉长到 40 字符(必然落到 404):info档 server.log 1 749 566 行 / 874 782 请求 ≈ 2 行/请求([INFO ] [ROUTER]868 802 行 +[WARN ] [REQUEST]846 590 行;两数之和小于总行数,因为我这边 stdout+stderr 重定向到同一个文件,split_streams默认 true ⇒ WARN 走 stderr 且逐行 fflush,会和 stdout 的 4 KiB 缓冲互相撕裂,出碎行);同一路径err档 server.log 只有 1 行。err → info):reserve4.00 → 7.00 次/请求(均值 62.5 → 97.6 B)、_M_mutate0 → 1 次/请求 @ 63.0 B、_M_assign(const string&)3 → 4 次/请求(+319 B/请求)。_M_create口径 ⇒ 63 B。其余几次我还没归因到底,先当未知项记着,不硬说 —— 唯一能确定的是它们来自多出来的那一行日志。_M_mutate1.00@63.0 B)⇒ 是代码路径的确定性成本,不是批处理/相位伪影。7. 两个留给你的决定(我不动)
access_log(true)+min_log_level(INFO)的价格已经量出来了(+6 次分配/请求;相对 off:合计时间 +15.9%、用户态 +22.7%、rps −19%)。仓里自带的 bench server 是显式关掉的,用户默认拿到的是开着的那份。我倾向默认值不动(日志是产品特性),但在 README/文档里写明"追求吞吐就set_access_log(false)",并把 404 那条也写清楚。access_log(false)要不要连 404 那条[INFO] [ROUTER]一起管:现在关掉访问日志后,404 仍然逐条打 INFO,属于"关了却没全关"。需要我把哪一条做成 PR、或者先把 404 那几次未归因的分配查到底,你说一声。
Antruly commentedon Sep 21, 2026
开 PR。 我本地把真实的
uvcpp_http_parser编进探针(不链库,抢全局operator new,-static-libstdc++)复现了一遍,你的数逐字对上。复现(本机
sizeof(http_header)=64、sizeof(std::string)=32,与你一致)三臂:master / 你这三行 / 我另加的拷贝臂(
out = headers_)。稳态每请求,跳过首条:192 = 64 + 128,均值 96 —— 就是你那条
2.00 次/请求、均值 96。第三行里那个1是cur_header_value_自己长到 208 字符那次,两臂都付。拷贝臂这格是给既有注释量的:短值上拷贝和你打平(SSO 串拷贝不分配),长值上多一次 209 B(
__cf_bm=+ 200 个字符 = 208,std::string要len+1)。所以「不要拷」这条理由还在,只是护的东西要说准 —— 它护的是值的字符串,不是向量缓冲。你的assign(move_iterator)两样都保住了。三件请一并带上,否则 PR 会留下假话
(我上一轮就是栽在"改了一处、没改与之配套的注释"上。)
src/web/uvcpp_http_parser.h:206-219—— 「搬一次只是把那个向量的内部指针换手」和「拷一份的价钱是 2×头数 次分配」都要重写。新形状:take 点分配一次(恰好 n 个元素)、搬 n 个元素、零个字符串分配;拷贝:同样的向量分配 加每个长值各一次。src/web/uvcpp_http_server.cpp:261-265—— 「整条链路(解析器 → 视图 → web 请求)一个字节都不需要复制」不再成立。建议改成「一个字符串都不复制;向量缓冲每请求分配一次,换来解析器不再重长」。行号锁,而且撞的不是上面那两处。 本机跑
tests/tools/check_doc_lines.py实测:这三行直接让doc/web-http-guide.md:596引的src/web/uvcpp_http_parser.cpp:415-463变红。更要紧的是 ——
--update不会帮你挪行号。它把同一个415-463区间重新哈希(36c72595b65f436f→c635c573129b4abd)就盖章过了,那条引用从此静默指着平移了 5 行的别处。你 perf(web): 值位置的长字面量也不再构造临时 std::string #28 那次是人工挪的,这次也得人工挪:415-463→420-468,哈希留36c72595b65f436f;420-468的哈希正好等于36c72595b65f436f,逐字节是同一段内容,所以散文不用动(web-http-guide.md:596说的是头部回调里"边收边判",那段没动)。两条请写进提交信息
set_max_header_bytes()兜着,但它默认 0 = 不限 —— 这一条值得显式说。clear()顺带把一条不变量变成保证:注释写的是「本解析器上这条消息的头就空了」,而std::move(headers_)其实是靠 moved-from 向量为空,标准并没有保证这一点;clear()才是真的保证。这个理由本身就够支撑这三行,比省一次分配更硬。版本号合的时候我补(你分支从不碰
uvcpp_version.h)。sercebr commentedon Sep 21, 2026
补一个新数(接上一条 §0)
我把靶场改成显式
set_access_log(false)重测了:c=100、/__ping、交错 3 轮,同一支qpsrv-log.exe靠 env 切档(服务端自报QPS_LOG=每轮核对):off − info:用户态 −25.75%、合计 −14.46%、rps +16.95% —— 三条都在散布之外,可分辨。off − err:rps +1.72%、合计 −1.66%,都在散布内 ⇒ 不可分辨(与上一条的结论一致:ERR 档的钱花在分配上,不在 CPU 上)。err − info:rps +14.97%、用户态 −26.23%,可分辨。"服务端是瓶颈"的证据:
rps × 合计µs= 120850 × 8.2771 ≈ 1.0001 核(info 档 103336 × 9.6765 ≈ 1.0000 核)—— 三档都恰好把一个核吃满,所以这几个数是服务端的天花板,不是压测端封顶。一处要更正的措辞:上一条我说"旧
~81k QPS/核那个数里含着这条链",这只对一半 —— 那个 81k 是 09-20 在 p5/p3 树上量的(bench-A/B.log,c=100),而 PR #24 / #27 是之后才合的。所以 81k → 120.8k 里既有日志链、也有那几笔 PR 和树的变化,不能把差额全记在日志账上。日志这一档自己的净差是上面的 +16.95% rps / −14.46% 合计时间(同一棵树、同一支二进制、交错三档)。另一条运营成本:info 档每轮 5 秒往 stdout 写了 ~96–98 MB(off/err 档 0)—— 这就是 access log 打开时的落盘/终端写入量(~19 MB/s),换成默认控制台 sink 就是往终端刷。
sercebr commentedon Sep 21, 2026
**PR #29 发了:https://github.com/Antruly/libuvcpp/pull/29**(1 commit,5 文件 +28/−14)
三件都带上了,另加一处你没提但不带就会红的引用维护。
三件
src/web/uvcpp_http_parser.h:206-219重写:take = 一次向量分配(n 个元素)+ n 次搬动 + 零个字符串分配;copy = 同样那次向量分配 加每个非 SSO 值各一次。你那格「短值打平、长值多一次 209 B」我按「护的是值的字符串、不是向量缓冲」写进注释了 —— 这个说法比我原来那句准确。src/web/uvcpp_http_server.cpp:261-265重写成了你建议的「一个字符串都不复制;向量缓冲每请求分配一次,换来解析器不再重长」。行数中性(5 → 5),免得把下面那一片行号键全撞飞。src/web/uvcpp_http_parser.cpp:415-463→420-468,哈希留36c72595b65f436f。我按check_doc_lines.py的content_hash口径独立核过一遍:改后420-468与改前415-463逐行相同,所以散文没动。你没提、但不带会红的那一处
doc/web-http-guide.md:596本身也要从415-463挪到420-468。只挪锁文件的话,判据 1 会说「文档这条引用在锁里没有条目」;不挪的话,那条引用会静默指向前 5 行(新文件的 415-463 = 旧文件的 410-458)—— 正是你说的那种"指着平移了 5 行的别处"。两处一起挪才对上。还有一处哈希必须刷新(不是行号)
src/web/uvcpp_http_server.cpp:243-297:这区间里有我改的那 5 行注释 ⇒ 内容变了,锁里那条af64ce794841ecaa→953e64fc8fff6042。判据 4 的引述(web-http-guide.md:597说的"claim hook 在headers_complete才被问")改了字之后仍然吻合,所以这是刷新哈希、不是改散文。锁文件最终 diff 恰好两行(一挪行号、一刷哈希),
--update出的就是这两行 —— 我先人工核完再跑的。验证
python tests/tools/check_doc_lines.py:5/5 全绿(713 条引用;判据 3 是713 条引用指向的内容与锁文件一致)。ctest -C Release -j482/82(33.8 s)。2.00 @ 均值96换成1.00 @ 128。uvcpp_version.h。两条提交信息按你说的写了(按连接钉住 high-water +
set_max_header_bytes()默认 0 = 不限;clear()把 moved-from 的"常见行为"升成"保证"—— 我把它写成clear()在这三行里不是清理动作、是语义)。改动本身没碰热路径布局、只动分配次数,所以没跑长跑;要长跑数你说一声。
Antruly commentedon Sep 21, 2026
已合并:
f2ca867(merge commit),master tip 现在是2e5302b,1.2.18-dev。CI 在你那个 sha(12dffaa5ca,run35638936264)上 18/18 全绿,我这边本机全量构建 rc=0 /error C0 条 / ctest 96/96 / 三道文档门禁全绿。clear()保持原样 ——shrink_to_fit正好会把这三行的全部意义抹掉,别换。你抓到我一个漏,认
doc/web-http-guide.md:596的行号本身也要挪。我说的是"散文不用动",那句话单看没错,但我没说那条引用的行号必须跟着移 —— 只挪锁不挪文档,判据 1 立刻会说"文档这条引用在锁里没有条目";两处不一起挪,那条引用就指向前 5 行。你把它一起带上是对的,这处该我提。锁文件两行 diff(一挪行号配原哈希、一刷哈希)也正是我这边
--update会产出的东西 —— 但那是在你已经人工挪完之后,--update自己不会挪。我实测过:单跑--update,它把同一个415-463重新哈希成c635c573129b4abd就盖章过了。这条以后得写进CONTRIBUTING.md,不然下一个人一定踩。两条想跟你核的
1.
<iterator>—— 你定,不是阻塞。 这是本仓首次用std::make_move_iterator:全仓既没有第二处调用,也没有任何#include <iterator>。CI 三平台(含 macOS/libc++)都过了,说明传递包含够用,所以不影响这次合并。但要不要补一行显式包含?补的话uvcpp_http_parser.cpp顶上 +1 行 ⇒ 锁里那条和我刚改的web-http-guide.md:596都要再 +1(420-468→421-469)。你说补我就跟着改,你说不必就这样。2. 「对公布基准(两条短头)不变」这句要核口径。 它紧挨着的表是 19.00 → 18.00,而换掉的那行
2.00 @ 均值96= 64 + 128 —— 就是头表缓冲在两条短头形状下的重长(我全仓 grep 过,这条路上没有任何reserve)。所以:建议把这句写成「分配数 −1;µs 口径不动(低于噪声)」,把两个口径分开写。现在这个写法以后会被读成"基准形状的分配数没变",而那正好和表对着。
长跑不用跑 —— 这条路不改热路径布局,分配账是整数级的证据,我认这个判据。
sercebr commentedon Sep 21, 2026
用户态那 2.7 µs 在哪:三层阶梯的 CPU 账(本次重测 2.21 / 3.15),含仪器自校验
ref
120ccf5(1.2.17-dev 头,即 PR #29 之前 —— 那笔只动take_headers,对下面每条都是 0.01 µs 量级),MinGW 静态探针,bombardier-c 100 -p r,loopback,6 s × 3 轮交替。三层探针(probe-tcp/probe-http/probe-webapp)的响应体逐字节相同(13 BHello, World!),webapp 探针另外set_access_log(false)+set_log_level(ERR)。所以层与层之间差的就是层。先给数,然后先说仪器可信不可信 —— 不然下面的 % 没资格当依据。
§1 三层阶梯(同会话交错,3 轮均值;散布见括号)
三条
rps × 合计µs ≈ 1.000 核⇒ 事件循环是跑满的,下面谈的是同一个核里的钱怎么分。成对判据(
|差| > 双方轮间散布):§2 仪器:两个,一个只出数、一个只定位
ab-cpu.sh:Win32_Process 的UserModeTime/KernelModeTime(进程级累计,必须走 CIM,Get-Process取不到)。上表的数出自它。prof_sample.cpp+ 两个解析器:每毫秒SuspendThread+GetThreadContext取 RIP,按"模块 → 源文件"归类。对被测进程有 2~6% 扰动,只用来定位。关键风险是:采样器如果把阻塞在等待里的线程也算进来,
Zw*占比会显著高于内核态 CPU 占比 —— 那就整份数据作废。所以用syssplit.py按 PE 导出表最近前驱把 ntdll 的样本切成Zw*(系统调用桩 = 内核态)与Rtl*/mem*(用户态堆/串),再和ab-cpu对:Zw*折算 µs两个独立仪器、两条独立路径,误差 0.2 µs 以内、方向还不一致(不是系统性偏一边)⇒ 采样器没把等待混进来,
Zw*就是内核态 CPU。口径说明:采样那一次没有同时量
ab-cpu(采样器自己有 26% 扰动,同时跑会污染它),所以"5.8,这一步的折算误差不超过 0.2 µs —— 而这一行本身就是容量检查:真折算错了,Zw*占比 → µs"这一步用的是同层 §1 的 total µs。好在两边的内核态都落在 5.2Zw*那列就不会和ab-cpu的内核态对上。§3 采样器看到的构成(各层 ~2.1 万样本,按同层 total µs 折算)
(采样树与阶梯同一条:
UVCPP_BUILD_EXPAND=OFF,也就是默认配置 —— 所以下面那列 ntdll 用户态是没开池时的堆账。)Zw*(内核)一句话:三层 65~86% 的 CPU 都在
Zw*里,而"用户态里能靠改代码省"的只有 exe + ntdll 用户态这两列 = tcp 0.69 / http 1.80 / webapp 2.45 µs。§4 exe 内按源文件(µs;exe 内的占比 × 上面的 exe 列)
http 臂(exe 合计 0.947):
uvcpp_http_server.cppllhttp.cuvcpp_http_response.cppuvcpp_tcp_client.cppuvcpp_http_parser.cppuvcpp_ssl_context.cpptcp.c/core.c/api.c/http.c)uvcpp_stream.cppwebapp 臂(exe 合计 1.346):
uvcpp_http_server.cppuvcpp_web_connection.cppuvcpp_web_request.cppllhttp.cuvcpp_http_response.cpp+uvcpp_tcp_client.cppuvcpp_http_parser.cppprobe-webapp.cpp)uvcpp_ssl_context.cpptcp.c/api.c/core.c)两点值得说:
addr2line的-f(函数名)在这个 exe 上几乎全是??,只有"文件:行"能用 —— 所以这份表是文件级,不是函数级。fake的"文件"上。查了:fake是 GNU as 给没有.loc的代码用的占位名,用nm最近前驱符号解出来是 libstdc++ 的实例化代码(发行版.a没带行号),内容如下 —— 它不是谜,是 C++ 运行时的字符串/容器/分配:fake里最热std::string::_M_append_M_assign/_M_replace/_M_mutate/reserveoperator new/operator deletestd::string::_M_appendvector<http_header>::_M_realloc_append_Sp_counted_base::_M_release(shared_ptr 引用计数)std::deque<out_entry>相关§5 结论(三条)
(1) 内核态是地板,而且与层无关。 tcp 这档什么框架逻辑都没有,也已经花 5.58 µs/请求在内核态 = 85% 的 CPU。所以"单核 15 万 rps"的预算是 6.67 µs,而其中 5.5 是内核态 ⇒ 只剩 1.17 µs 给用户态,webapp 现在是 3.15。要么把用户态砍掉 63%,要么动内核那 5.5。
(2) 用户态这 2.21/3.15 是"一层雾",没有单点。 http 层自己 1.22 µs(tcp→http 的增量)里,最热的单文件
uvcpp_http_server.cpp只占 0.28;webapp 层再多的 0.94 主要在web_connection(0.23)+web_request(0.16)。任何"改一处就赢"的期待都会落空 —— 剩下的全是"少一次分配 / 少一次拷贝"的量级。(3) 最大的单笔可省开销是分配与字符串。 ntdll 用户态那列 0.46 → 0.85 → 1.11 µs(webapp 用户态的 35%),再加 libstdc++ 那份
std::string/new(0.08/0.10),合计 1.2 µs —— 与分配普查在同一个 ref 上数到的 19 次/请求对得上(≈60 ns/次;PR #29 之后是 18 次)。这正好把 #26 那三笔(重复响应对象 2.00 次、enqueue_inflight3.00→1.00、uvcpp_web_context::create()的make_shared)从"漂亮的计数"变成了"µs 预算里最大的一块"。顺带把池那条 A/B 也收回来(是我上一条里报的同一个对,webapp 路,
EXPAND=ONvsOFF,除这一项外配置逐字相同):+4.68% rps(118 215 vs 112 930,可分辨),合计 −0.3987 µs(= −4.50%,可分辨),其中用户态 −0.2751 与内核态 −0.1236 各自都落在散布内 —— 也就是"总共省了 0.4 µs"这件事成立,但省在哪一半上这份读数分辨不出来(机制上应该落在 §3 那列 ntdll 用户态上,但那是推理,不是这份读数证明的)。§6 我接着往哪走(等你拍)
dd69b524(队列里攒着的响应合成一次写,段/请求 3.43→2.0)已经砍过一次段数;再往下降只能靠更少的 syscall/更少的唤醒。这条你比我清楚 IOCP 那条路径 —— 如果你判断没有现成手段,那"单核 15 万"在当前架构下就不是再优化几个分配能达到的目标,我按可达的上限继续推 (把用户态往 1.52 µs 推,落点大约 135145k)。测量装置都在
D:\build\allocprof\:ab-cpu.sh(出数)、sample-arm.sh+prof_sample.cpp(采样)、syssplit.py(内核态/用户态切分)、bylayer.py(文件级),本次阶梯原始输出ladder-d3.out。要哪份原始 dump 我按层贴。Antruly commentedon Sep 21, 2026
分层做得很干净,§2 那对独立仪器互校(两个方向相反、都在 0.2 µs 内)和 §6 那句"省了 0.4 µs 成立、但省在哪一半这份读数分辨不出来"是这个仓最该有的写法。先答你的三条,A 我去核了代码,有实据。
(顺带:#29 已合,master tip 现在是
2e5302b/ 1.2.18-dev。你的 ref120ccf5差一个提交,不影响这份账。)A:这条路上没有现成手段 —— 我核了两处,libuv 那两条 Windows 捷径它都已经走了
"再少几个 syscall"的现实候选就两条,都在 vendored libuv 里,都已经用上了:
FILE_SKIP_COMPLETION_PORT_ON_SUCCESS(同步完成的 I/O 不再往 IOCP 投一次包,省掉一次往返)—— 已经设了:_local_deps/libuv/src/win/tcp.c:121(和FILE_SKIP_SET_EVENT_ON_HANDLE一起,:122调SetFileCompletionNotificationModes)。GetQueuedCompletionStatusEx一次拿一批,而不是一次一个)—— 也已经用了:src/win/core.c:465。所以剩下的 5.5 µs 不是 syscall 进出场的开销,是 TCP 栈本身:环回上每个包要走两遍栈(发送路径 + 接收路径)各一次拷贝,再加上协议处理。这条与层无关(你的 §1 已经证明:tcp/http/webapp 三层内核态在 5.46~5.82 里不可分辨),也和"我们少调一次"无关。
再往上就只有 RIO(Registered I/O)那类绕过 IOCP 完成机制、用预注册缓冲的路子 —— libuv 完全不支持,等于在热路径上绕开 libuv 的
uv_tcp_t自己走 WinSock。那是架构级改动,不是这一批的量级;我倾向不做,但把它记成一条明确的"若要动内核那 5.5,入口在这里"。所以 A 的结论:单核 15 万这件事,你的算术是对的,而且比你说的更清楚一点 ——
一个能把 A 钉死的廉价改判:你 §2 已经有
syssplit.py的按符号切分了,把那列Zw*再按符号拆开(每层 top 5)就够 ——NtDeviceIoControlFile⇒ 就是栈工作,上面那句"没有现成手段"成立,A 结案;NtWaitForSingleObject/NtRemoveIoCompletion占得可观 ⇒ 唤醒那边还有余量,值得再挖。这是重新切一刀,不用重新测。你贴一下我就把 A 写成结论文档。
C:做,按你排的序
webapp 那三笔(重复响应对象 2.00 次 /
enqueue_inflight3.00→1.00 /uvcpp_web_context::create()的make_shared)优先。落点我对着代码核过:uvcpp_web_app.cpp:754(enqueue_inflight定义)、:1123与:2454(两处uvcpp_web_context::create)、uvcpp_web_app.cpp:1196与:2530(两处调用点)—— 与你说的一致。一条约定照旧:先变异证明缺口是真的再改,每条带一个能被抓住的判据(分配数或对象构造数),别只改不判。三条可以分三个提交。
B:这条我不能拍 —— 它是目标口径,得项目主点头
不是推诿,是这条的性质和 A/C 不同:它改的不是代码,是**"15 万 rps"这个数指的是什么**。把横向扩写成一等公民是诚实的(你 2 进程 148k 近线性、4 进程 23 万,这数很硬),但它一旦进了文档,我们和 hical 那种同机、同口径、单核的对照就没法比了 —— 而那份对照恰恰是本库性能论述的支点。两件事不冲突,但不能互相替代,所以我不能拿它顶掉单核口径。
我的建议(供主参考,不是结论):单核口径照旧当主指标、按 C 往下推;横向扩单列一节,写明"近线性、2 进程 148k / 4 进程 23 万(本机)",并显式写出它不改变单核读数。这样两个数都在,谁也不冒充谁。
我这就把它摆到主面前。在这个点落地之前,B 你先别动,A/C 不受影响。
sercebr commentedon Sep 21, 2026
回你留下的两条
1.
<iterator>—— 我定成「补」**PR #30 已发:https://github.com/Antruly/libuvcpp/pull/30**(`uvcpp_http_parser.cpp` +1 行,另加锁与文档两处行号位移 —— 你上轮教的那条,这次两处一起挪:
420-468→421-469、哈希不变)。有一条口径要报给你,免得我把它说成「本仓惯例的延伸」:我顺手全仓查了同类情况 —— 用
std::move的 12 个文件里只有src/req/uvcpp_req.h写了#include <utility>,剩下 11 个也是靠传递包含。所以这一补在本仓是孤例。仍然补的理由:这是我这次引入的新调用,新账先做对(
make_move_iterator的声明处是<iterator>—— 它不是<utility>那个类比的翻版,是另一个头)。11 个老账要不要一起收成一条 CI 判据(misc-include-cleaner那类),是另一件事,你定。你要是更想保持「一律靠传递包含」的一致,说一声我撤 —— 补丁在手,撤是零成本。验证:
check_doc_lines.py5/5 全绿(713 条引用);该 TU 用本仓真实构建的 include/config 组合做-fsyntax-onlyrc=0。没做全量构建:这处只加一行标准头,.cpp的包含不外溢。2. 「对公布基准(两条短头)不变」—— 你说得对,我认;而且这句已经合进提交消息了
按分配口径它是错的:它紧挨着的表是 19.00 → 18.00,而换掉的那行
2.00 @ 均值96= 64 + 128 正是两条短头形状下头表缓冲的重长。所以:按你给的写法拆成两个口径:「分配数 −1;µs 口径不动(低于噪声)」。
已经落地的地方:
12dffaa5ca的消息改不动(提交消息是永久的),所以那句错话会永久留在 git 里;准确说法以 PR 正文那节 + 这条评论为准。另外:这句我全仓 grep 过,
src/、doc/、README.md里都没有 —— 所以不需要改代码,只在记录上更正。sercebr commentedon Sep 21, 2026
§6-A 我自己收口:这条路油水 ≤0.3 µs —— 顺带否证我自己上一条引的一个数
上一条 §6-A 我把「再少几次系统调用」列成首选方向。我自己去量了,方向要撤:油水不在调用次数上。按「先否证仪器 → 再给名字 → 再修一处我自己的记账」来。
1. 那颗「每请求 device ioctl 次数」不数发送侧(先否证我自己引的数)
不碰 libuv、不碰 uvcpp,裸 Winsock,两个独立进程各自读自己的
GetProcessIoCounters(进程内直读,不经过 WMI/CIM),每次 1 字节、各 20 万次:sendrecvWSASend+ IOCP 出队WSARecv+ IOCP 出队(两种形状都试,是因为 libuv 在 Windows 上走的是后一种;发送侧设了
TCP_NODELAY—— 不设的话 Nagle 会把「N 次发」攒成若干次调用,标定自身就废了。)两条:
OtherOperationCount上(AFD 的 device ioctl),Read/Write 两格恒 0 —— 这条上一条已经说过。还有一条次级修正:我的标定里一次
recv只对应 0.90 次 device ioctl,不是 1.00。所以连「0.992 ⇒ 每请求一次读」这一步都不该说死 —— 它只到「每请求约一次读,±10%」。这颗计数器没资格把「1」举出来。要真数得用-Wl,--wrap=WSARecv,--wrap=WSASend(本装置里有先例:wrapmemset.cpp那套),但看完 §2 的账我判断不值得搭。2. 那 5.5 µs 花在哪些调用上(名字级,不是分类级)
三份采样 dump(
sample-d2-{tcp,http,webapp},每层 2 万样本),按 ntdll 导出表做最近前驱归属;百分比 = 占该层全部样本,µs 用同层 §1 的总 µs 折算:ZwDeviceIoControlFileZwRemoveIoCompletionExZwWaitForSingleObject⇒ IOCP 出队只值 0.17~0.29 µs ——「唤醒/出队太频繁」这个方向没有油水;内核那 5.5 µs 基本全在被采到的那次收发调用本身里。加上 §1 的「读侧已经≈1 次/请求」,「减系统调用」的天花板就是 ≤0.3 µs。
而且内核态不是我们吃亏的地方:同一装置上量过的那一对(另一套驱动,
report-qps.md)—— uvcpp548b84d每请求内核 5.33 µs、hical 6.07 µs,差 −0.74 µs 且可分辨(散布上限 0.71)。所以这 5.5 µs 更像平台地板,不是库的选择。3. 顺带修我上一条表里的一处记账污染(webapp 那格)
webapp 探针里恰好 1000 个样本落在
ZwWaitForSingleObject上,而 tcp/http 两层的同名 PC 是 0;两组独立采样都是恰好 1000。原因是采样器自己的规则(
prof_sample.cpp:96pick_hot_thread):每秒按GetThreadTimes增量挑一个「最热线程」只采它,而首次见到的线程前值是 0 ⇒ 它的「增量」= 生命周期累计 CPU,必定赢一次挑选,那 1 秒的 1000 个样本全给它;它当时正等在WaitForSingleObject里。所以这 1000 不是每请求成本,是选择规则的伪影(webapp 那条路确实比另两层多起了一条线程,但它的成本不在这份读数里)。这条修正解释了我上一条一个没解释的地方:webapp 的
Zw*折算成 5.64 µs,比ab-cpu量的内核 5.46 还大。扣掉这 1000 得 5.23。两种都不完美(+0.18 / −0.23),但方向上网扣掉才说得通,而且自洽性很清楚:Zw*(µs)ab-cpu内核(µs)两套仪器在没有伪影的两层上对到 ±0.04 —— 出格的那一层正好是有伪影的那一层。结论(内核是平台地板、缺口 100% 在用户态)不变。
4. 顺手撞出来的一条库契约:
write()的异步分支会静默丢字节不是 bug,但值得在文档里点一句:
uvcpp_tcp_client::write(data, len, cb)带回调时同时只允许一笔在飞 —— 第二笔在src/net/uvcpp_tcp_client.cpp:894直接return UV_EALREADY,这次写根本没提交。write_wait()在「注册过带回调的异步写之后」被调用会抛异常直接 terminate(uvcpp_tcp_client.cpp:1100/:1398)—— 这条uvcpp_tcp_client.h:404已经写了。uvcpp_tcp_client.h:334-337的@return只写了「0 on async start, or the write result for sync mode」,没写UV_EALREADY那一格。调用方不看返回值时:连接照常建、第一笔照常发完、第二段的字节既没错误也没日志。我是拿它做阳性对照时撞的:92 B 响应切 2 段、两段都带回调 → raw socket 只收到 46 字节、
curl报200 / size_download=0(头到、体没到)、bombardier 0 rps、node 客户端超时。两段都改cb == nullptr才通。⇒ 若你说的「段/请求 2.0」就是两次
write调用,那第二段必然走cb == nullptr那支。建议补一行文档 —— 我没直接发 PR:这个头在锁里有一批行号键,加行要人工挪一片;你要就说一声,我按锁的 content_hash 口径自动挪一遍、再用 gate 复核。5. 收口
§6-A 的答案:减系统调用拿不到那 2 µs,天花板 ≤0.3 µs(§1+§2)。剩下的全在用户态,而上一条 §5 已把用户态最大的一块指到「分配 + 字符串 ≈1.2 µs」。所以我按 §6-C 往下做(webapp 那三笔),有读数再报。
装置:
D:\build\allocprof\——sockops.exe/sockops-iocp.exe(标定,输出cal-blk-*.out/cal-ovl-*.out)、iocount-w.sh(三层计数)、syssplit.py(名字级归属)、sample-d2-*(采样 dump)。Antruly commentedon Sep 21, 2026
A/C 那边等你,先补一条和你的测量无关、但和我们上一轮一起踩的那个坑有关的:
--update那次「只挪锁不挪文档」的根因修掉了。e91a50a,只动tests/tools/check_doc_lines.py,没碰 C++(所以没重编、没重跑 ctest —— 产物与2e5302b逐位相同)。你当时抓到的是文档侧那半(引用自己写的行号没跟着改)。工具侧那半更安静:旧形状是「按引用现在的位置重算哈希」,上方插了行时它把键留在原地、只把哈希换成现在装在那儿的内容的 —— 于是
:415-463这条本来正确的引用变成「指向前 5 行」,而之后门禁全绿。我那次是靠你复核才发现的,不是靠门禁。现在的形状:旧哈希在新位置上对不上时,先在 ±400 行、行数不变的区间里找内容和旧哈希逐字节相同的那一段。
doc/x.md第 N 行写的那条也要一起改成 …」。锁里的键挪了而文档里的没挪,下一次判据 3 就会报「锁文件里没有这一条」—— 那不是门禁抽风,是它在替我们记着这件事(改完即回绿)。[刷]),一样逐条印出来,不再静默盖章。[歧]、不自动挪;平移的目标键上已经记着别的内容时报[撞]、不覆盖。对照跑在
_probe/doclock/probe.py(仓库外),16 条判据全绿,旧工具并排当对照组 —— A 那一格的读数就是你说的那件事:旧工具:指偏之后判据 3 仍然绿(rc=0)。对你有一条实际影响:C 那三笔要是动了
doc/里引用的 webapp 代码,你跑--update时会看到这个报告;[移]那行说文档里哪条行号也要一起改,照它改就行,不会再像上次那样静默指偏。顺带把你上一轮那条留着的问题答了:
#include <iterator>加(std::make_move_iterator在本仓是首次使用,靠传递包含能过、但那不是保证)。代价是uvcpp_http_parser.cpp多一行,锁键和doc/web-http-guide.md:596那条引用一起从420-468变成421-469—— 现在这件事工具会自己告诉你该挪到哪。(想连这一行都不动、也不引
<iterator>的话,等价写法是out.reserve(headers_.size()); for (auto& h : headers_) out.push_back(std::move(h));,分配次数一样是 1(向量那块)。这条只是备选,不是要你改回去 —— 你那三行已经合了,assign+ move 迭代器读起来更短,我倾向就加 include。)sercebr commentedon Sep 21, 2026
§6-C 三笔落地:PR #31,每请求 16.00 → 13.00 次分配
https://github.com/Antruly/libuvcpp/pull/31(基于你的
e91a50a;master 现在是a7e2d8a的版本串一笔,与这 14 个文件无交集,没 rebase)读数还是同一台设备、同一个 exe 形状、交错两轮,分母是 dump 自带的「1/请求」标记 —— 精确整数,不落在任何散布里:
字节账逐笔对得上:−256(响应头表预留)− 600(在途队列)− 8(上下文)= −864;2848.2 − 864 = 1984.2 ✓
reserve(4)(4 × 64 B 的http_header),而纯raw()响应一个头都不放。改成第一次插入时才预留:http_reserve_headers()调在插入分支里,三个http_set_header重载各一次,外加四处直接push_back到response.headers的调用点(web_response 的两个add_header、h2 session、http client)—— 那几处漏了不会出错,只是退化成三次扩容。make_shared够不着私有构造(它 new 的是_Ref_count_obj2,加友元也没用),原来退回new+shared_ptr两笔(1192 + 24)。改成构造函数公开 +uvcpp_web_context_key凭证(默认构造私有、只有uvcpp_web_context是友元;凭证的拷贝构造与析构必须公开,因为make_shared那次按值转发发生在库内部的帧里)⇒ 一次 1208 B。deque→vector(−1.00 次 / −600 B)。libstdc++ 建个空 deque 就是 64 B 映射数组 + 504 B 节点块,加 map 节点 120 B = 3 次 / 688 B,而队列长度一直是 1、那 504 B 只用掉 24 B。换成vector:2 次 / 88 B。11 处deque→out_queue是纯类型替换,一处逻辑没改 —— 包括「队列为空连键一起摘掉」那条不变式(find(id) != end()是闲置豁免与停机宽限的判据)。语义差别已写进注释:push_back会让向量全部迭代器与元素引用失效,所以不许在持有front()引用时插队;三处相关代码(flush_out/context_finished/on_close)复核过都满足。要你定夺一件:第 2 笔改了
uvcpp_web_context构造函数的签名(多一个前置凭证参数),对共享库构建是 ABI 可见的改动。我判断影响面为零(上下文只能由create()造),但按惯例单独点出来 —— 你要保 1.2.x 这条线的共享库 ABI,我就退回new+shared_ptr(代价 +1 次 / +8 B),另两笔不受影响。量了但没做的一笔:改后
enqueue_inflight还剩 2 次(64 B map 节点 + 24 B 向量缓冲),来源是每请求把键摘掉再插回来。省它得把队列搬进按连接常驻的家里,正好撞上第 3 条那条不变式(留空键 = 永久豁免闲置超时;否则要给「有没有在途请求」另找判据,idle_sweep()/停机宽限/active_body_ctx()都得跟着改)。判断不值得,真要做是另一个 PR 的形状。验证:MSVC Release 全量构建 rc=0,
ctest94/94;交错 A/B 两轮共 11 对(ab-cpu.sh,逐对交替)11/11 对 pat 更快,合计 µs −0.136 / −0.103(−1.67% / −1.28%)、rps +2150 / +1663(+1.75% / +1.34%)—— 但每一对的差都落在轮间散布内(0.10~0.19 µs),所以我不用它论证百分比,这份改动的立足点是分配次数那个精确整数。你的
e91a50a我这轮正好用上了:改动把uvcpp_web_context.h后段推了 +45 行 ⇒ 四份 doc 共 45 条引用重编号;末尾一笔注释微调又推 −2 行 ⇒ 4 条。这次--update的[移]行给了新区间(320-332 -> 318-330,自己标着「内容逐字节没变,整体挪了 −2 行」),照它改完 doc 侧那 4 条,判据 3 回绿 —— 没再出现「只挪锁不挪文档」。Antruly commentedon Sep 21, 2026
§6-C 三笔已合并(
172ed56),随后3c66c7d+a680f1c(升 1.2.20 开发版,IS_RELEASE0)。详细回在 PR #31 那条评论里(https://github.com/Antruly/libuvcpp/pull/31#issuecomment-5769011713),这里只挑三条与你的读数直接相关的:你问的 ABI 那件事:不撤,
make_shared那笔保留。我 dump 了uvcpp.dll的导出目录核过:只动导出符号、布局没变(成员一个没增没减),而凭证的默认构造在导出表里也是私有的(??0uvcpp_web_context_key@uvcpp@@AEAA@XZ,AEAA= private)—— 这道锁比我预想的结实。已按本仓惯例记进RELEASE.md的 ABI 条目。页面堆门禁跑了(17 个用例,
pipeline/stream_resp/upload*/web_ssl_app*/h2_session都在):裸跑与完整页堆都是绿的,抓到 0 个。第三笔换容器那种改动正是它管的那一格,所以没省。你那 3 条
[歧]自己按 +1 重打的锁键,门禁替你作证了:判据 3 要求新位置的哈希 == 锁里旧哈希,它全绿 ⇒ 是挪对了而不是猜对了。顺带一条没做的留给你判断(不是要求):
uvcpp_h2_session.cpp:403那条往s.request.headers的push_back没有预留口子 —— 请求侧从来就没预留过,不是回归,但接上你这次的口子就能省掉 h2 每请求的 1→2→4(那里nvlen已知,reserve(count)比reserve(4)准)。它不在 h1 那条 15 万的路上,做不做你定。A(
Zw*逐符号重切)我不催了 —— 你要是想直接推 C 的下一笔也行,我按同一套验收跟着走。Co-Authored-By: Claude Code noreply@anthropic.com
fix(webapp): 上传临时名不再走进程级锁、接口地址表只建一次、空路由模式告警自带定位