静态下发剩下的 **2.00×** 用户态拷贝:`body()` 自存一份 + `to_string()` 头体合并成一条。 (本条是从 #14 里拆出来的。背景、测量方法、以及"拷贝放大比"这个指标怎么算, 见 #14 —— 那里已经量清楚了,本条不重复。快路径那部分已经落地:PR #15 / `00b1eed`。) ## 为什么它比快路径大一个量级 探针数出来的账(每上线一字节所付的用户态拷贝字节): | 响应体 | 基线 | 快路径之后 | 剩下的两份 | |---|---|---|---| | 4 KiB | 3.11× | 2.05× | `body_store` + `tostring` | | 256 KiB | 3.00× | 2.00× | 同上 | | 4 MiB | 1.00× | 0.06× | 走分片,不经过这两处 | 这两份**不是同一件事的两半**,是一次所有权设计的两个面: - `to_string()` 之所以存在,是因为要把头和体**合并成一条**才能交给一个 `uv_buf_t`; - 改成 `nbufs = 2`(头一条、体一条)就能不合并 —— 但异步写要求那条 `uv_buf_t` **活过完成回调**,而 `uvcpp_web_response` 今天是"体拷进自己存一份",响应对象析构后 体就没了。要让 `uv_buf_t` 指向它,就得让响应体变成**引用计数** (`shared_ptr<const std::string>` 之类)而不总是拷贝。 顺带解决探针发现的另一个结构性事实:**`uv_write` 的 `nbufs` 恒为 1.00** —— 散射聚集能力一次都没用上。 ## 爆炸半径 `uvcpp_web_response` 不只静态下发放用:**流式、压缩变体、h2 都过它**。 所以这是改一个所有出站路径都要走的对象的语义,不是加一条旁路。 ## 已记下的线索(不在本条的必做范围) - **TLS 路径比明文多两次拷贝**(#14 里作者只作为线索提,没做实测): `tls_flush_out()` 里 `take_ciphertext` → `tls_out_.append` 一次, `uvcpp_buf bufcpp(chunk.data(), chunk.size())` 又一次。第二次看起来可以用同一个 试发手法消掉(`chunk` 是连续的、`tls_out_` 本身就是成员 `string`),但 **部分接受之后新产生的密文不得插到余量前面** —— 这条次序约束得单独想清楚。 - 缓存命中的静态文件是最理想的情况(文件字节本来就在 LRU 里、生命周期够长), 这条路上 `body()` 那份拷贝能一起免掉。 ## 非目标 - 不动分片流式那条路(它本来就只有 transport 那一份)。 - 不为它加开关:这是所有权语义的改动,可配置等于两条语义并存。
Activity
sercebr commentedon Sep 20, 2026
把我这边欠的两件东西补上:按路径的拷贝普查,和你表里 4 KiB 那一格的更正。顺带量到一件我没预料到的事 —— 压缩把这条比值放大了 4~7 倍,而且热路径上那份文件拷贝是纯白付。最后一条与你刚落的 #11 变体缓存直接接上。
一、更正:4 KiB 那格是门槛前的旧口径
你表里写"快路径之后 4 KiB = 2.05×"。那是 #15 里无门槛那一版的数。落地的形状带 32 KiB 门槛(
UVCPP_TRY_WRITE_MIN_BYTES),所以 4 KiB 那一档根本没走快路径,三份拷贝一份没少:同一棵树、同一个二进制里量到的,探针里
tw_skipped每响应 +1.03、tw_attempted恒 0 —— 门槛把这一档整个让出去了,所以它在 4 KiB 上等于没开。这条对 #17 是好消息:最常见的档位上一份拷贝都没省,你在 #17 里能拿走的比表上写的更多。
二、按路径的拷贝普查(master
d5cbcbd= 1.1.24)装置重建在 master 上了(老探针树停在 1.1.14,master 现在是 1.1.24,中间隔了 #15/#16/#11 三条改动,那些数不能再用)。这棵树的形状是可核的:
git archive d5cbcbd+ 15 行插入 + 2 个新文件,0 删除,其余与 master 逐字节相同;全量ctest76/76。探针只加计数点,不改任何控制流。明文(不带
accept-encoding)—— 与上面那张表同口径:/__fixed?n=4096(纯内存,隔离磁盘/静态层 LRU)三件事被这组数钉住:
nbufs恒 1.00,七条路径无例外 —— 你说的第 ② 条在 master 上原样成立。body_store就是那份文件拷贝:4 KiB 档 4100 / 响应(1.01 次/响应),4 MiB 档塌到 0.05 次/响应(流式那条路不把整个文件装进 body)。它和tostring的可省部分,恰好就是 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 的两个靶子。transport(分片流式,每笔 256 KiB),body_store/tostring都接近 0。你的非目标里写了不动分片流式 —— 数字支持这个决定。#17 之后的可证伪预期(按这两个数字推):
nbufs=2中列那一格值得看一眼:4 KiB 档只做引用计数只从 3.11× 落到 2.11× —— 因为
tostring那份"头体合并"里还裹着一整个体。nbufs=2不是可选项,它是第二半。 64 KiB 档中列就到 ~1.00×(那一档的快路径本来就把transport吃干了)。前提写清楚:右列成立的条件是异步写那条路直接吃引用计数缓冲、不再拷进
uvcpp_buf—— 那正是引用计数要买的东西。如果只做引用计数、不碰nbufs,落中列。另外我这套探针数的是uvcpp_web_response::body()这个调用点;静态层一旦改成交引用(不再调body()),body_store会直接掉到 0,所以同一套装置可以原样复测,不用改判据。三、没预料到的:压缩把这条比值放大 4~7 倍
两组冷/热数(
accept-encoding: gzip,资产是 gzip 到 440 / 3796 字节的可压文本):(冷 = 20 个不同文件名、同一份内容的资产,每个都必然未命中;探针把「未命中数 == 请求数」当判据卡死,不是旁证。热 = 紧接着请求同一个文件,
cv_hits正好 100。)两个机制,一个比一个值钱:
① 分母塌了,分子没动。
body_store拷的是未压缩的原始文件,而上线字节是 gzip 之后的。web-4k.html4096 → 440 字节,body_store一项就占了 931.9% ~ 1726.6%。所以文件越大、越可压,这条比值越难看 —— 它量的其实是"压缩换来的分母缩小",不是拷贝变多了。② 热路径上那份文件拷贝是纯白付。 这是最扎眼的一条。命中时
uvcpp_http_server.cpp走的是:静态层刚把整个文件拷进
resp.body(4100.5 / 65540.5 字节/响应),下一行就clear()掉。命中率 100% 的那 100 次里,这一份一次都没被用过。它不是 #11 引入的 —— 变体缓存之前,这份拷贝的用途是"拿去 deflate";有缓存之后,命中时它的用途变成零。#11 的收益(省掉整个 deflate)是真的,代价是让这份拷贝从"必要"变成"白付"。四、与你刚落的那条变体缓存直接接上
我为这三处新加了探针点位(老探针一处都没有,因为 1.1.14 时还没有压缩路径):
compress_out——resp.body.clone(result.data),deflate 的产出拷回响应体,冷热都有,1× 压缩体;variant_hit——resp.body.clone(it->second.data),命中时从表里拷回来,热路径,1× 压缩体;variant_store——v.data.clone(resp.body),就是你注释里那句「存进表要再拷一份」,冷路径,1× 压缩体。所以 #17 的引用计数和这条缓存是同一条线的两头,而且方向一致:
variant_store那一份本来就是被注释解释为"不得不"的(响应自己那份还要发出去,不能 move)——引用计数正好解掉这个"不得不":表里存shared_ptr<const string>,存的是引用不是副本,那句话就不成立了;variant_hit那一份同样变成换个引用;resp.body.clear()前那句"刚拷完整个文件"也没必要发生。我的判断(你说过我定):先做 #17,不要反过来。 理由不是"哪个更重要",而是顺序:先在
uvcpp_http_server里局部造一个"响应体可以共享"的约定,等 #17 把所有权通用化时还得改一遍 —— 一次到位更省事。#17 整个落下去(body_store那份 +tostring里裹着的那份体都消掉)能拿走明文路径上 **64%(4 KiB)/ 99.8%(64 KiB)**的拷贝量,变体缓存那三处是它的免费副产品。压缩那条路上的预测:热路径 13.55× → 约 1.0×(body_store与variant_hit归零,tostring/transport缩到头)。一个要提前想的记账问题:
compress_variants_bytes_现在是"表内字节"的精确账(32 MiB 上限靠它)。body 变成引用计数之后,"表里存了多少字节"不再等于"表占了多少内存" —— 一个被共享的 body 可能本来就是活的。要么把上限改成按唯一字节记,要么明确接受它变成高估。这条不影响正确性,但会让人在调上限时算错。五、装置与判据
装置在
D:\build\census\(copysrv.cpp+measure2.js+census.sh+build.bat),跑bash census.sh复现全部数。它把老装置那几条硬前置检查都带过来了,因为它们全是踩出来的:端口被遗留进程占着会让两轮数一模一样地"成功";run/下的 DLL/exe 是拷贝,忘了对齐只会表现为数不对;copy_probe_stats是按值返回的结构体,加字段后旧 DLL + 新 exe 会读到栈上垃圾 —— 所以客户端里那条「reset 后body_store_bytes == 5且transport_bytes == tostring_bytes」的自检同时也是 ABI 检查。局限,写明白:探针树是 master + 15 行计数插入,不在上游,别人复核要走这套装置;全部走回环,绝对值(那几个 ns、MB/s)随机器变,但比值与压测端速度无关(计数器在服务端),所以上表里拷放比那些数是稳的。压缩那两组用的是可压文本资产(gzip 到 10.4% / 5.7%),换成真图片/已压过的资产会靠近 1.0× 那一侧 —— 这一条我按"最坏方向"给的。
Antruly commentedon Sep 20, 2026
一、4 KiB 那格的更正:接受,是我没跟着自己的改动更新表
你是对的,而且错得比"抄错了"更难看一点 —— 门槛是我自己在 #15 里加的,落地之后我没回头改 #17 那张表,
于是表里的"快路径之后 2.05×"是无门槛那版的数。探针里
tw_skipped每响应 +1.03、tw_attempted恒 0是硬证据:门槛把 4 KiB 这一档整个让出去了,它在那一档上等于没开。
所以这张表该按落地形状重写。而且方向对 #17 是好消息:最常见的档位上一份拷贝都没省。
二、普查数我认;并且"热路径那份文件拷贝是白付"我这边当场核过
不用探针也能核,两处对读就够了:
// src/web/uvcpp_http_server.cpp:923-924 —— 命中时下一句就把它 clear 掉 resp.body.clear(); resp.body.clone(it->second.data);中间没有任何一处读过那份拷贝。命中时它 100% 是白付 —— 与你的计数器一致。
三、排序:同意,先 #17,而且我这里还有一条独立理由
你说的理由(先在
uvcpp_http_server里造局部约定,等 #17 通用化时还要再改一遍)成立。补一条我自己的:我本来会想到一个"便宜的替代" —— 把变体表的查表提到
web_static产出 body 之前,命中时那一份文件拷贝就压根不发生。但那正是我在
apply_compression里钉死不许做的事:(那条注释护的是四个运行时旋钮的"改了配置不会发出旧结果":查表必须在"要不要压"这个判断之后,
否则"配置变了但表里还发旧结果"就成立了。)
所以这份浪费没有便宜的修法,只有 #17 —— 你这个排序与那条约束是同一个方向。
顺带,它给那条注释加了一条新理由:把查表提前不仅能发旧结果,还会让热路径多付一次白拷贝。
variant_store那句「存进表要再拷一份(响应自己那份还要发出去,不能 move)」的注释,等 #17 落地就是过期的绕行说明 —— 引用计数正好解掉那个"不得不"。到那天我会跟着改掉它,
不会让它比我描述的 bug 活得更久。
四、你提的记账问题:接受高估,不取"unique bytes"
我的判断是保留今天的"表内字节"口径,并把触发条件写进上限的注释。
理由是先把高估的范围缩小:body 变成引用计数之后,"表里存了多少字节"什么时候才不等于"表占了多少内存"?
只有一种情形 —— 两条表项引用同一个 shared body(两个 tag 对应同一份压缩产物,比如内容相同、
mtime 不同的两个文件)。单条表项引用的 body 反而更接近正确:响应释放之后,是表在持有它,
那笔内存就是表占的。
而为这一种罕事去维护一个指针集合,代价落在每次 store/evict 上;何况高估的方向是偏早淘汰 ——
内存少用一点,是安全的那一侧。所以我取"接受 + 注释写明触发条件",不取去重。
五、如果要做,我要的落地面
四条,前两条是形状、后两条是门槛:
shared_ptr<const std::string>或等价物)。现有入口(
body(const char*, size_t, ...)等)语义不变、仍是拷进来,另加一个"接管已有缓冲"的入口。这样对现有消费者不破坏 —— 与 三条接入与加固问题:req.path() 不规范化致前缀鉴权可被 // 绕过、请求头与 URL 无长度上限、使能宏不一致静默出错 #6 第 3 条那种"动公开头契约"的改动不是一类,不需要出包级验收。
nbufs = 2同批做。你那张中列已经把它钉死了:只做引用计数,4 KiB 档只从 3.11× 落到 ~2.11×,因为
tostring那份"头体合并"里还裹着一整个体。它不是可选项,是第二半。tests/tools/run_pageheap_gate.py)。改的是所有权语义,而所有权是 UAF 的高发区—— 裸跑绿不算数,本仓在这上面栽过。
compress_variant_stats()已经在(hits/misses/stored/entries/bytes),对应你探针的
variant_hit/variant_store。你说的"静态层改成交引用后body_store会直接掉到 0、同一套装置可以原样复测"——这个复测方式我认可,判据不用改。
六、一个前提我认,但想写明
你右列的 ~0.11× / ~0.0035× 成立的前提是"异步写那条路直接吃引用计数缓冲、不再拷进
uvcpp_buf"。这条我同意,但它是形状上的必做项,不是可选的优化 —— 只做引用计数不碰
nbufs,落你写的中列。所以上面第 2 条我写成"同批",不是"顺带"。
sercebr commentedon Sep 20, 2026
一、四条落地面收到,排序也收到(先 #17)
逐条认,并且说清楚哪几条在我这边兑现、哪条只能在你那边:
body(const char*, size_t, ...)/body(const std::string&)并存,不是替换 —— 这样「现有消费者一行不改」本身就是一条可核的判据。
nbufs = 2同批 —— 同意,而且我把你那句「它不是可选项,是第二半」读得更具体一点:只做引用计数会把节省停在中间那列,4 KiB 档从 3.11× 只落到 ~2.11×,离右列差一个量级。
所以「同批」的意义是不让中间状态被当成结果。
tests/tools/run_pageheap_gate.py在本机跑不了(没有 cdb,也没有 gflags)。和 perf(net): write() 先 uv_try_write 试发,吃得下的那部分不再拷进待发缓冲 #15/perf(handle): uvcpp_stream::try_write 不再拷贝一份 uv_buf_t 数组 #16 一样的分工:我这边交裸跑 ctest + 变异 + 端到端,
门禁由你跑。我不会把「裸跑绿」说成「过门禁」——这两句话在你这里不是一个意思,我知道。
compress_variant_stats()那五个字段够用;「静态层改成交引用后
body_store直接掉到 0、同一套装置原样复测」这条复测方式我照用。二、你那两处坐标我核过,结论一致
uvcpp_web_static.cpp:962-963先把整个文件拷进响应体,uvcpp_http_server.cpp:923-924命中时下一句就clear()掉 —— 中间没有任何一处读过那份拷贝,命中时 100% 白付。与我的计数器一致。三、那条「便宜的替代」确实被封死,我复核了你的理由
把查表提到
web_static产出 body 之前被否掉,理由是「配置改了会发旧结果」—— 成立(而且那正是apply_compression上面那条注释护的东西)。所以这份浪费没有便宜的修法,只有 #17,同意。两处注释的到期日我记下来了:
variant_store那句「响应自己那份还要发出去,不能 move」在 #17 落地后就是过期说明。到那天建议一次改两处,顺带把
apply_compression那条「谁要把查表提到门前,这条就破了」补上你新加的那半句理由(提前查表还会让热路径多付一次白拷贝)。你改或我改都行 —— 若你自己改,
我在 PR 里核这三处措辞与代码是否还对得上。
四、记账:接受高估 + 注释写明触发条件
同意,而且我认为关键是你把高估缩到「只有两条表项引用同一个 shared body」这一步 ——
它把触发条件从「可能有偏差」变成了可判定的(内容相同、mtime 不同的两个文件)。我会把这句话
写进上限常量的注释,并且不引入指针集合。补一条我这边能顺带做的:装置上加一格
「表内字节 vs 进程 RSS 之差」的观测,这样那种罕事一旦真发生,是看得见的,不用等它把内存吃歪。
五、我打算怎么落地(结构,供你先否)
先报一个我读到的事实,它决定手柄的落点:
uvcpp_buf今天是纯拷贝 API ——clone(const uvcpp_buf&)/clone_data(const char*, size_t)/clear(),没有共享路径,resp.body就是它(uvcpp_http_response.h:41)。所以「可共享的手柄」不是给已有东西换个用法,是新加的:uvcpp_buf内加一个可选std::shared_ptr<const std::string>(等价物按你偏好),get_const_data()/size()优先读它。所有既有 mutator 一律先丢弃手柄(clear()、clone*())——这保证现有入口语义逐字不变,也保证「共享之后又被改写」只会退化成一次拷贝,不会写坏别人的缓冲。
为了让这种退化可观测而不是静默变慢,我打算给它挂一个探针计数(沿用我们这套口径)。
这一条如果你想要硬断言而不是静默丢弃,请在 PR 上直接说,我按你的来。
body_ref(handle)一类)。静态层这边几乎白拿:
j->data本来就是shared_ptr,整份下发时递手柄 = 一次引用计数加一;partial(切片)留在拷贝路径上 ——!partial那道判断已经把这个岔口分好了。nbufs = 2:头的序列化与体不再合并成一个std::string,改成uv_write/uv_try_write递两个uv_buf_t(头 + 体)。体那个uv_buf_t的生命周期由写请求持有的手柄保证 —— 这正是「异步写那条路必须直接吃引用计数缓冲」那条前提的兑现方式,
你把它定成形状必做项我同意:不这样做,
nbufs就还是 1。compress_variant.data变成手柄,命中时递手柄而不是clone;存入时同理 —— 那句「不能 move」的注释到期就是这一步。
顺序:先做 1+2(静态层整份下发命中路径 + 未命中路径都走手柄),量一次;再做 3,量一次。
两次都用现装置(
body_store那条判据两次读数),这样「中间那列」的落点是被量出来的,不是推出来的 —— 也正好能验证我上面第 2 条说的「不让中间状态被当成结果」。
我先在探针树上做,不在 fork 上开 PR 直到本机自证(裸跑 + 变异 + 端到端)过了。
Antruly commentedon Sep 20, 2026
一、四条对上了;第 3 条确实只能我跑
分工照 #15/#16 办,门禁在我这棵树上跑。你的 PR 出来我就跑,结论只属于那个 SHA ——
我报的时候会连同 SHA 一起报,不会拿"上一版跑过门禁"顶账。
二、静默丢弃,不要硬断言 —— 理由是
clear()+clone()不是误用你要的这条我直接定:静默丢弃 + 计数器。
clear()紧接clone()是响应对象复用的正常序列(keep-alive 上同一个响应对象被反复填),不是误用。硬断言会把一段合法用法变成崩溃,而你想防的事("写坏别人的缓冲")已经被
shared_ptr<const std::string>自己挡住了 —— 那个const就是防线,不需要第二道。断言在这里加的不是安全,是约束了一个不该被约束的用例。
计数器我认,加一条判据:它在现有全套用例上的读数必须是 0,或一个能逐处解释的常数。
到处非零说明有地方"先共享又丢"—— 那不是错误,是一次白拷贝,但值得看见。
三、
nbufs = 2的措辞我改一下:不是"第二半",是"唯一一次能看到中间状态的机会"你说"不让中间状态被当成结果"—— 对,我把它写得更具体:
所以两次量我同意,但两次必须在同一棵探针树上、同一次进程内,否则两次之间混进了别的改动
就说不清是哪一步的功劳。(#15 那次的教训:结论只属于跑它的那棵树。)
四、记账:接受高估;你那格"表内字节 vs 进程 RSS"是对的加法
我上一条把高估缩到"只有两条表项引用同一个 shared body"这一步,你加一格 RSS 观测正好补上另一半
——它让"罕事一旦发生是看得见的",而不用等它把内存吃歪。这比我原来"写进注释就完了"强,
照做。
五、落地顺序与注释到期日
1+2 → 量 → 3 → 量,同意。两处注释你来改(你在那个 PR 里动得最多),我在 PR 上核三处措辞:
variant_store那句"响应自己那份还要发出去,不能 move" → 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 落地即过期;apply_compression的"谁要把查表提到门前,这条就破了" → 补上提前查表还会让热路径多付一次白拷贝这半句理由。
对不上我会在 PR 上直接指出来,不另开一轮。
sercebr commentedon Sep 20, 2026
一、1+2 步落地了,也量了:整份文件那份拷贝真的消掉了
先按你上一条给的加法式要求兑现:新入口
body_share()与现有body(const char*, size_t, ...)/body(const std::string&)并存,替换掉的一个没有。落点:
uvcpp_buf里加一个shared_ptr<const std::string> shared_手柄。capacity_ > 0= 自有块、capacity_ == 0 && base= 外部视图 —— 手柄是第三种,判据是shared_ != nullptr,跟这两种都不撞。
materialize():先拷、后放引用(顺序反了就是把那个 string 析构掉再 memcpy 它)。所有写入口(
resize/set_zero/get_data/get_udata/out_uv_buf)都先过它,move_buf()把引用一起搬走。所以"共享出去之后又被改写"不会写坏别人的字节 —— 这是共享能不能成立的那一条,不是优化。
!partial是护栏(见下面第三档)。一个施工期踩到的编译错,值得记一笔:
get_data() const里调materialize(),而它是非 const成员函数 →
error C2662。只把成员标mutable不够(那让成员能在 const 上下文里被换掉,不让非 const 成员函数能在 const 上下文里被调用)。最后是
materialize() const+buf/capacity_/shared_三个成员一起mutable,没有用const_cast—— 本类的const从来只保护"句柄的同一性"、不保护那些字节(既有的
char *get_data() const就交出可写指针),mutable只是把这个既有事实写明。你那边如果落法不同,这一处是唯一会因为 const 报错的地方。二、方法:同树变异 A/B,不是跨版本比
你说"想要那个数",但跨版本比(1.1.24 基线 vs 1.1.26+#17)会把 206 和掩码那两个改动混进差值里。
所以走的是同一棵树上只差一行:
const bool share_whole = false;(编译器把if折掉,与 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 之前的代码逐字等价)share_whole是那个条件判断编译开关、探针、资产、客户端、机器全是同一份,差值只能归给那一行。变异脚本对锚点带断言,
来回都幂等。另外为了让"前后"两臂可比,两臂各自跑了两遍 —— 确定性计数逐点一致
(DLL 哈希会变,但它不是内容判据:同一个源码连着链两次哈希都不一样,PE 里有链接时间戳)。
三、数(1.1.26,Release,单位 = 字节/响应;
share_ref= 这次"免掉"的拷贝)bytes=0-1023(反面对照)判据是一升一降、而且降在预期那处:
body_store每档正好降一份文件大小(253952 / 4096),
share_ref正好升同样多;tostring/transport/variant_hit/variant_store/compress_out一处没动。这两条同时成立才说明钱省在了该省的地方,而不是"合计变小了、不知道小在哪"。
两档对照臂逐点相同,我认为这比主结果的更能说明问题:
share_ref恒 0 而body_store非 0 ⇒!partial护栏在起作用。共享视图指的是整份内容,切片不能共享;哪天它也共享了,就是把整个文件当切片发、
头还声明着那一段 —— 这正是 响应压缩:HEAD 与 GET 的头不一致(HEAD 报未压缩长度,客户端照此读会挂住);另附两条容量观察 #11 里我们讨论过的同一类"头与体各说各话"。
四、两件我想单独说的事
1)248 KB 明文那一档落到了 1.00×,剩下的唯一一份是
to_string()。不是"还差一点",是结构性的:
transport那一份已经被 try_write 吃掉了(吃下 100.1%),所以明文大文件这条路上剩下的就只有头体合并那条报文 —— 而它本来就是发出去的字节,
不是白付。换句话说,明文大文件这条路上 #17 已经到底了。
2)热压缩那档剩下的 2.01× =
tostring+variant_hit——正是"第二半"(③④)的目标。冷压缩从 8.93× 掉到 3.01×、热压缩从 7.93× 掉到 2.01×,差额全部来自
body_store。热路径上
variant_hit(命中拷回)那一份还在:#17 的 1+2 只碰了"从文件到响应体"这一段,没碰"从变体表到响应体"那一段。它要 ④ 才能消掉。所以我把 ③ 和 ④ 当成一次量、不是两次。
五、一个必须说清的副作用:这是二进制 ABI 变更
我这边先在探针树上自证,中间撞到一次看起来像 bug、其实是我自己的错的现象,但结论对你有用:
uvcpp_buf加了shared_成员、materialize()从非 const 变成 const(改名的)——结构体布局和符号名都变了。只把新
uvcpp.dll换进去、不重编消费者,会炸:旧的测试 exe + 新的 DLL → 局部
uvcpp_buf按旧尺寸开栈帧、DLL 的构造函数按新尺寸写→ 退出码
0xC0000409(栈越界)/0xC0000374(堆损坏),在 ctest 里表现为0.01 秒、一行输出都没有(这类失败最像"环境问题",我第一次就误判成 flaky;
把 exes 重编一遍 → 76/76,一次不差)。
所以:"加法式"在源码层成立,在二进制层不成立 —— 现有消费者一行不改是对的,
但不重编不行。这条要怎么处理(版本号 / 说明 / 放不改的成员位置)由你定,我只是把它
量出来了。我认为该说明而不是该回避:这个库是 DLL 分发的,静默的 ABI 破坏会以
"别人的程序崩在一行莫名其妙的代码上"的形式出现,那种报告最难查。
六、本机状态与下一步
76/76全绿。探针树:76/76全绿。nbufs = 2,量一次。queued_write必须持有手柄——我把响应析构、写请求还指着那两个
uv_buf_t的时序专门查过:不持有就是悬空指针,而不是"拷贝还在"。然后 ④ 变体表按手柄存/取。
run_pageheap_gate.py(本机没 cdb,跑到哪步我会写清楚)。docs(web): 两处注释到期(按你 04:22 那条点名的两处)
sercebr commentedon Sep 20, 2026
一、③ 落地了:PR 在这里,门禁请跑这个 SHA
PR #19,head
fdccc32e40bb3b3cb503bf5e567fd1cc9c2d05b(分支sercebr:perf/response-body-refcount)。①②③ 三步 + 新测试 + 你 04:22 点名的那两处注释都在这一个PR 里。
/workspace/libuvcpp上那个run_pageheap_gate.py跑的就是这个 SHA,不是之前那个aaa9d8f。按你 §三 的要求落的方法:两臂是同一棵树、同一份二进制、背靠背跑,唯一差异是一个环境变量
(
UVCPP_NBUF2_OFF=1= 头体先合并成一块再写,也就是 ③ 之前的形状;不设 = 两块交出去)。编译开关、链接、探针插点、资产、客户端、机器全同一份,差值只能归给那条分支。臂的物证是服务端自己
报的
nbuf2_off与探针上uv_write的平均 nbufs(前恒 1.00 / 后恒 2.00,六档逐档核过)。说清一句:我是两个服务进程(同一个 exe)依次跑完两臂,不是字面意义上"一个进程里量两臂"——
同一棵树、同一轮、中间不夹别的改动这个意思我兑现了。你要的若是字面的一进程内两臂,我把开关
做成请求级的(
/__arm?nbuf2=0|1),一句话的事,你说要我就做。二、数(字节/响应;比值 = 用户态拷贝合计 ÷ 上线字节)
body_storebytes=0-1023(反面对照)!partial护栏没变)compress_out+variant_storevariant_hit(= ④)六档的
tostring(头体合并那次拷贝)全部归零;③ 碰不到的那几行(body_store/compress_out/variant_hit/variant_store/share_ref)逐字相同 —— 这两条同时成立才说明省在该省的地方。后臂的 248 KB 明文落到 242 B/响应,那就是头部本身,不是"还差一点"。
三、你定的那条判据:计数器的读数
按你 04:22 §二 落的:共享视图被写时静默物化(
clear()紧接clone()是复用响应对象的正常序列,那个
const就是防线),但计进uvcpp_buf::share_discard_count(),只在那一支 ++。全套用例的读数:77 个用例,只有新测试自己是 1(那 1 就是它 E5 里故意"先共享又丢"那次),
其余 76 个全是 0 —— 包括整个 web/静态层/压缩那一片。那个 0 是逐档看过的,不是"没量到"
(临时在库退出时打印、量完已撤,没留在 PR 里)。
变异验证它能咬:把热路径取字节从
get_const_data()改成get_data()(该只读的地方拿了可写指针),三条判据同时红,其中就有这条。另一次变异(
hold传nullptr)红的是引用计数那条。四、其余两份力气的落点
variant_store的"不能 move"补上"≠只能拷"(表里存句柄就是 ④);
apply_compression的"谁要把查表提到门前,这条就破了"补了后半。但第 2 条这一半我按"我量到的那条机制"落的笔,可能不是你说的那条 —— 我写的是:提前查表换不到你要的
东西,热路径上那次白拷贝的成因不是"查表太晚"而是"命中判断晚于身体就位"(静态层先把整份文件
拷进
resp.body,命中那一支下一句就丢掉),而查表早不了(键要的cache_tag与门槛要看的body.size()都是身体就位之后才有的)。你要指的是另一条,PR 上直接指出来,我改。ctest -C Release:77/77(改动前 76/76)。探针树同样 76/76。五、这个 PR 唯一的接口变化
send_response由void改成返回"这次上线的 body 字节数"。以前访问日志是调用之后回头读resp.body拿到的;头体分开走后体会被移动出去,再读一次静默变成 0(gzip 的响应会记成 0字节,比"记成压缩前的长度"更难看)。所以改成由函数返回。
uvcpp_buf那边加的是加法式入口(
share()/body_share()/ 两个append_uv_buf_*/ 那个计数读数),既有入口一个都没动。六、④ 是什么(不在这个 PR 里)
热压缩那 1.01× = 变体表命中时那次拷回。把它消掉就是表里存句柄、命中时递句柄。它要动
compress_variant的持有形状,比前三步更容易踩到"共享出去之后被人改了",所以单独一轮。Antruly commentedon Sep 20, 2026
收到,PR #19 进队列;先回你 §五 那个 ABI 问题
按「必须重编」处理,不藏。
shared_挪到一个不改变布局的位置」:uvcpp_buf是公开类,谁按值持有它都受影响,挪位置只能推迟、不能消除,而且会让代码形状变怪。透明说明比隐藏便宜。
RELEASE.md里单开一条写明:本版uvcpp_buf的布局与符号名变了,换 DLL 而不重编消费者会崩(
0xC0000409/0xC0000374,在 ctest 里表现为 0.01 秒、一行输出都没有)。现有消费者源码一行不用改,但必须重编。
你 §五 里那句「这类失败最像环境问题」我这边有同款记录 —— 本仓的门禁规则里就有一条
「
0xc0000409先当产物问题、别当代码」。所以你把它量出来这件事本身有价值:以后遇到「换过 DLL 没重编」就有了可查的形状,而不是每次都从 flaky 查起。
另外两点,我同意你的读法
to_string()是发出去的字节,不该算进「白付」。「响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 在明文大文件上已经到底」这个结论我接受。
variant_hit和tostring是同一件事的两头,分开量只会得到两个都不完整的数。
我这边现在在跑的门禁
fdccc324这个 SHA 上:默认全量 Release 构建 →grep -c "error C"归零 →ctest全绿→ 页面堆门禁(这次的写所有权契约是它的主场)。跑完在这条下面报数,没问题就合。
顺带说一句我这边的进度:#6 第 3 条(使能宏,也就是你说的那个「静默出错」)我已经做完并
在跑本机验证了,走的是「生成头 + 编译期硬失败」那条路。它和 #17 不冲突,但会让消费者
必须重编这件事多一个同类 —— 两个都会写进同一批说明里。
sercebr commentedon Sep 20, 2026
④ 落地了,也量了(按你说的「③④ 一次量」那个表报)
分支
perf/variant-handle,在我 fork 上。先不开 PR:等 #19 合了我就 rebase 到 master 再开—— 现在开的话 diff 里会含 #19 那一整坨,而你的门禁只该对单个 PR 的 SHA 负责。你要是想现在
就看,说一声,我立刻开。
改了三处(
compress_variant的持有形状:uvcpp_buf data→shared_ptr<const std::string>):命中
clear()+clone(v.data)→resp.body.share(v.data);压出来的产出落进一个共享串,响应与表共用它;存表存的就是同一个句柄(以前 clone 一份,理由是"响应自己那份还要发出去")。淘汰与上限
按新的
bytes()记字节 —— 这一处必须跟着换,忘了的话字节账恒为 0,字节那条淘汰腿就静默瘸了(条数那条还在,所以"能淘汰"这件事不会立刻露馅,只在大文件上悄悄失效)。
量到的(p4 量测树,同一份二进制 +
UVCPP_VARIANT_HANDLE_OFF一个开关分臂;六档里只有压缩那两档动):其余四档逐字不变(静态 4 KiB 0.06×、静态 248 KB 0.00×、内存 4 KiB 1.03×、Range 1.28×),六档的
tostring全为零。冷那档剩下的那一次compress_out仍然在 —— 它是 deflate 的产出落进那个共享串(冷路径一次性的事);热那档只剩 242 B/响应的响应头 —— 那一档到底了。
报文的字节两臂 md5 相同(同一臂里冷热两次也相同):装置数的是拷贝,不是把字节换掉。
判据(
variant-ab.sh+variant-compare.py,六档全量 + 逐档结构 + 臂的物证):④ 碰不到的行与四档非压缩合计逐字相同;压缩那两档的降幅正好一份体(只说"降了"不够 —— 降半份也是降);
后臂【热】里没有
variant_hit行、【冷】里没有variant_store行(是"那一行没有了",不是"变小了");
share_ref(挂在uvcpp_buf::share()上的独立探针)恰好升一份体;两臂平均nbufs 全 2.00(③ 没被碰到)。臂的物证是服务端自己报的
variant_handle_off—— 环境变量没到服务端时两臂会一模一样地"成功",那是最贵的一类失败。
PR 树这边:
ctest78/78(77 + 新用例)。新用例buf_share_handle_func守的是这件事带来的三条契约,都不是性能:
(
share_discard_count()+1),clear()之后再写(你说的那个正常复用序列)= 一个字节都没白拷,所以不记账(判据写成 +1 的话就是在钉一个错的账);
必须撑住的东西,反过来(响应先走)同理。
另外把
web_app_static_func里那条s.bytes > 0换成算死的账:表内字节数必须正好等于那三次响应的 Content-Length 之和(换句柄之后账还得对)。
变异验证(每次往返逐字节核对过):
bytes()归零 → 账那条红;命中那条路递空句柄 → 三条红(含"第二次的字节与第一次逐字相同")。
一个和你那条「
0xc0000409先当产物问题」同款、但更阴的坑(是本仓的,不是我装置的)copy_test_dlls是个ALL目标:按单目标构建不会刷新测试目录里那份 DLL(--target test_xxx只建那条依赖链)。于是"改一行 →
--target重建 → 直接跑 exe"会静默跑旧行为:不崩、不报错,断言全绿而改动其实没生效 —— 我这轮就差点拿旧 DLL 去验变异,是那条"该红却没红"的判据把它撞出来的。
你门禁那条"默认全量构建"正好躲开了它(
ALL目标会跑)。写出来是给以后按单目标调试的人留个形状:跑之前核一下 exe 旁边那份 DLL 的 md5 与构建产物是否一致。它和你说的
0xc0000409是同一族("失败像环境问题"),只是它连崩都不崩。
test(tcp): 头体两块写(nbufs = 2)与它必须持有的那块字节
docs(release): 写明 1.1.28 起 uvcpp_buf 布局变了、必须重编(#19 的升级注意)
Antruly commentedon Sep 20, 2026
#19 合了(
cb9960d,1.1.28 开发版)—— rebase 开 ④ 吧不用等我。合的时候我在根提交里写了:
uvcpp_buf的布局变了,所以 ④ 要 rebase 到新master、别基于旧点开。你那条"现在开的话 diff 里会含 #19 一整坨"的顾虑,正好靠这次合并消掉了。
先不开也没关系,但没必要再等 —— 我在 #19 的评论里已经把 ① ② 记下来了,那批跟 ④ 不冲突。
你那条"字节账必须跟着换",我认这个形状
「忘了换 ⇒ 字节账恒为 0 ⇒ 条数那条腿还在,所以"能淘汰"不会立刻露馅,只在大文件上悄悄失效」
—— 这条比数字本身值钱:一个仍会通过的旁路判据把主判据的失效盖住了。我这边有个同款的
坑记着:
uv_run/loop_close当泵的判据会把"其实没泵到"盖成绿。所以 ④ 的那个表我想看到的是字节那条腿自己的判据(
bytes()随淘汰单调降),而不是"淘汰发生了"——后者在条数腿还活着的时候是绿的。
提醒一处重叠,免得撞车
我随后要单独提的那批里有一项(#19 评论里的 ②)方向和你 ④ 的重叠:
clear()+clone(v.data)→resp.body.share(v.data));clone*那一族往共享视图目标里写时,materialize()会先把目标那份共享内容整份拷一遍、紧接着被 memcpy 整份覆盖 —— 白付。
两条不冲突(② 是
uvcpp_buf的通用行为,别的调用点还在),但如果 ④ 先合,压缩命中那条路上的受害者就没了,② 我只会拿别的调用点做判据。你 ④ 照开,不用为这个改。
我那批会先变异证明缺口是真的再动(本仓规矩),所以不会出现"改完才发现原来没这回事"。
perf(web): 压缩变体表按句柄存/取(存表与命中各少一次整份体拷贝)
sercebr commentedon Sep 20, 2026
④ 开了:PR #21,门禁请跑
efad491按你 06:43 那条 rebase 到
cb9960d之后的新 master(基线b7d82a6),故意让 diff 里只剩 ④ 这一坨:
+416/−16,四个文件(uvcpp_http_server.cpp/.h+buf_share_handle_func.cpp+web_app_static_func.cpp)。ctest -C Release本机 78/78。那条过期注释也一起到期了 —— master 上
src/web/uvcpp_http_server.cpp:981-983现在还写着「今天仍然照拷:形状没变……那是第 ④ 步」,本 PR 删掉。
你要的那条腿:
test_variant_byte_account_on_evict钉的是字节腿自己的判据,不是"淘汰发生了"。做法是把条数上限跑满:存 1100 条
(键不同、体完全一样,于是每条压缩后都是同一个长度 C),开始淘汰之后两条腿分开钉 ——
kCompressVariantMaxEntries);我特意写成"== 剩余条数 × C"而不是某个算死的常数:钉的是那个上限存在,换个压缩长度
也照样成立。旧的
test_compress_variant只有三条 entry、一次淘汰都没发生(bytes = 124就是三次响应长度之和),对"淘汰时少减一次"零信息量 —— 缺口就在这中间那段。
变异验证(删掉
compress_variant_evict()里那句-=)正是你认的那个形状:一个仍会通过的旁路判据把主判据的失效盖住。
顺手记一个坑:这条变异第一遍是假绿
删完那行单目标编
test_web_app_static_func,跑出来读数一点没变 —— 差点当成"这条腿本来就没人管"。原因是单目标构建不刷新
tests/functional/Release/uvcpp.dll(copy_test_dlls是个ALL 目标),exe 配的还是旧 DLL。核 md5 才看出来(旧的 13:14:55、新的 14:59:02),走全量
构建才复现。"绿色"和"这条判据到底跑没跑"是两件事 —— 跟你那个
uv_run/loop_close当泵的坑是同一类,所以写进 PR 正文里了。
② 那处重叠
按你说的 ④ 照开、不为此改。这里只是确认一遍我理解没偏:④ 合了之后压缩命中那条路上的受害者
就没了,② 拿别的调用点做判据。
sercebr commentedon Sep 20, 2026
④ 换了个 SHA:
0d94f2e(连带你可能会更关心的一条:跨 64 KiB 边界的请求会被丢掉)1. 先说 ④ 本身
我上一版把 macOS 的三个 job 全跑挂了(
h2/ssl/web,都是test_web_app_static_func**Timeout 30.04 s)—— 是我那条新用例的**耗时**,不是判据错了: 它要真打 1100 条请求,而我一版是一请求一连接,Windows 上每条连接约 4 ms、总共 4.5 秒; 本文件其余用例本来就要十几秒,macOS runner 再慢一点就顶穿了 CI 那条--timeout 30。 (顺带说明:basic/full` 那两个 macOS job 过,是因为它们没有跑到这条用例。)现在改成每批 20 条走一条连接(批内最后一条带
Connection: close):0.37 秒,1100 条一条不落地回、一条不落地进表。判据本身一个字没改,变异往返重跑过一遍,
还是那个形状:
本机
ctest -C Release78/78(总时长还从 123 s 降到 107 s)。分支已 force-push,head
0d94f2e,PR #21 的正文也同步过了。2. 但为了把它塞进 30 秒,我撞到了另一个东西 —— 这条我觉得比 ④ 重要
中间我试过最省事的那版:把 1100 条一口气灌进同一条连接(HTTP 流水线)。0.2 秒,
但它丢请求,而且 100% 可复现:
GET /vb/<i>(全 keep-alive,末尾跟一条Connection: close的终止请求)打进一条连接 ⇒ 固定丢第 745 条。它的字节区间是
65450..65538,正好盖住 65536。服务端只回 1099 条
/vb/的响应,那一条既没响应、也没进变体表(stored也是 1099)。连跑三次,每次都是 745。
65522..65634),而且它之后整段都不回(只回了 586 条)。跟着边界走 ⇒不是"某一条请求有问题",是跨在单次 read 边界上的那条被丢了。
uvcpp_tcp_client::internal_alloc_cb用的就是 libuv 建议的尺寸),所以看着像是解析累积那一侧:一条被 read 切断的请求,后半段没有被接上/被丢掉了。
我没往代码里再挖(那是另一条线,我手上这个 PR 不该混进去),所以给你的是一份
可复现的现象 + 边界证据,不是根因。要不要我:
(a) 单开一个 issue 记它,(b) 补一条最小的复现用例(比如"一条连接里只发一条、但故意
让它跨 64 KiB"),还是 (c) 你自己看 —— 你说一声,我按你说的做。
注意这一条跟 ④ 无关、也不被 ④ 的判据覆盖:④ 那条用例改成小批之后每条都回、每条都存。
我把这段也写进 PR #21 正文了,免得它读起来像 ④ 的一部分。
4 remaining items
Antruly commentedon Sep 20, 2026
收到
60f4e84,门禁在跑,这一版我按 merge commit 落三点:
1. 超时那条你自己提了,我不重复加。
我核的是它对门禁的影响(见第 2 点),不是改它的内容。
if(TARGET ...)这个写法我认同:set_tests_properties对着不存在的测试名会在 configure 期硬报错,而 WEBAPP=OFF 时这条确实已被上面的过滤器摘掉。顺带一个我这边核过的数:全树此前一条
TIMEOUT属性都没有(
tests/functional/CMakeLists.txt与tests/unit/CMakeLists.txt两处 grep 皆空),所以这是第一条、也只动这一条,其余用例的检出时间确实一点没动。
2.
60f4e84相对c851ec2只动tests/functional/CMakeLists.txt(+17),src/零改动。所以我把正在跑的那遍页堆门禁的结论直接搬到最终合并的 SHA 上,理由是可当场复核的:
run_pageheap_gate.py不走 ctest —— 它按ctest_list拿到用例清单之后直接起 exe(基线 180 s / 页堆 900 s 是自己给的预算),那个
TIMEOUT 90属性门禁根本不消费;再加上库侧零改动,门禁的输入与我要推的那棵树逐位一致。
先把话说清楚,免得回头看见门禁结论挂在"旧 SHA"上,以为是漏跑了一遍。
3. 落地方式:
--no-ff的 merge commit(本仓 #9、#19 都是这么落的),你那三条commit 的内容原样保留,我不改写你的提交内容与注释;version 另起一条
chore(version):升到 1.1.31(本仓规矩:功能推送升一档 patch,IS_RELEASE保持 0)。合并完我把 merge SHA 和门禁结论一起贴上来。在那之前你还要推的话先说一声 ——
推了我这边就得把合并重做一遍(今天已经重做过一次了)。
sercebr commentedon Sep 20, 2026
门禁结论落在
60f4e84:18/18 全 success(run35498002335)含三个 macOS job 与
mingw64。test_web_app_static_func这条现在跑在自己的 90 s 预算里,但耗时本身没变(窗口 16.5~21.6 s,最紧的仍是 Windows
web那条)—— 属性改的是上限,不是让它变快。所以按你 07:39 的口径:这条以后不会再随时红时绿,一旦它真红就是真挂死。
sercebr commentedon Sep 20, 2026
这分支我不再推了,你合吧 —— 另外你第 2 点我自己核过一遍,站得住
先答你问的那句:不再推了。
60f4e84之后我没有待推的东西,合并不会再被搅第二次。一处精度,免得你回头数出个"怎么多一条":
b82c44a..60f4e84是 4 条,不是三条 ——25efc94实现 /0cde15b句柄契约用例 /c851ec2淘汰字节账用例 /60f4e84超时预算。你说的"三条"大概是 ④ 那三条;
60f4e84是第 4 条。你第 2 点我没照单全收,独立核了一遍(结论一致)
git diff --stat c851ec2..60f4e84⇒ 只有tests/functional/CMakeLists.txt,+17;git diff c851ec2..60f4e84 -- src/⇒ 0 行。tests/tools/run_pageheap_gate.py:清单取自ctest_list.ctest_tests()(那函数只取名字/exe 路径/工作目录),跑的时候是
subprocess.Popen([exe_path])直接起进程;预算自己给(
BASELINE_TIMEOUT_S = 180/PAGEHEAP_TIMEOUT_S = 900),全程不调 ctest。所以那个
TIMEOUT 90属性门禁确实一点都不消费它 —— 你把结论搬到合并 SHA 上,站得住。TIMEOUT属性都没有":grep -rn TIMEOUT tests/ --include=CMakeLists.txt与根CMakeLists.txt+cmake/*.cmake都只有我这一处。数是对的 —— 这是第一条,也只动这一条。
其余
--no-ffmerge commit + 另起一条chore(version):到 1.1.31(IS_RELEASE=0):收到,没问题。我那 4 条的内容与注释你原样留就行。
(issue + 最小复现用例)。
bytes()没有编译期保护、以及"共享出去就不能写"这条契约现在靠静默物化 + 计数器兜着(你 04:22 定的口径)。不急着现在答 ——
合并后你要是倾向在类型上收紧,我另开一条,不往已合的东西上叠。
Antruly commentedon Sep 20, 2026
已合入
master:dcfbd27(merge commit)+86ed312(版本 1.1.31)60f4e84按--no-ff的 merge commit 落地,两条父提交是10c0230(master)与60f4e84(你的 head)。你那四条 commit 的内容原样保留,我没改写。这一版我跑过的:
error C/error LNK/C1060皆 0build-webappctestbuild-h2(OPENSSL+NGHTTP2)重编+ctest86ed312)35498833503在跑,完了我把结论贴上来门禁那条多交代一句,免得回头看着像"漏跑":
run_pageheap_gate.py不走 ctest ——它按
ctest_list取到用例清单之后直接起 exe(基线 180 s / 页堆 900 s 是它自己的预算),所以你那条
TIMEOUT 90属性门禁根本不消费;再加上60f4e84只动tests/functional/CMakeLists.txt、src/零改动,门禁的输入与推上去那棵树本来逐位一致。我先把这层说清楚,是这样还不够、我还是在重编之后的产物上又跑了一遍 —— 两遍都 rc=0。
顺带一个数:
test_web_app_static_func现在本机跑满约 17 s(CI 上 ubuntu 16.5 / macOS 19.1 /Windows 21.4),新预算 90 s 是 5 倍余量,够厚了。
CI 一过我就在 PR #21 上留一句合并说明并关掉它;head 分支
perf/variant-handle你留着或删都行。
sercebr commentedon Sep 20, 2026
那条 64 KiB 的我没等你点头就先做了 —— 单开成 #22,而且根因定位到了
你说过"要不要开你说一声",我这边问过两次没等到回复,就按"能自证就先摆出来"办了:
#22(
https://github.com/Antruly/libuvcpp/issues/22)。一句话结论:服务端在每次读的开头无条件
ctx.parser->reset()(on_connection_data的if (ctx.msg_done)分支),而msg_done的语义只是"之前有报文完成过" —— 于是流水线时那条跨在读边界上的报文,半个解析状态在下一次读进来之前就被
llhttp_init抹掉了。ll_on_message_begin里你自己的注释写的正是相反的前提("少一个llhttp_init:llhttp自己会处理缓冲区内的连续消息,这正是流水线能跑通的前提"),包装类那边对、服务端这边把它补回来了。
两向都量过:一口气灌 1100 条 ⇒
stored=1099(静默丢一条);只把判据改成ctx.msg_done && ctx.parser->get_state() == COMPLETE⇒ 绿;全套 79/79 不动。另外单独链
llhttp.lib的探针把两种现象都解释了(断在末尾空行 ⇒HPE_OK+ 正好丢一条;断在头部中间 ⇒
HPE_INVALID_METHOD⇒ 400 关连接 ⇒ 之后整段不回)。不影响 #21 合并:这些实验都在
60f4e84上做,做完两处改动逐字节还原、git status干净、79/79 复跑过。修不修、怎么修你定,我没提交也没推。
Antruly commentedon Sep 20, 2026
CI 结论:
86ed312的 run35498833503—— 18/18 全 success,包含之前挂掉的那几个 job(macOS 的
web/full/h2都在里面)。门禁 + 本机两棵树 + 全平台 CI 都过了,这个 PR 到此为止。多谢。
sercebr commentedon Sep 20, 2026
收到,这条收尾了。我这边没有待推的东西:
perf/variant-handle我留着(停在60f4e84)——它通过门禁的那个 SHA,做个指针;哪天你想清就删,我不介意。
git diff --stat 60f4e84 upstream/master在我碰过的那几个文件上是空的 ⇒ 与过门禁的那棵树逐字相同。所以页堆结论那一层不用再复述第二遍。
修法都在里面)。修不修、按那个判据修还是你有更好的,都你定 —— 不催。
Antruly commentedon Sep 20, 2026
补一句收尾:你点名的那条 #22 已经修了(
3b7f2ac,1.1.32)——它不是一行,你那个候选判据只盖住一半,两半的实测对比写在 #22 里。另外我在 #22 上更正了自己一处把
msg_done因果写反的说法(条件和我说反了:坏的那一刻恰恰是
msg_done已经为真),一并记在那里。perf/variant-handle我不动 —— 它是你留的指针,清不清由你。sercebr commentedon Sep 20, 2026
我这边复核完了 ——
#17可以关,零待办四步(①②③ 的 PR #19 + ④ 的 PR #21)都合进 master 了,合并后 CI 18/18,我核过合进去的字节
与通过门禁的那个 SHA(
60f4e84)逐字相同。四步的收益我也都重量过一遍:248 KB 明文1.00×→0.00×、静态 4 KiB 2.12×→0.06×、压缩冷 3.01×→1.01×、热 2.01×→0.01×。
我这边没有待推的东西:
perf/variant-handle停在60f4e84只当指针用,你想清就清。这条随时可以关。
Antruly commentedon Sep 20, 2026
收尾:你那三条我按「让那个不变量不存在」做了,另外顺着你 07:04 那条往下挖了一层
四步(①②③ 的 PR #19 + ④ 的 PR #21)你都复核过了。
dcfbd27之后我这边又做了一轮收尾,三条,都在
1.1.34里。先说结论:你说的"随时可以关"我认,这一版落完我就关。一、你那条「忘了换 ⇒ 字节账恒为 0」:我没有把它补得更牢,而是把那个账删掉了
compress_variants_bytes_这个计数字段不存在了 ——stats().bytes与淘汰循环的while条件都改成
compress_variant_total_bytes(),从表里求和。你那条的形状(一个仍会通过的旁路判据把主判据的失效盖住)我 06:43 就认了,所以补法
不是"把
-=补得更牢",是让**"账"这个东西不存在**:没有存起来的账,就没有"存入那条路与淘汰那条路各自记得同步"这个不变量,
+=/-=/ 度量的本体三处收敛成一个表达式,"漏了一次减法"这一类从此写不出来。
一句诚实的边界(我怕被读成比实际更强):这不等于"表达式写不坏"。
return 0;照样编得过。剩下那个表达式的失效由三条判据钉住,关键是两边来源不同 —— 左边是表里
那些句柄长度求和,右边是线上实测的
content-length。所以"度量错对象"(拿源长度、拿
uvcpp_buf::size())、"累加写成赋值"、"返回常量"都会红;而如果哪天有人把右边换成由s.bytes推出来的量,这条就成恒等式了 —— 这句我写在判据旁边的注释里了。代价也写进注释:淘汰循环每轮重算一次求和。没有换复杂度类 —— 原来那句线性找 LRU
本来就是每轮 O(n),只是常数翻倍。顺带修掉两处过期措辞:一处点名了被删的成员(留着
就是个指向不存在符号的注释),另一处写着"条数很少(上限数量级是『几十』)"而常量实际是
1024 —— 那一句恰好是"线性找 LRU 够用"的成本论证,而派生求和的成本论证依赖同一个 n,
所以它是低估的。
二、顺着你 07:04 那条往下挖一层:字节上限(32 MiB)本身此前没有任何判据
test_variant_byte_account_on_evict把条数腿跑满了 —— 但它的条目是 8 KiB 的'x'、压完约 43 B,存 1100 条也只有约 44 KB,是 32 MiB 上限的 0.13%。也就是说:
这个我先量了再动(本仓规矩)。实测那个变异:
test_variant_byte_account_on_evict绿、
test_compress_variant绿 —— 缺口是真的,不是推出来的。你那个变异证明的是算术那一半(
-=记得维护),"上限存在"这一半至今没人守,两者要分开守。新用例
test_variant_byte_cap_on_evict专门去摸那条腿,且完全不碰条数腿:12 条 × 4 MiB(正好等于单条上限,那头是
<=,所以这是能造出来的最大条目)的近不可压数据(94 个可打印 ASCII 上等概率取字符,压完约 82%)⇒ 约 40 MiB > 32 MiB,
而 12 < 1024 ⇒ 条数腿一声不响。
"压不动"是这条用例的关键前置,不是随手写的:换成
std::string(4 MiB, 'x')的话单条只压出几 KB,永远摸不到 32 MiB,这条就退化成一条恒绿的装饰。反过来说,字节上限也不是
死代码 —— 它只在"大文件 + 压不动"时才是主角,而那正是它被设计出来要封的那件事。
变异两态都跑了:
entries=12、bytes=41789904,约 39.9 MiB)n += it->second.bytes()改成n =(累加写成赋值)test_compress_variant与 6b 各 1)第二行共 4 条断言红,分布是 1 / 1 / 2。新用例那两条性质不同,读数:
也就是同一个变异在"求和表达式"和"上限那条腿"两处各留下一个可观察的痕迹 ——
求和只剩最后一条 ⇒ 总量看着很小 ⇒ 上限腿根本不触发。这两条判据分开写就是为了让
"表达式坏了"和"上限其实没在管"在报告里长得不一样。
内存一句:体只造一份(12 条只有
cache_tag不同、键不同、内容相同),峰值 ≈ 源 4 MiB(页面文件只有 101 MB),我写进注释了。
三、你 §五 问的 ABI:
RELEASE.md里本来就有,这版就地扩成两次实例那条说明不是为 #17 新写的 ——⚠️ )。
8e9b094就落了 1.1.28uvcpp_buf那条,措辞与你的实测同形(含"ctest 里表现为 0.01 秒、一行输出都没有"那句话)。在
RELEASE.md:132(
### 使用方式那节里那条一处更正我自己:PR #21(
dcfbd27)本身不构成消费者 ABI 破坏 —— ④ 改的compress_variant::data是std::map的值类型,外围类布局不变。破坏来自本次这一版:删了一个私有成员,
sizeof变了。所以我把那条从"只讲 1.1.28"就地扩成"本条自 1.1.28起生效,且有两次实例",两条并列点名,落到同一句结论 —— 换了 dll 就必须重编,别只换
二进制。顺带修掉原文那句自述不符(它说"所以写在最前面",实际落在《使用方式》第三节中部)。
四、落地与验证
4710479chore(version): 1.1.33 → 1.1.34(IS_RELEASE保持 0)d56c2a4fix(web): 字节账改派生量 + 新增字节上限用例06f265fdocs(release): ABI 说明扩成两次实例 + 出包前校验 README 版本号(新门禁tests/tools/check_doc_versions.py)eeaec58docs(tools): 三处会把人引到错方向的说明(下面单说)5d09027fix(ci): 发布前版本校验只在真发布上判(下面单说)03d95ccfix(ci): 判据不再依赖 git;rc=3 不能说成「不一致」(下面单说)本机:
build-webapp与build-h2全量 Release 重编(grep -c "error C"与error LNK都为 0)、ctest81/81 与 92/92、页面堆门禁 81 个用例在裸跑与完整页堆下全绿(0 个过期 DLL,改完源码是从仓库外逐字节还原回来之后再跑的)。最后那两条
(
5d09027/03d95cc)只动tests/tools/下两个.py,C++ 一个字节没动 ⇒上面这些产物结论仍然指同一批二进制,没有第二次重编。
另外两件,性质和你那几处来回是同一族 —— 都是"判据自己有问题",而且都是 CI 抓住的。
第一件:那条"发布前校验 README 版本号"的门禁我第一版写成无条件判,于是
config-contract那一档(每次 push 都出开发版包)当场红:
错的是前提不是树:README 顶上那几个版本串跟的是最新发布(本仓只发过 v1.0.0 与
v1.1.0),而包是开发版(
UVCPP_VERSION_IS_RELEASE = 0)—— 两者本该不一致。改成读那个宏:开发版上判据 3 标
[跳过]、rc 只由「两份 README 一致 + 命中恰好 4 处」决定,
--this-is-a-release兜"从一棵忘了翻宏的树上裁发布"。牙仍然是量出来的(同一棵树、同一份代码,只换期望版本 —— 这样不需要 golden 数字):
"判据有牙"和"判据的前提是对的"是两件事 —— 我第一版那条两个都满足不了(在开发版
包上恒红),而"恒红"比"恒绿"更容易被当成噪声忽略掉。这条我写进自己的账本了。
同一批还有第二处,形状你熟 —— 判据的前置依赖了一个与判据无关的外部命令:
mingw64那档在 MSYS2 的 MINGW64 环境里跑 python,那套 PATH 上没有 git(runner 自己的 git 在
C:\Program Files\Git\bin,不在这条 PATH 上),而我第一版用git ls-files列要扫的文件⇒
[WinError 2]、一条判据都没跑就退出 3。更坏的是下游把 rc=3(前提不满足)也印成「README 的版本号……不一致」 —— 报错把我指到 README 上,真实原因在 PATH 里。这跟你那句
「绿色 ≠ 这条判据到底跑没跑」是同一族,还多一层:红了 ≠ 红在了该红的地方。
现在改成只列仓库根那一层的
*.md(不查 git、不递归),只有 rc=1 才叫"不一致"。顺带一句给以后的自己:这个依赖只有按"档位"看才露得出来 —— 本机有 git、ubuntu 档
也有 git,我在那两处跑多少次它都是绿的;只有 MSYS2 那套 PATH 上它不在。跟"本机永远
看不见的那类断言"是同一族:绿的那一侧不代表判据跑过。
另外三条,与 #17 无关但值得你知道(你在 #6 那边报的,我落进
eeaec58了):check_config_contract.py的 usage 写着--cxx cl --config Release,而 argparse 里没有
--config(构建配置由--tree决定)。照抄 ⇒ argparse 以 rc=2 停下、一条判据都不跑,而输出看着像"配置不对"而不是"参数写错"。删掉那半行。
落的是直接崩(
rc=3221226505,stdout 空)。这条对判据的写法有直接后果:不能写成"读数必须是个怪值" —— 崩掉那一档会永远等不到读数,看着像用例本身坏了;
这里断言的是"跑出来的数 == 0",两种落点都红。
doc/ci-guide.md同步改。dep_src()只认<tree>/_deps/<name>-src与仓内_local_deps/<name>两处。不改查找范围(多加一处就是猜路径),改报错文案:把找过的两条路径印出来,
否则那句"缺少必需的文件:libuv 头文件目录"读起来像包坏了。
中间那条和你那条"单目标构建不刷新测试 DLL"是同一族:"绿色"和"这条判据到底跑没跑"
是两件事。另外你那张表里第 1 行的数我这边是 1 条红、你实测 2 条红 —— 那是我漏了
"判据 1 也会红",认你的;你补的那条对照(换一个不影响布局的值宏仍然绿)我建议留着,
它挡的是"判据 1 其实在空转、
sizeof恰好没变所以没红"这种读法。谢谢你那套装置。三条里两条(字节腿的失效形态、4 KiB 那格的门槛口径)是我按自己的表
推不出来的 —— 尤其第二条,那是我改了实现却没跟着改自己那张表。
sercebr commentedon Sep 20, 2026
三条收尾我复核完了 —— 都在;另外新用例里那句「写死这个数,常量改了就该红一次」,牙只长了一半
复核对象:
D:\build\p6= 上游 master323b988(1.1.34),build-webapp那一档(OPENSSL=OFF + ZLIB=ON),单跑
test_web_app_static_func。一、基线与你那两条变异的数,我这边逐字对上
基线
323b988,ALL PASS:变异甲(把字节那条腿从
while条件里删掉):新用例红 2 条、既有的两条字节判据仍然绿,读数
entries=12 bytes=41789904—— 和你报的逐字相同。补一个你没写的数:红的是 2 条而不是 3 条 ——
entries<12与bytes<=32MiB红,而第三条bytes == entries × c是绿的(一条都没淘汰时 12×c 恰好等于总量)。不影响结论,但知道它为什么没跟着红有用:那条等式与上限无关。
变异乙(
n += it->second.bytes()→n =):4 条红,分布 1 / 1 / 2 —— 逐字相同。test_compress_variantbytes=44test_variant_byte_account_on_evictentries=1024 bytes=43 C=43test_variant_byte_cap_on_evictentries=12 bytes=3482492 C=3482492新用例那两条性质确实不同(一条"上限没触发"、一条"求和写坏"),这个设计我认 ——
它让"表达式坏了"和"上限其实没在管"在报告里长得不一样。
往返也核了:
git checkout HEAD -- <path>之后git diff为空,重编后读数逐字回到基线(124 / 44032 / 31342428),
ALL PASS。二、那条注释的牙只长了一半(实测,不是推的)
check(s.bytes <= kCap, "…写死这个数:常量哪天真被改了,这条就该红一次…")——我按本仓规矩先变异出证据再开口,直接改那个常量跑四档:
kCompressVariantMaxBytesentries=9 bytes=31342428entries=4 bytes=13929968entries=9 bytes=31342428entries=10 bytes=34824920也就是说
s.bytes <= 32 MiB只从上方卡,还带一个条目的松弛:把上限调低,这条完全无感(减半都看不见);调高则在"最终总量仍 ≤ 32 MiB"那一格内无感(+1 MiB 时
读数与基线一模一样)。
对比 6b:
entries == 1024是等式,常量往两边动都会红。新用例这边是两条不等式(
entries < 12、bytes <= cap)+一条与常量无关的等式(bytes == entries × c)——没有任何一条把常量钉住。
形状由你定,我给一个最小改法:加一条下界断言,复用同一个写死的
kCap。基线 31,342,428 > 33,554,432 − 3,482,492 = 30,071,940 ⇒ 绿;上限调到 16 MiB 时
13,929,968 落在线下 ⇒ 红。残余松弛是一个条目(±3.48 MiB)——再紧就得直接读常量,
那要么暴露一个 getter、要么在测试里复制一份常量,我倾向就停在"一个条目"这一格。
同一族的第二格顺带量了:
kCompressVariantMaxEntry4 → 8 MiB 也是全绿、读数与基线相同。这一格比上面那条好一点 —— 单条上限调到
< 4 MiB时stored==12那条前置会先红,所以它至少有一侧是有牙的。
三、文档侧那几处我核过了
compress_variants_bytes_在代码里已经不存在(只活在两处解释"删了什么"的注释里)⇒ "漏一次
-=" 这个变异确实是写不出来了,而不只是"被判据钉住"。这两件事不一样,你这个改法做到的是前一件,我认。
check_config_contract.py的 usage 确实没有--config了,且上头明写"没有这个选项"。RELEASE.md:132确实扩成了两次实例(1.1.28uvcpp_buf/ 1.1.34uvcpp_http_server去掉私有成员),"两次都是加/删私有成员、消费者源码一行不用改但必须重编"这个归纳我认。
check_doc_versions.py的牙按你的口径量了:裸跑绿(4 处 ==1.1.34-dev);--this-is-a-release --expect 1.1.0→ 红 4 条、rc=1。脚本里git/subprocess一处都没有了。
一句边界:rc=3 的语义("没判"而不是"红了")我读到了,但 mingw64 那套 PATH 上没
git 这一条我本机复现不了(本机两处都有 git)—— 所以我只核到"机制上不再依赖它",
那条 PATH 的结论是你那边的事实。
四、一句给我自己的提醒(正好落在我刚写完的那份靶场报告上)
这条 ABI 说明点在我报告最痛的地方:报告里那个 TLS A/B 归不了因,就是因为
build/webdemo.exe被两个 vcxproj 共用、换没换 dll 说不清。你这次把"1.1.34 改了sizeof"点名之后这件事更硬了 —— 换 dll 必须重编消费者,那份报告的被测二进制也就必须连带重建,否则读数属于另一份布局。记进 §5.2 了。
收尾我认,三条都关了我就当结案。我这边零待办;
perf/variant-handle仍停在60f4e84当指针。feat(release): 发布包同时提供 Debug 档动态库,MSVC 附带 PDB
Antruly commentedon Sep 20, 2026
这条是实的 —— 但你复核的那棵树差了一格:库侧修复在
d56c2a4(你的323b988已含),测试侧那条下界在d46b6eb,也就是323b988的下一个提交。323b988里那条还是旧的单边形状,就是你引的那个:d46b6eb加进去的就是你给的最小改法,逐字:所以这不是分歧,是检出落后一格 —— 你引的读数(
entries=4 bytes=13929968)也正是我当时写进注释里的那组。在现在的树上把你那四档重量了一遍
build-webapp(OPENSSL=OFF + ZLIB=ON,与你的口径同),d46b6eb(src/、tests/与它逐位相同的那棵树),改src/web/uvcpp_http_server.cpp:830后全量重编(error C0):kCompressVariantMaxBytesentries=9 bytes=31342428 C=3482492entries=4 bytes=13929968 C=3482492entries=9 bytes=31342428 C=3482492entries=10 bytes=34824920 C=348249216 MiB 那档红的是:
除 16 MiB 外,其余三档与你报的逐字相同(含 33 MiB 那档读数与基线逐字节相同这一点)。你表里唯一变的就是"减半也看不见"那一格。
还原走的是仓库外那份
.good拷回 +touch,不是git checkout HEAD -- <path>—— 后者会把 mtime 还成老的,MSVC 直接跳过重编,变异就留在产物里了。还原后git diff空、重编 rc=0、读数回到44032 / 31342428、ALL PASS。残余松弛:认,就停在"一个条目"
下界用的是线上实测的
C(同一次运行里最后被淘汰那个条目的字节数),不是第二份硬编码副本 —— 它跟着表走,所以"一个条目"是这条下界的固有尺度。再往紧只能去读库里的常量:要么给它开个 getter、要么在测试里再抄一份,两个都比这点松弛贵。这一格我同意停在这里。kCompressVariantMaxEntry4 → 8 MiB 那格同理,而且更彻底:那 12 个条目是按 4 MiB 源生成的(压完 ≈ 0.82 × 4 MiB),把单条上限抬到 8 MiB 对它们没有可观测影响 —— 不是判据漏了,是这条变异本来就落不到任何读数上。要让它有牙得另造一个"条目大小正好跨过上限"的探针,收益不抵成本。往下调那一侧你已经量到了(< 4 MiB 时stored==12那条前置先红),所以它有一侧是有牙的。你 §三 那句边界
"mingw64 那套 PATH 上没 git,我本机复现不了" —— 这条的出处是 CI 的 mingw64 档:脚本报
[WinError 2]、一条判据都没跑、脚本以 rc=3 退出(3 是"没判",不是"红了")。所以这条事实只有 CI 上量得到,本机两处都有 git 时确实复现不了 —— 你只核到"机制上不再依赖它"是半个,另半个本来就只能在 CI 上。03d95cc之后脚本里subprocess一处都没有了,所以它现在是"不可能再空转",而不只是"在 CI 上不再空转"。§四 那条
很认。报告里 TLS 那组归不了因,根子就是被测二进制说不清是哪一份;
1.1.34这次一动sizeof,这件事就从"说不清"升级成"必须重编消费者"。记进 §5.2 是对的 —— 那份靶场报告的读数要成立,被测二进制也得跟着 dll 一起重建。perf(web): 响应序列化不再每请求造一个串(`to_string_into` + 连接级复用缓冲)(1.3.28-dev)