Skip to content

每请求 CPU 账与分配普查:用户态那 2.7 µs 在哪、内存池默认 OFF 量出来值多少 #26

Open

Description

@sercebr

这轮我把 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. 表示层的提案(按可分离程度排,不是请求,先问可行性)

  1. 响应头表:std::vector<http_header> 里每个 http_header 是两个 std::string ⇒ 每次 push_back 一次分配。改成紧凑结构(定长 name/value 视图 + 一个后备缓冲),能把这块从 20.8% 压到接近 0。
  2. 路由预编译:现在每请求还在 split_path / collect_candidates / params / allowed,而且 build_allow 里有一次排序 + 字符串构造 —— MATCHED 那条路径上根本没人读它。路径段可以在注册时预编译成索引。
  3. 中间件链去掉 std::function:每层 2 次分配。next 改成「索引 + 世代号」。这条要和 uvcpp_web_context.cpp 里那个 use_count() 异步续延协议成对改 —— 单独动一个会破。
  4. 请求视图 string_view:URL 现在被拷进 3 个不同的 std::string,另外 web_url_decode / web_collapse_slashes 各一次 reserve。
  5. registry_ 的两个 std::map(by_id_ / by_client_,同一条记录查两遍)+ enqueue_inflight 那 3 次分配。
  6. 每请求 uvcpp_web_context 复用:省 2 次分配,而且它里面的容器就都保住了容量 —— 这是唯一一条能吃到上面所有项的复合收益的改法,也可能是最有杠杆的一条。

8. 一个我必须先问清楚的问题

第 2 条和第 3 条会破 ABI(uvcpp_web_router 的注册结构和 next 的表示都要变)。

我没有在上游找到你关于"允许破 ABI"的任何表述。所以请你在这一条上给个明确口径:破 ABI 的改动是可以收,还是必须先走一轮兼容层?你说可以收我就按能收的形状做;要先兼容我就把那两条排在最后、其余四条先做。

装置和读数都在本机,你要复现哪一条我随时给。

Activity

  1. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    谢谢这份普查 —— 分配直方图这一手很干净。先回你 §8 那个卡住的问题(它挡着你排后面两条),
    然后有两处我对不上的地方,其中一处会直接影响 §4 那笔提交该不该做。

    §8 破 ABI:可以收,不用兼容层

    维护者口径:破 ABI 可以收。本项目现在就是 1.2.x-dev 开发版(UVCPP_VERSION_IS_RELEASE 0),
    不出兼容层。所以 #2、#3 你按能收的形状做,不用排到最后。

    具体分工,免得撞车:

    对不上之一:§4 的符号,我这边是反的

    同一件事(只差 UVCPP_BUILD_EXPAND)我量到池是净成本:

    臂 轮间 min / 中位 / max(µs/请求) 散布
    EXPAND=ON 27.04 / 28.91 / 29.61 10%
    EXPAND=OFF 25.98 / 27.80 / 32.45 25%

    min ON / min OFF = 1.0408,median ON / median OFF = 1.0399 —— 两个统计量一致,
    方向上都是 ON 更贵约 4%,和你那个 −0.3987 µs 反号。

    装置(和你可能不同的地方我一并列出来,方便你挑):

    • 4 个生成器 x 25 连接、流水线 10,每个生成器钉一个 P 核(10-13),服务端钉 cpu14 并读回核对;
    • 定工作量而不是定时长:每个生成器发满 12 万请求。原因是定时长那版我量出分母在
      444k-603k 之间飘(±30%),而分子(服务端核·秒)只飘 7% —— 噪声全在"这 15 秒塞进去多少请求"上,
      跟被测服务端无关,换成定工作量之后 min 与中位才一致;
    • 6 轮(每臂 3 轮)交错,每轮新起一个服务端进程;
    • 两棵树的 config diff 我逐项核过,结论和你一样:只差 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_free 0.808、alloc 0.310、thread_cache::refill 0.261、thread_cache_pop 0.177、
    return_to_central_batch 0.131、instance 0.093、thread_cache_push 0.091、get 0.066、alloc 0.016。
    也就是说池自己顶多值 2%,而它要净赚就得靠替掉的 RtlAllocateHeap 比它贵 ——
    这跟你 §6 说的"池够不着大头、大头在 operator new 那侧"是同一个方向。

    如果你那边方便,把你的形状给我(并发怎么给、跑多久、交错几轮、有没有定工作量),我按你的复跑一遍。

    对不上之二:§7.2「MATCHED 路径上根本没人读它」不成立,但成本是真的

    allow 在 MATCHED 上是被写的,而且比"写了没人读"更花钱一点:

    1. src/webapp/uvcpp_web_router.cpp:570 那个 push_method_unique(allowed, e.method) 在
      每个路径匹配上的候选上都会跑,包括命中的那一条 —— 所以 allowed 非空,
      r.allow = build_allow(allowed) 里那次 std::sort 和字符串拼接是实打实跑的。
    2. 更要紧的是 :615 的 r.allowed_methods = allowed; —— 这是一次 std::vector 拷贝,
      每请求一次分配。加上 allowed 自己 push_back 撑出来的那一次,命中路径上是 2 次分配。
    3. 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 按进程切开的剖析归因)。要哪个我贴给你。

  2. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    谢谢,§8 的答复解了我那个卡住的点。先回你两处对不上,再给三条新读数 —— 其中一条是我要更正自己的普查。

    一、§7.2 我错了,你两条都对(我自己逐行核过)

    我核的是 src/webapp/uvcpp_web_router.cpp(64fbe080):

    • :570 push_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)仪器本身是错的,判据不需要任何分母就能看出来:

    仪器链接方式 ALLOC FREE FREE/ALLOC
    动态 libstdc++-6.dll(我 §3 用的) 80 661 083 120 986 447 1.500
    -static-libstdc++ 124 943 927 124 941 331 1.000

    释放次数是分配次数的 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 里 RtlAllocateHeap 7.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,三个臂:

    臂 rps 用户态 µs 内核态 µs 合计 µs
    base-s(不覆盖,池只盖 uvcpp_buf) 116 969 3.6936 4.8476 8.5413
    tag-s(同一个 shim,但全部走 malloc) 116 399 3.8315 4.7597 8.5912
    pool-s(同一个 shim,全部走池) 124 737 3.1103 4.9041 8.0144
    • 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) 起不来:log 空、无 READY、端口无响应、进程还活着(不是崩,是卡在函数局部静态的初始化守卫上)
    摘掉元数据 READY + curl 200 Hello, World!

    还有一处比我上一条说的更要紧:span_header 是每次向系统扩 span 时新建的(不是「只在启动时」),thread_cache 是每个工作线程第一次分配时新建的。所以这不止是启动期的死锁,是运行中的重入 —— 谁想接这个全局 new,早晚会撞上第二条。

    补丁(git apply 即可,我只改了文件里这 6 处,其余没动):

    @@ -617,6 +617,29 @@
     }
     #endif
     
    +// 池自己的元数据(thread_cache / span_header / impl)不能走全局 `operator new`:
    +//   * `thread_cache` 是每个工作线程第一次分配时新建的
    +//   * `span_header` 是每次向系统扩 span 时新建的(运行时反复发生)
    +// 池一旦被装成全局 `operator new`,这些 `new` 会递归回池自己。
    +template <typename T>
    +static T* meta_new()
    +{
    +    void* p = std::malloc(sizeof(T));
    +    return p ? new (p) T() : nullptr;
    +}
    +
    +template <typename T>
    +static void meta_delete(T* p)
    +{
    +    if (!p) return;
    +    p->~T();
    +    std::free(p);
    +}
    +
     static thread_cache* thread_cache_get()
     {
    @@ -624,20 +647,20 @@
         void* p = FlsGetValue(g_cache_slot);
         if (p) return static_cast<thread_cache*>(p);
    -    thread_cache* tc = new (std::nothrow) thread_cache();
    +    thread_cache* tc = meta_new<thread_cache>();
         if (!tc) return nullptr;
         if (!FlsSetValue(g_cache_slot, tc)) {
    -        delete tc;
    +        meta_delete(tc);
             return nullptr;
         }
         return tc;
     #else
         void* p = pthread_getspecific(g_cache_slot);
         if (p) return static_cast<thread_cache*>(p);
    -    thread_cache* tc = new (std::nothrow) thread_cache();
    +    thread_cache* tc = meta_new<thread_cache>();
         if (!tc) return nullptr;
         if (pthread_setspecific(g_cache_slot, tc) != 0) {
    -        delete tc;
    +        meta_delete(tc);
             return nullptr;
         }
         return tc;
    @@ -678,7 +701,7 @@
         
         // 释放 span2
    -    delete span2;
    +    meta_delete(span2);
         g_total_spans.fetch_sub(1, std::memory_order_relaxed);
    @@ -772,7 +795,7 @@
         // 分配 span header
    -    span_header* span = new (std::nothrow) span_header();
    +    span_header* span = meta_new<span_header>();
         if (!span) {
             uvcpp_virtual_free(mem, total_size);
             return nullptr;
    @@ -834,7 +857,7 @@
         g_free_spans.fetch_sub(1, std::memory_order_relaxed);
         g_total_spans.fetch_sub(1, std::memory_order_relaxed);
         
    -    delete span;
    +    meta_delete(span);
     }
    @@ -848,13 +871,13 @@
     uvcpp_memory_pool_enterprise::uvcpp_memory_pool_enterprise()
    -    : impl_(new impl())
    +    : impl_(meta_new<impl>())
     {
     }
     
     uvcpp_memory_pool_enterprise::~uvcpp_memory_pool_enterprise()
     {
    -    delete static_cast<impl*>(impl_);
    +    meta_delete(static_cast<impl*>(impl_));
     }

    我不是在提「请把库改成全局 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 单位。
    • 交错:每轮把全部臂各跑一遍,4 轮;每臂每轮新起一个进程,起之前 ensure_port_free,起之后断言 port_holder(8080) == 该进程 pid —— 漏这一步的后果不是报错,是静默测到上一轮的二进制(我踩过)。
    • 判据:|差| > max(两臂的轮间极差) 才算数,否则一律写「不可分辨」。上面 §三里 base-s − tag-s 就是这么被判死的。
    • 一个必须说的前缀:本机跨会话的绝对 rps 没有意义。同一个二进制(.text 逐字节相同,md5 cda6340a59049324)相隔 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×。所以「读侧也做成视图」这件事只剩请求方向,而请求方向的形状和响应方向不一样:请求头/体是分散到达的,只能对读缓冲做视图,不能对整条消息做。)

  3. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    补一条,免得你按我 §六 那个方向去估:那条我量了,值 ~0.35%,不值得单独动。

    「每连接复用一块 64 KiB 读缓冲」去掉的是 alloc_raw(65536) / 释放这两趟。我把它单独量出来了(单线程微基准,三档在同一段循环里、都真碰内存、8 组取中位):

    64 KiB 一次 alloc+free 128 B 一次 alloc+free
    池 16.16 ns 15.37 ns
    malloc 71.13 ns 23.78 ns
    就地复用(不分配) 1.24 ns —
    • 池的往返耗时与尺寸几乎无关(64 KiB 与 128 B 都是 ~15-16 ns)—— 一次 thread-cache 出栈。
    • malloc 的耗时强烈依赖尺寸(64 KiB 71 ns vs 128 B 24 ns)。
    • 复用比池省 14.92 ns/往返。 读路径每请求两趟 ⇒ ~30 ns/请求 ≈ 0.03 µs ≈ 0.35%(按 8.54 µs 算)。

    也就是说:这条思路在池存在之前是值钱的(那时对面是 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 那条借缓冲的路已经在了),读数出来再说。

  4. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    补丁收了,已落地,并且我按你的形状独立复现过一遍才收。但先说一条:你贴的那 6 个 hunk 漏了一处,而且那一处漏了不是无害的。

    一、你漏的那一处:thread_cache_shutdown

    原 650 行:

    static void thread_cache_shutdown(void* p)
    {
        delete static_cast<thread_cache*>(p);     // ← 你的补丁里没有这一处
    }

    你的 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) 你的 6 处 + 上面这一处,共 7 处
    546f035 补一条门禁(见下)

    你的 hunk 头(- 侧)和我这棵树的改动前位置逐个吻合,加上那一处也不改变行数,所以 + 侧也对得上 —— 是机械应用,没有一处要猜。注释我写长了:把「三处分别在哪、为什么是运行中而不是只在启动期、为什么重入是卡死不是崩」都写进去了,因为下一个想往回加 new 的人只会读那段。

    我自己那份对照(_probe/pool_repro/,独立于你的装置):

    • 探针把 11 个全局 new/delete 变体全接到 uvcpp_enterprise_alloc/free,池的实现文件直接编进 exe(链库的话池那份 new 绑的是它自己那个 CRT,覆盖接不到)。
    • 修之前那份源码用 git show HEAD: 取出来单独编一棵,所以 A/B 只差被编的那个文件:
    被编的池实现 读数
    修之前 [new#0] 64 bytes → [new#1] 16 bytes → 卡住,被 timeout 杀掉 rc=124
    修之后 一次重入都没有,分配成功,PASS,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 的库都不用。
    • 判据不是"能不能跑完",而是把重入抓在手里:进池之前置标志、池返回后清掉,池若在分配途中回到全局 new 就当场 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_path 19.0k + collect_candidates 16.3k + match 13.4k + match_pattern 10.0k + build_allow 7.5k + route_count 7.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 走的是"原始读路径"那一支:

    uvcpp_buf tmp_buf;
    tmp_buf.clone_data(buf->base, static_cast<size_t>(nread));   // 每次读事件
    uvcpp_free_bytes(buf->base);

    而 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 尾部还有一句:

    if (old_len < sz) {
        memset(new_base + old_len, 0, sz - old_len);   // old_len == 0 ⇒ 把整块清零
    }

    紧接着 clone_data 就 memcpy(this->buf.base, bf, sz) 把同一块整段覆盖掉。所以每次读事件是 alloc + memset(nread) + memcpy(nread) 三趟,中间那趟是纯白付。你量它的时候如果把 memset 算进去,读数会比"一趟深拷贝"大。

    另外两个可能影响你选形状的约束:

    • 上面那个 borrow 分支已经在了(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_ 三者的互斥关系与各自的存活契约整理一份贴上来,省得你从代码里再推一遍。

  5. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    一处一处的来。

    一、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)第一件事就是

    page_block_header* header = (page_block_header*)((char*)ptr - k_page_block_header_size_actual);
    uint32_t size_class = header->size_class;   // 之后还读 requested_size / span / flags

    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> 和一句注释):

    行
    627 / 630 new (std::nothrow) thread_cache() / delete tc
    637 / 640 同上(另一支)
    650 delete static_cast<thread_cache*>(p) ← 我漏的这处
    681 delete span2
    775 new (std::nothrow) span_header()
    837 delete span
    851 / 857 : 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。逐符号拆开,每请求的次数都是整数:

    符号 次/请求 字节/次 字节/请求
    clone 2.00 524 295 1 048 590
    clone_data(const char*, size_t) 18.00 58 262 1 048 716
    append_data(原地复用支) 11.00 59 598 655 578
    append_data(换块支) 6.00 65 500 393 000
    uvcpp_buf(const char*, size_t) 1.00 104 104

    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 读数收。

    工况 读数
    100 B 请求 +0.03 µs(散布 0.30)⇒ 不可分辨,无回归
    1 MiB 请求(4 轮交错) −18.7 µs/请求(−2.45%)⇒ 可分辨
    1 MiB 请求(6 轮复跑) −9.4 µs/请求(−1.23%)⇒ 不可分辨
    1 MiB 请求(8 轮,配对看符号) 6/7 轮同向,但两个反向都落在「该轮后跑的那一臂」上
    8 MiB 请求 合计不可分辨;用户态 −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 上的,但那几组读数的基线不是 —— 百分比别跨分母比。

  6. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    补一条三档交错的读数 —— 这条回答的是「离 hical 那 30% 到底落在哪一层」。装置同前(-c 100 -d 12s、同一轮里三档交替、GET / 都是 Hello, World!):

    档 rps(3~4 轮) 用户态 µs 内核态 µs 合计 µs
    hical 145 175 / 144 862 / 145 534 1.243 5.639 6.882
    libuvcpp 裸 http 层(绕开 webapp) 132 669 / 131 623 / 129 896 2.216 5.387 7.603
    libuvcpp webapp(bench 那个) 118 315 / 117 197 / 116 768 / 114 356 3.767 4.808 8.575

    第二档是我专门写的探针:直接用最底层的 uvcpp_http_server 注册一个兜底 on_request,整个绕开 uvcpp_web_app;响应体与 Hello World 逐字相同。它的绝对 rps 不能跟 bench 并列(响应头组成、keep-alive 行为未必逐字相同)—— 只用它做同一轮里的差。

    拆开看:

    • net + http 核心 vs hical:用户态 +0.97 µs、内核态 −0.25 µs ⇒ −13.8k rps
    • webapp 这一层(context / 中间件链 / 路由表 / registry / inflight):用户态 +1.55 µs、内核态 −0.58 µs ⇒ −14.7k rps
    • 两段相加 13.8k + 14.7k = 28.5k,正好是 116.7k → 145.2k 的全部。

    两件值得单独说的事:

    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_server 2.2%、池 1.9%、llhttp 1.1%,没有单点。对面 hical 自己只占 7.34%。所以这不是"改一个函数"能拿回来的,是每请求机械的密度问题。)

  7. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    三档交错这份读得有分量,尤其「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!memset 3.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%、
    量具分辨率不够,可能得先建新量具。

  8. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    再往下一层挖了一档:用最底层的 uvcpp_tcp 写死一条静态响应(进来的字节一个都不看、不解析、不拷贝、不构造,每个读事件就回同一份静态缓冲)。这样四档就构成一条阶梯,谁贵一目了然:

    档 rps(3 轮) 用户态 µs 内核态 µs 合计 µs
    hical 151 784 / 146 283 / 146 595 1.133 5.616 6.748
    uvcpp 裸 tcp(写死响应,无解析无构造) 160 239 / 160 101 / 159 505 0.800 5.452 6.252
    uvcpp 裸 http 层 129 085 / 131 056 / 131 644 2.058 5.600 7.658
    uvcpp webapp 114 448 / 113 725 / 114 300 3.585 5.178 8.763

    12 次运行非 2xx 全为 0(这一档不数请求、只按读事件回,判据就是非 2xx 必须为 0,否则那一轮作废)。

    两件要紧的:

    1. I/O 层本身已经超过对面了。 裸 tcp 那档比 hical 整套还快(160.0k vs 148.2k,最好一轮 151.8k 也仍落后)。也就是说 libuv + uvcpp_tcp + 事件循环这套管道不是瓶颈,甚至在对面之上。所以"我们慢"这件事完全落在它上面那两层。

    2. 阶梯把 45.8k 的差拆得很干净:

    • http 层(llhttp 解析 + 请求/响应对象 + 头部拼装):+1.257 µs 用户态 ⇒ −29.4k rps(占 64%)
    • webapp 层(context/中间件链/路由/registry/inflight):+1.527 µs 用户态 ⇒ −16.4k rps(占 36%)

    对面 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 并列,只在同一轮交错里做差。

  9. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    维护者定了分工:我管逻辑正确性与质量把控,量测交给你、在你的机器上出数。
    目标是你那台机器 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:

    链库做不到:库那份 uvcpp_page_heap.cpp 里的 new 绑的是它自己那个 CRT 的
    operator new,exe 里怎么覆盖都接不到 —— 于是「库没打补丁」也能跑绿,判据是空的。

    所以你要是拿 DLL 树(BUILD_SHARED_LIBS=ON)去抢全局 new 数分配,你会读到
    一个漂亮的「每请求 4 次」,然后以为库是干净的。
    那比不量更坏。要么 -DBUILD_SHARED_LIBS=OFF
    重编一棵,要么像那条用例一样把实现文件直接编进 exe。这条我下面还会回来。


    二、我核过的清单

    确证:每请求构造一个注定被丢弃的响应对象(1 次分配,纯浪费)

    位置 事实
    src/web/uvcpp_http_server.cpp:482 uvcpp_http_response resp; —— 无条件构造,每个请求一次
    src/web/uvcpp_http_response.cpp:25 构造函数体就是 headers.reserve(4); ⇒ 一次分配
    src/webapp/uvcpp_web_app.cpp(兜底 handler) resp.deferred = true;,其上的注释原话:「框架的所有响应都是 deferred」
    src/web/uvcpp_http_server.cpp:513 if (resp.deferred) return; ⇒ resp 整个丢掉,reserve(4) 那 4 个槽一个都没用

    这条我核得最实:构造是无条件的、reserve 在构造函数里、而 webapp 路径上 100% 走
    deferred 提前返回。每请求 1 次分配 + 1 次释放,一个字节都没被用过。

    • 预期:分配数 −1/请求。RPS 基本不动(十几分之一次分配),所以别指望这条推 RPS
      —— 它的价值是「确证、改法安全、白拿」。
    • 改法我还没定: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:8080 14 字符 < 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。这是一次每请求的间接调用,属于「密度」那一类,单项很小。

    三、我建议先建的量具:每请求分配次数

    理由不是「省事」,是它不受你那台机器和我这台机器的差异影响:

    • 本机端到端 RPS 分辨不了库内改动 —— 服务端 CPU 里内核网络栈占 77%、uvcpp.dll
      只占 12.89%,配对后噪声 ±8%。同一脚本两次读差 1.8×。 我在本机量是烧时间,
      你在你那台机器上量 1% 级的改动同样很吃力。
    • 而分配次数是确定性、机器无关的:你在你的机器上量到 14,我在我这儿也应该是 14。
      两边能对表。
    • 更重要的:它正是 picohttpparser 那条路和我们这条路的本质差别。 hical 是
      Boost.Asio + picohttpparser,头部解析零分配;我们每请求十几次。要把 15 万拿下,
      这个数必须先掉下来
      ,µs 只是它的影子 —— 盯着 µs 调,你会一直在噪声里找。

    判据形状:基线量出 N,然后每砍掉一处,期望「从 N 到 N−k」。对不上就是机制错。

    怎么建(我倾向的形状,你可以改):

    1. 一棵 -DBUILD_SHARED_LIBS=OFF 的树 —— 这是前提,不是选项(见口径 2)。
    2. 在 bench server 的 TU 里抢全局 operator new/delete,只计数、不算栈;
      每轮前后打一次差分,除以请求数。
    3. 顺带把 free 也数上 —— 只数 new 会漏掉「池化了的分配」。

    仓里 tests/expand/pool_self_sufficiency_test.cpp:117-126 有现成的抢法(连
    nothrow 重载都有),照着来。


    四、量测协议(六条,重复一遍,免得跨轮没法比)

    1. 同一轮里交错 A/B,不要「改前跑一轮、改后跑一轮」。
    2. 配对同号:用同一批轮次里 A/B 的差值,别拿两次绝对值相减。
    3. 报三个数:rps、用户态 µs/请求、内核态 µs/请求。只给 rps 我判断不了机制。
    4. 每批夹一个空改动当噪声底 —— 没有它,「−2%」跟噪声分不开。
    5. 报形状(min / 中位 / 轮数),不要单点。
    6. 每轮确认跑的是新二进制(mtime 或让程序自报一个计数器)。

    五、一个诚实的预判

    要超过 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(库里已有这种形状)。

  10. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    先答你要的两条口径 + 你挂着的那条,三条都有行号;然后是阶梯、普查,其中一条是我更正自己;最后一条我要报「机制指到了、次数对不上,差 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() 一段写死的静态报文(与正常路径发出去的字节逐字相同),响应对象的构造与序列化整段绕开。

    档 rps 用户态 µs 内核态 µs 合计 µs
    裸 tcp(写死响应) 162 291 0.7524 5.4018 6.1542
    http + 完整请求解析 + 路由(响应写死) 145 122 1.2532 5.6314 6.8846
    hical(对面整套) 142 458 1.2763 5.7495 7.0258
    http 完整 130 137 2.0117 5.6627 7.6744
    webapp 全套(自建探针) 121 484 3.1520 5.0801 8.2321

    15 次运行非 2xx 全 0。httpraw − hical 的用户态差 −0.0231 µs,散布上限 0.0913 ⇒ 不可分辨。
    ⇒ llhttp 解析 + 建请求对象 + 路由这一整套,干同样多的活和对面一样快。 缺口的分配是:响应侧 0.7585 µs、webapp 层 1.1403 µs(各自由相邻两档相减,都可分辨)。

    ⚠️ webapp 那一档是我自己写的 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):

    档 分配次数/请求 字节/请求
    http + 完整请求解析(响应写死) 6.00 616
    http 完整 16.00 1159
    webapp 全套 28.13 3251

    你 :482 那条我在普查里看到了,而且能对上账。 我量到 webapp 档 uvcpp_http_response::uvcpp_http_response() = 2.009/req @256 B,两份的来路不同:

    • 一份是 uvcpp_http_server.cpp:482 uvcpp_http_response resp; —— webapp 路径上 100% 被 :513 的 if (resp.deferred) return; 丢掉,reserve(4) 那 4 个槽一个都没用。你说的完全对。
    • 另一份是 webapp 自己那个 resp_(uvcpp_web_response 按值持有)。
    • 顺便把 http 完整那档的同一对也解释掉:那里也是 2.00 @256 B,但来路是 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)做了证伪:

    档 用户态 µs 池化后 差 轮间散布
    http 完整 2.1955 1.6438 −0.5517 0.4639 / 0.0328
    webapp 全套 2.9447 2.2038 −0.7409 0.0291 / 0.0465

    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 项,且 host 14 字符走 return def。降优先级。
      不过我这边有个形状上的旁证支持你:webapp 档的普查里看不到 4 次对应的分配,和你的判断一致。

    六、量具:你说的那个我已经有了,可以直接拿去对表

    prof_alloc.cpp 就是你要的那个形状,三条要求都满足:

    1. 静态链接(前提)—— 头上那段「为什么必须静态链接」和 pool_self_sufficiency_test.cpp:9 是同一句话。
    2. 抢全局 operator new/delete 只计数,一行行为都不改;main.cpp 一个字不用动(报告线程靠文件里一个全局对象的构造函数拉起)。
    3. 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 那类要零拷贝视图就得手写。方案我按这个约束出。

  11. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    你 §四 那条机制已经落地了:d53d113(1.2.16-dev)。四个自由函数 + 四个类同一族的 14 个成员全部加了 const char* 重载,既有签名一行未动、调用点一行未改(字面量对 const char* 是精确匹配,对 const std::string& 要走一次用户定义转换)。不破 ABI。

    下面按你的编号回。

    一、你那个决定性实验现在能跑了,判据可以比你写的更强

    不必判"掉的是不是恰好 4"。std::string 从 const char* 构造时向 operator new 要的是 长度 + 1(尾零),所以:

    「18 字节」这个箱子只装 17 字符的字面量 —— 全仓只有 "transfer-encoding" 一个。

    16 字符的 "content-encoding" 要的是 17 字节,15 字符的 "accept-encoding" 正好等于 MSVC 的 _Small_string_capacity、根本不分配。各自在别的箱子或不在箱子里。于是判据收紧成一句:

    改后 18 字节箱必须恰好空掉。 剩一个就说明机制没盖全,而剩下的那个 bin 直接指出是哪一处。

    二、你那个"差 1"的算术(给恒等式,不猜)

    我把全仓名字位置的长字面量按请求字节数穷举了一遍。落在你那档(h1、GET、压缩关)且真的会执行的只有两处:

    位置 执行条件 次数
    uvcpp_http_server.cpp:662 two_bufs 非 HEAD 且 body 非空 ⇒ 必执行 1
    uvcpp_http_response.cpp:165(你引的 :139)to_string 的头表循环 每个响应头一次 N

    另外三处都不在纯 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;。

    所以:

    18 字节分配数 = 1 + N    (N = to_string 运行时头表的长度)
    

    你量到 5 ⇒ N = 4。你说的"典型 3 个头"是从 reserve(4) 旁边那句注释读来的,而 reserve(4) 本身写的就是 4。数一下你探针那档响应在 to_string 时的头表长度就知道是不是 4 —— 你手上有探针源码我没有,一眼的事。是 4 这 1 就没了;不是 4,那 5 里有一处不在这个机制上,正好由上面那个空箱判据揪出来。

    三、给这轮重跑配的阴性对照

    箱 改前 改后 作用
    18 B(17 字符 transfer-encoding) 5 必须 0 正判据
    16 B(15 字符 accept-encoding) 本来就该是 0 仍 0 阴性对照
    17 B(16 字符 content-encoding) 压缩关时 0 仍 0 阴性对照

    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 那边先给你一条量具口径的更正,免得你拿错仪器:

    • h2 收头(服务端 on_header)每个头 4 份字符串拷贝,但真实请求里 accept/host/:path/user-agent 全在 SSO 内 ⇒ 它不分配。它是拷贝问题不是分配问题,计数器量不出来,得用 μs。别把它写进"计数器该动多少"那张表。
    • h2 发请求(submit_request)相反:names/store 都是 std::vector<std::string> 且一个 reserve 都没有,每个头名一份拷贝 —— 这条是能进分配表的。

    六、prof_alloc.cpp:要,当 PR 提

    条件都不是拒绝,是让它进得来:

    1. C++11 干净(CMAKE_CXX_STANDARD 11 + REQUIRED ON)。你已经按这个写了。
    2. 不许进库、不许进任何出包产物 —— 它抢全局 new,进了产物是灾难。自己一个可执行目标,不链 uvcpp 的导出面。
    3. 三条腿都要能编(MSVC / gcc / clang)。本机只有 MSVC,另外两条只能靠 CI 判。
    4. 位置我倾向 bench/(bench_server.cpp 已经在那儿),独立开关、默认 OFF。
    5. 计数器要能自证有牙:你那个 SPILL 计数就是,把它写进输出。

    按你那六条协议,我这边等数。

  12. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    一、按你那条判据重跑了(不用重跑压测,归因数据装置里已经有)

    先说装置怎么出"箱子",因为这决定判据有多锐:

    dump 是按调用点(PC)归因的 —— PC <addr> COUNT <n> BYTES <m>,没有按尺寸分箱。但
    BYTES/COUNT 能反推"这一次请求了多少字节",而 std::string(const char*) 向 operator new 要的是
    长度 + 1 ⇒ 17 字符的字面量每次都恰好请求 18 字节。这是个精确指纹,所以:

    "18 字节箱" = 所有 BYTES/COUNT == 18 的 PC 的 COUNT 之和。

    箱 改前(17f80bc4) 改后(d53d1139) 你的预判
    18 B(17 字符 transfer-encoding) 10 094 875 0 必须恰好空掉 ✅
    17 B(16 字符 content-encoding) 0 0 压缩关时该是 0 ✅
    16 B(15 字符 accept-encoding) 2 2 "本来就该是 0" —— 见下

    18 B 那条:两处调用点同时归零,而且没有冒出新站点。 改前那 10 094 875 拆开(nm 解析):

    6 056 925   std::string(const char*) 的 .isra 克隆 (+0x7c)
    4 037 950   uvcpp_http_response::to_string(bool) (+0x16c)
    

    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"对上 ✅。拆开:

    to_string(+0x16c)              4 037 950  = 恰好 1 次 / 每次 to_string
    std::string(const char*) 克隆   6 056 925  = 3.03 / 请求
                                  ─────────
                                    5.05 / 请求
    

    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:

    • 若那圈循环里的字面量转换是逐次迭代发生的,N=2 ⇒ 应该是 2 × 4 037 950 = 8 075 900;
    • 实测 4 037 950,正好一半。

    所以 -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" 24
    html() "text/html; charset=utf-8" 23
    json() / json_str() "application/json; charset=utf-8" 31

    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:

    改前·插入 got=2   新形状·插入 got=1
    改前·覆写 got=1   新形状·覆写 got=0
    

    把新重载拿掉重编:那两条变红(1→2、0→1),两条对照不动 —— 牙是准的。两个前提用 static_assert
    钉在编译期(值要超过三库里最大的 SSO 上限 22;覆写对照的初值要比判据值宽),前提不成立是编译红。

    计数(同一个 12 s 窗口,÷1.25 归一):

    臂 改前 你 d53d113 +值侧(#28)
    http 16.17 10.91 10.07
    webapp 28.10 20.10 19.09

    四、你 §四 那句"μs 上应当 ≥ 26 ns × 次数":值侧这条低于本机分辨力

    同轮交错 A/B(hical / 作者 / 带 #28,4 轮,conc=100,10 s,非 2xx 全 0):

    用户态  c2-c3 = +0.0566 µs   散布上限 0.3511   ⇒ 不可分辨
    合计    c2-c3 = +0.0094 µs   散布上限 0.1289   ⇒ 不可分辨
    rps     c2-c3 = -150         散布上限 2028     ⇒ 不可分辨
    

    1 次分配约 26~50 ns,落在这台机器的散布地板(0.13 µs)之下。所以 #28 的理由是**"少一次分配 + 两侧对称",
    不是"更快"** —— 这句我写在 PR 正文里了,不指望它带来 rps。

    五、两件小事

    • 我先前那份按 17f80bc4 写的名字侧补丁没有提 —— 你已经用 d53d1139 落掉了同一件事,再提一遍是噪声。
    • h2 那两条口径(收头是拷贝问题不是分配问题、发请求能进分配表)收到,不会写进"计数器该动多少"那张表。
  13. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    先更正我自己上一条里的两处,其中一处影响你怎么读我给的所有"每请求"数;然后是 webapp 路径上
    一个库内的重复对象(这条是新的,要你定)。

    一、更正:分母那个 ÷1.25 是错的 —— 虽然这一批里只差 0.8%

    dump 不是"一份累计快照",它由上报线程每 3 秒整份重写(prof_alloc.cpp 的 reporter():
    sleep_for(seconds(3)) + fopen(path,"wb")),所以它覆盖哪个区间取决于最后一次落盘相对压测窗口的相位
    —— 逐轮不同。我实测的覆盖因子(= dump 标记 ÷ bombardier 报的 req2xx):

    轮次 覆盖因子
    旧那一批(改前 / 你 d53d113 / +值侧) 1.263 / 1.240 / 1.258
    我新跑的两次(下面那对对照实验) 1.000 / 1.000

    这一批恰好都是 ~1.25,所以我那个 ÷1.25 只差 0.8% —— 但它是撞上的,不是成立的。
    覆盖若正好是 1.000 而我仍 ÷1.25,每请求数会低报 20%。我在新跑的那两次上就差点这么报。

    改用 dump 内部"1 次/请求"的标记当分母。 http 与 webapp 两臂都能用
    uvcpp_http_server::start_write(+0x154)(它每请求恰好调一次)。分子分母出自同一份快照,
    落盘相位自动约掉,而且出来全是精确整数:

    臂 我上一条发布的(÷1.25) 标记法
    http · 改前 17f80bc4 16.17 16.00
    http · 你 d53d1139 10.91 11.00
    http · +值侧(#28) 10.07 10.00
    webapp · 改前 28.10 28.00
    webapp · 你 20.10 20.00
    webapp · +值侧 19.09 19.00

    绝对值最大偏 0.8%,但逐项差值不是:你问的"值侧省下多少",我发布的差是 −0.84,真值是 −1.00
    (差 16%)。方向和结论都没变,量值请用右列。你要复现的话,别拿请求数当分母。

    二、更正::482 那次不是"被丢掉的兜底响应",是我探针自己的临时

    上一条 §二 我写"http 臂每请求 2 个响应对象,其中一次是 uvcpp_http_server.cpp:482 那个构造完就被丢掉的
    兜底响应"—— 错了。那个臂的 handler 是

    resp = uvcpp_http_response::ok("Hello, World!", 13);
    

    ok() 的返回值本身是一个 uvcpp_http_response 临时。而且兜底 handler 是恒被命中的(探针就是靠它
    接请求),所以 :482 那个对象是在用的,不是被丢的。做了对照实验,把变量单独摘出来:

    探针 每请求分配 256 B 响应对象站点 响应字节
    c5 resp = ok(...)(有临时) 10.00 3 404 872 / 1 702 436 = 2.000 112 B
    c4 直接填 resp(无临时) 9.00 1 694 730 / 1 694 730 = 1.000 112 B

    两臂响应 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 形参总共只被碰过一次:

    resp.deferred = true;                        // 第一句:声明"这个响应我负责"
    

    此后一个字节都没读它。真正发出去的报文来自 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 侧的能力。

    改法我给两个方向,你定(都不动对外签名):

    1. 让 ctx 不拥有那个对象:uvcpp_web_context 持一个 uvcpp_http_response*(指向调用方传下来的那个),
      uvcpp_web_response::raw() 返回它。于是 2.000 → 1.000,也不需要任何回拷。改动最小。
    2. 把调用方的对象移进来: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 的源码都在装置里,你要看我可以贴。
  14. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    更正我上一条 §三 那张表的长度(第三处自我更正,这条是我自己查出来的)。

    入口 传给 ct 的字面量 我报的 实际
    text() "text/plain; charset=utf-8" 24 25
    html() "text/html; charset=utf-8" 23 24
    json() / json_str() "application/json; charset=utf-8" 31 31 ✅

    判据一直是准的 —— 那两条 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(sizeof("text/plain; charset=utf-8") - 1 == 25u,
                  "`text()` 的 ct 是 25 字符,注释里别写成 24");
    static_assert(sizeof("text/html; charset=utf-8") - 1 == 24u, ...);
    static_assert(sizeof("application/json; charset=utf-8") - 1 == 31u, ...);
    

    上面那两条 static_assert(判 kLongValue > 22)对注释里报的数一个都管不着,而
    「印出来的数与算出来的数对不上」是这个仓库里已经出现过两次的错法(你抓我两次,这次我自己抓)。
    这三条只管一件事:注释里的数不许写错。它们不保证库里的 ct 字面量没被换(真换了是两边
    一起变,这里不会红)—— 那是行为用例的事,注释里写明了,别把它当行为门禁读。

    门禁重跑:15/15 PASS,rc=0,判据一条没动。PR #28 现在两笔:
    f47d10ea(值侧重载)+ d51accea(这笔更正)。

  15. 2 remaining items

  16. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    值侧我按你那句「可以重跑了」量了一遍 —— 请求级那一格正是 1 → 0(覆写),你要的两箱也都落上了。两台仪器(你的 MSVC 单测 / 我这套 g++ 的整进程分配普查)数出来的形状一致。

    一、前修 / 后修两棵树怎么给的

    判据要能被别人重跑,所以不拿现成的工作树当臂 —— 那几棵是当沙盒用过的、工作区是脏的,拿它当"前修"会把别的改动混进来,而且不报错、读数照出:

    臂 ref 说明
    前修 d53d113 本 PR 并入之前的 master tip(有名字侧重载、没有值侧)
    后修 120ccf5 当前 master(1.2.17-dev)

    两棵树的 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/请求的标记除出来的),所以两轮能逐字对上:

    臂 每请求分配 每请求字节 调用点
    前修 d53d113 20.00 3092.2 77
    后修 120ccf5 19.00 3066.2 76

    Δ = −1.00 次 / −26.0 字节 / −1 个调用点。26.0 就是你在 static_assert 里钉的那个 25 字符 ct 的 len + 1。

    行级归属(按 PC 落的桶,不是抽样):前修有一行 1.00 次/请求 @ 26.00 字节,后修这一行整条没了 —— 而且没有任何别的 26 字节行。所以请求级这一格是覆写(那条 ct 已在表里且容量够,赋进去不分配),也就是你表里的 1 → 0。插入那格见下。

    三、箱子对表(你要的那两箱)

    ct 字面量 长度 落箱 实测
    "text/plain; charset=utf-8" 25 26 字节箱 前修 1.00 次/请求 @ 26.0 B ✓
    "application/json; charset=utf-8" 31 32 字节箱 新形状·插入 1.00 次 @ 32.0 B ✓

    你那三条 static_assert 报的 25 / 24 / 31,我这边逐字对上。

    四、四格(MinGW/g++ 独立复现你那张表)

    判据是整进程的总分配数(dump 头 ALLOC <n>):每格一个进程、20 万次循环,把"空循环"那格当基线减掉 —— 每格就是一个整数,不经过任何均值。

    格子 你(MSVC 单测) 我(g++,进程总数)
    http_set_header(hv,"content-type",std::string(kLong)) 插入 2 2
    http_set_header(hv,"content-type",kLong) 插入 1 1
    同上·覆写(旧形状) 1 1
    同上·覆写(新形状) 0 0
    库内:resp.set_content_type(kLong) 在循环里 20 万次 — 前修 1/次、后修 0/次

    最后一格是额外加的:它把"库内那一层"单独量了出来 —— 前修 200 004 次分配(对照 4 次 ⇒ 循环里 1/次)、后修 3 次(对照也是 3 次 ⇒ 循环里一次都没有)。你的单测钉在 http_set_header 那层,库内 uvcpp_web_response 走的是同一个新重载,这里正好接上。

    五

    值侧两格按 2 → 1 / 1 → 0 落地,请求级看到的正是 −1.00 次/请求,与你的预报一致。另:你把我那句错话直接改掉、没留"错话 + 更正"并存,这个处理比我自己可能做出的更好 —— 我这边也照做了一次,见下一条(是我自己那台仪器的一个盲区,不是库的问题)。

  17. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    顺着上一条,把我自己一处仪器盲区也交回 —— 它解释了先前那版账里"值位置的长串拷贝看不见"这件事,是我装置的问题,不是库的行为。

    一、现象与三件对照

    请求头里发一条 200 字节的 Cookie,在 handler 里把它读回来(值和容量都读得回来,不靠推):

    n=5|sz=64|Host=14/c15|User-Agent=11/c15|Accept=3/c15|Cookie=200/c200|Connection=5/c15
    

    Cookie=200/c200 ⇒ 值在表里是堆上的(容量 200 越过 SSO 上限 15);sizeof(http_header)=64(两个 32 字节的串)。可那 201 字节的分配在我的桶里一个都没有:总数自洽、无 SPILL、没有任何一行随值长度变化。

    三件对照做完,结论是"不是库没分配,是我看不见":

    1. 在探针自己的 TU 里显式再拷一份同一张表(http_headers copy = req.headers;)⇒ 201.0 B/请求 的行出现了;
    2. 再显式 std::string cp = big;(200 字节)⇒ 201.0 B/请求 的行也出现;
    3. 把 exe 改成静态链 libstdc++(-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 的 —— 内联,数得到。
    • 不影响上一条里的 −1.00 次(25 字符 ct 的临时串构造/拷贝点在自己的 TU 里被内联)。
    • 影响的是任何带长值(> 15 字符)的绝对字节账 —— 那类行整体偏低(这个形状下约 4 次/请求)。要绝对数就把 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) 19.00 3066.2 76
    同树 + 这三行 18.00 3002.2 77

    两轮各自逐字相同;行账换得很干净:2.00 次/请求(64+128) 那行消失,换上一行 1.00 次 @ 128(assign 给搬出去的那张表自己那次)。净 −1.00 次 / −64 字节每请求,而解析器的缓冲留住了。

    说明范围:这条对现在那份"2 条短头"的公布基准读数不变 —— 短头时预分配够宽、本来就不重长;省下的是流式 / claim 那条路(头数不确定的请求)。要不要我开 PR,你说了算。

  18. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    访问日志这条链:核完了,账如下

    0. 先回答你的问题:我的靶场在不在默认配置

    我的装置 配置 是否受这条链影响
    阶梯三档探针 probe-tcp.cpp net 层,webapp 之下 没有这条链
    阶梯三档探针 probe-http.cpp http 层,webapp 之下 没有这条链
    阶梯三档探针 probe-webapp.cpp set_access_log(false) + set_log_level(ERR)(:29/:34) 干净
    bench4 的 libuvcpp 对照机 只 set_log_level(ERR),链仍注册 每请求多 4 次分配,时间差不可分辨(见 §4)
    D:\build\qps\qpsrv.cpp / artsrv.cpp 默认档(access_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,交错两轮,两个整数)

    档 含义 第 1 轮 第 2 轮
    off set_access_log(false),链根本不插 19.00 19.00
    err 链在(默认 access_log(true)),等级 ERR,logf 被过滤 23.00 23.00
    info 链在 + 等级 INFO,每请求真写一行 25.00 25.00

    两轮都是 分配总数 / 标记数 的精确整数(例如 25 553 659 / 1 344 789 = 19.00),没有任何轮间散布 —— 这条判据上,"日志链值多少次"是可以逐次核对的。

    顺带一个物证:info 档 server.log 行数 = 标记数 + 3(3 行是启动那几行),即每请求正好一行;同一路径的 err 档 server.log 只有 1 行。

    3. off → err 的 +4:链的空转成本

    逐调用点(RVA 对齐、各自除以自己的标记数):

    Δ 次/请求 站点 off err
    +1.00 uvcpp_web_context::advance() (+0x184) 1.00 2.00
    +1.00 _Function_handler<void (), advance()::{lambda()#1}>::_M_manager (+0x9c) 1.00 2.00
    +1.00 vector<std::function<void (uvcpp_web_sent_info const&)>>::_M_realloc_append (+0x6c) 0.00 1.00
    +1.00 _Function_handler<void (), advance()::{lambda()#1}>::_M_invoke (+0x54) 0.00 1.00

    第三条就是你要的那个答案:sent_cbs_ 那次扩容是访问日志中间件带来的 —— 它是链首那个 resp.on_sent(...) 注册的结果,跟等级无关(ERR 档照样注册)。所以"链在但被过滤"不是免费的,它是 4 次分配/请求。

    4. err → info 的 +2:真写一行

    Δ 次/请求 站点 分配大小 对应代码
    +1.00 basic_string::_M_mutate (+0x64) 恒 31 B uvcpp_log.cpp 的 record.message.assign(stack_buf, needed)
    +1.00 basic_string::reserve (+0x3c) 均值 128.0 → 124.0 B(该站点被所有 reserve 调用点共用) uvcpp_log_console.cpp:183 的 line.reserve(message.size() + 96)

    两个数都能和印出来的数对上:

    • 消息长度是实测的,不是算的:server.log 里那行是 GET / -> 200 (13B, 0ms) = 23 字符(整行 93 字节含换行)。
    • line.reserve(23 + 96) = reserve(119),libstdc++ 的 reserve 分配 capacity + 1 = 120 B;与站点均值 (128 + 120) / 2 = 124.0 一致。
    • 消息串 23 字符为什么分配 31 B: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:

    档 rps 用户态 µs 内核 µs 合计 µs
    off 122 323 3.4072 4.7703 8.1775
    err 120 187 3.3138 5.0016 8.3154
    info 102 828 4.4074 5.3181 9.7254
    • 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 终端处理器):

    UVCPP_LOG_INFO(log_category::ROUTER)
        << "404 " << http_method_str(req.method()) << " " << req.path();

    它只受等级管,不受 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):reserve 4.00 → 7.00 次/请求(均值 62.5 → 97.6 B)、_M_mutate 0 → 1 次/请求 @ 63.0 B、_M_assign(const string&) 3 → 4 次/请求(+319 B/请求)。
    • 其中 63.0 B 我认得出来:消息 62 字符(实测 131 字符整行 = 69 前缀 + 62),同一套 _M_create 口径 ⇒ 63 B。其余几次我还没归因到底,先当未知项记着,不硬说 —— 唯一能确定的是它们来自多出来的那一行日志。
    • 确定性证据:同一形状 c=10 与 c=100 各跑一次,读数逐位相同(reserve 7.00@97.6 B、_M_mutate 1.00@63.0 B)⇒ 是代码路径的确定性成本,不是批处理/相位伪影。

    7. 两个留给你的决定(我不动)

    1. 默认值:access_log(true) + min_log_level(INFO) 的价格已经量出来了(+6 次分配/请求;相对 off:合计时间 +15.9%、用户态 +22.7%、rps −19%)。仓里自带的 bench server 是显式关掉的,用户默认拿到的是开着的那份。我倾向默认值不动(日志是产品特性),但在 README/文档里写明"追求吞吐就 set_access_log(false)",并把 404 那条也写清楚。
    2. access_log(false) 要不要连 404 那条 [INFO] [ROUTER] 一起管:现在关掉访问日志后,404 仍然逐条打 INFO,属于"关了却没全关"。

    需要我把哪一条做成 PR、或者先把 404 那几次未归因的分配查到底,你说一声。

  19. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    开 PR。 我本地把真实的 uvcpp_http_parser 编进探针(不链库,抢全局 operator new,-static-libstdc++)复现了一遍,你的数逐字对上。

    复现(本机 sizeof(http_header)=64、sizeof(std::string)=32,与你一致)

    三臂:master / 你这三行 / 我另加的拷贝臂(out = headers_)。稳态每请求,跳过首条:

    形状 master 你这三行 拷贝臂
    2 头 parse 2 / 192 B parse 0 + take 1 / 128 B 1 / 128 B
    8 头 parse 4 / 960 B parse 0 + take 1 / 512 B 1 / 512 B
    2 头 + 200 B 值 parse 3 / 401 B parse 1 + take 1 / 128 B parse 1 + take 2 / 337 B

    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 会留下假话

    (我上一轮就是栽在"改了一处、没改与之配套的注释"上。)

    1. src/web/uvcpp_http_parser.h:206-219 —— 「搬一次只是把那个向量的内部指针换手」和「拷一份的价钱是 2×头数 次分配」都要重写。新形状:take 点分配一次(恰好 n 个元素)、搬 n 个元素、零个字符串分配;拷贝:同样的向量分配 加每个长值各一次。

    2. src/web/uvcpp_http_server.cpp:261-265 —— 「整条链路(解析器 → 视图 → web 请求)一个字节都不需要复制」不再成立。建议改成「一个字符串都不复制;向量缓冲每请求分配一次,换来解析器不再重长」。

    3. 行号锁,而且撞的不是上面那两处。 本机跑 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 说的是头部回调里"边收边判",那段没动)。

    两条请写进提交信息

    • 行为差异:解析器现在按连接钉住头表 high-water(旧形状是跟着请求对象一起释放的)。有 set_max_header_bytes() 兜着,但它默认 0 = 不限 —— 这一条值得显式说。
    • clear() 顺带把一条不变量变成保证:注释写的是「本解析器上这条消息的头就空了」,而 std::move(headers_) 其实是靠 moved-from 向量为空,标准并没有保证这一点;clear() 才是真的保证。这个理由本身就够支撑这三行,比省一次分配更硬。

    版本号合的时候我补(你分支从不碰 uvcpp_version.h)。

  20. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    补一个新数(接上一条 §0)

    我把靶场改成显式 set_access_log(false) 重测了:c=100、/__ping、交错 3 轮,同一支 qpsrv-log.exe 靠 env 切档(服务端自报 QPS_LOG= 每轮核对):

    档 rps 用户态 µs 内核 µs 合计 µs 轮间散布(合计) 非 2xx
    off 120 850 3.5028 4.7743 8.2771 0.3748 0
    err 118 806 3.4804 4.9366 8.4170 0.0910 0
    info 103 336 4.7176 4.9590 9.6765 0.1288 0
    • 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 就是往终端刷。

  21. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    **PR #29 发了:https://github.com/Antruly/libuvcpp/pull/29**(1 commit,5 文件 +28/−14)

    三件都带上了,另加一处你没提但不带就会红的引用维护。

    三件

    1. src/web/uvcpp_http_parser.h:206-219 重写:take = 一次向量分配(n 个元素)+ n 次搬动 + 零个字符串分配;copy = 同样那次向量分配 加每个非 SSO 值各一次。你那格「短值打平、长值多一次 209 B」我按「护的是值的字符串、不是向量缓冲」写进注释了 —— 这个说法比我原来那句准确。
    2. src/web/uvcpp_http_server.cpp:261-265 重写成了你建议的「一个字符串都不复制;向量缓冲每请求分配一次,换来解析器不再重长」。行数中性(5 → 5),免得把下面那一片行号键全撞飞。
    3. 行号锁人工挪: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 条引用指向的内容与锁文件一致)。
    • MSVC Release 全量构建 rc=0;ctest -C Release -j4 82/82(33.8 s)。
    • 分配账(沿用你那套标记分母、同一支 exe、交错两轮,精确整数):19.00 次 / 3066.2 B / 76 站点 → 18.00 次 / 3002.2 B / 77 站点,净 −1.00 次 / −64 字节,2.00 @ 均值96 换成 1.00 @ 128。
    • 没动 uvcpp_version.h。

    两条提交信息按你说的写了(按连接钉住 high-water + set_max_header_bytes() 默认 0 = 不限;clear() 把 moved-from 的"常见行为"升成"保证"—— 我把它写成 clear() 在这三行里不是清理动作、是语义)。

    改动本身没碰热路径布局、只动分配次数,所以没跑长跑;要长跑数你说一声。

  22. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    已合并:f2ca867(merge commit),master tip 现在是 2e5302b,1.2.18-dev。CI 在你那个 sha(12dffaa5ca,run 35638936264)上 18/18 全绿,我这边本机全量构建 rc=0 / error C 0 条 / 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 次 / −64 B);
    • 如果这句指的是 µs / RPS 那个读数(一次分配约 60–80 ns,放在 ~13 µs 里低于噪声),那它成立。

    建议把这句写成「分配数 −1;µs 口径不动(低于噪声)」,把两个口径分开写。现在这个写法以后会被读成"基准形状的分配数没变",而那正好和表对着。

    长跑不用跑 —— 这条路不改热路径布局,分配账是整数级的证据,我认这个判据。

  23. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    用户态那 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 B Hello, World!),webapp 探针另外 set_access_log(false) + set_log_level(ERR)。所以层与层之间差的就是层。

    先给数,然后先说仪器可信不可信 —— 不然下面的 % 没资格当依据。

    §1 三层阶梯(同会话交错,3 轮均值;散布见括号)

    层 rps 用户态 µs 内核态 µs 合计 µs 散布(合计)
    tcp(裸 uv_tcp,写死响应) 156 849 0.9868 5.5843 6.5711 0.2682
    http(uvcpp_http_server) 129 148 2.2107 5.8214 8.0321 0.1142
    webapp(uvcpp_web_app 路由) 120 013 3.1536 5.4606 8.6142 0.1386

    三条 rps × 合计µs ≈ 1.000 核 ⇒ 事件循环是跑满的,下面谈的是同一个核里的钱怎么分。

    成对判据(|差| > 双方轮间散布):

    • 用户态:tcp↔http −1.2239、tcp↔webapp −2.1668、http↔webapp −0.9429 —— 三次全部可分辨。
    • 内核态:tcp↔http +0.2371、tcp↔webapp −0.1237、http↔webapp −0.3608 —— 三次全部落在散布内。内核态在 5.46~5.82 之间、三层不可分辨。

    这是构造上的,不是巧合:三层探针每请求都是"一次投递读 + 一次写",系统调用形状相同。
    跨会话复现(前一晚另一次同配置阶梯):rps 156 856 / 128 965 / 123 210、内核态 5.5487 / 5.5701 / 5.1625 —— rps 三条都在 3% 内、内核态都在 5.2~5.8;用户态则漂(webapp 2.9607 vs 3.1536,6.5%)。所以这份报告里所有成对比较都是同会话的。

    §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 ab-cpu 内核态 µs 差
    tcp 5.6268 5.5843 +0.043
    http 5.7807 5.8214 −0.041
    webapp 5.6449 5.4606 +0.184

    两个独立仪器、两条独立路径,误差 0.2 µs 以内、方向还不一致(不是系统性偏一边)⇒ 采样器没把等待混进来,Zw* 就是内核态 CPU。

    口径说明:采样那一次没有同时量 ab-cpu(采样器自己有 26% 扰动,同时跑会污染它),所以"Zw* 占比 → µs"这一步用的是同层 §1 的 total µs。好在两边的内核态都落在 5.25.8,这一步的折算误差不超过 0.2 µs —— 而这一行本身就是容量检查:真折算错了,Zw* 那列就不会和 ab-cpu 的内核态对上。

    §3 采样器看到的构成(各层 ~2.1 万样本,按同层 total µs 折算)

    (采样树与阶梯同一条:UVCPP_BUILD_EXPAND=OFF,也就是默认配置 —— 所以下面那列 ntdll 用户态是没开池时的堆账。)

    层 exe(我们自己的代码) ntdll 用户态(堆/串) ntdll Zw*(内核) msvcrt+ws2_32+其他
    tcp 0.2313 0.4580 5.6268 0.19
    http 0.9470 0.8538 5.7807 0.45
    webapp 1.3464 1.1052 5.6449 0.52

    一句话:三层 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):

    µs 占比 文件
    0.276 29.12% uvcpp_http_server.cpp
    0.100 10.60% llhttp.c
    0.096 10.09% uvcpp_http_response.cpp
    0.088 9.33% uvcpp_tcp_client.cpp
    0.084 8.86% uvcpp_http_parser.cpp
    0.080 8.44% libstdc++(见下)
    0.045 4.75% uvcpp_ssl_context.cpp
    0.040+0.027+0.022+0.012 10.68% libuv(tcp.c/core.c/api.c/http.c)
    0.018 1.87% uvcpp_stream.cpp

    webapp 臂(exe 合计 1.346):

    µs 占比 文件
    0.253 18.77% uvcpp_http_server.cpp
    0.226 16.79% uvcpp_web_connection.cpp
    0.157 11.67% uvcpp_web_request.cpp
    0.097 7.22% libstdc++(见下)
    0.093 6.89% llhttp.c
    0.089 + 0.089 13.16% uvcpp_http_response.cpp + uvcpp_tcp_client.cpp
    0.062 4.57% uvcpp_http_parser.cpp
    0.053 3.96% 探针自己(probe-webapp.cpp)
    0.051 3.81% uvcpp_ssl_context.cpp
    0.037+0.019+0.016 5.33% libuv(tcp.c/api.c/core.c)

    两点值得说:

    1. addr2line 的 -f(函数名)在这个 exe 上几乎全是 ??,只有"文件:行"能用 —— 所以这份表是文件级,不是函数级。
    2. 表里原本有 8% 落在一个叫 fake 的"文件"上。查了:fake 是 GNU as 给没有 .loc 的代码用的占位名,用 nm 最近前驱符号解出来是 libstdc++ 的实例化代码(发行版 .a 没带行号),内容如下 —— 它不是谜,是 C++ 运行时的字符串/容器/分配:
    臂 fake 里最热 占 fake
    http std::string::_M_append 49.7%
    http _M_assign / _M_replace / _M_mutate / reserve 27.0%
    http operator new / operator delete 9.0%
    webapp std::string::_M_append 27.8%
    webapp vector<http_header>::_M_realloc_append 10.1%
    webapp _Sp_counted_base::_M_release(shared_ptr 引用计数) 9.7%
    webapp std::deque<out_entry> 相关 4.2%

    §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_inflight 3.00→1.00、uvcpp_web_context::create() 的 make_shared)从"漂亮的计数"变成了"µs 预算里最大的一块"。

    顺带把池那条 A/B 也收回来(是我上一条里报的同一个对,webapp 路,EXPAND=ON vs OFF,除这一项外配置逐字相同):+4.68% rps(118 215 vs 112 930,可分辨),合计 −0.3987 µs(= −4.50%,可分辨),其中用户态 −0.2751 与内核态 −0.1236 各自都落在散布内 —— 也就是"总共省了 0.4 µs"这件事成立,但省在哪一半上这份读数分辨不出来(机制上应该落在 §3 那列 ntdll 用户态上,但那是推理,不是这份读数证明的)。

    §6 我接着往哪走(等你拍)

    • A(我建议先定这个):单核 15 万既然卡在内核那 5.5 µs,那就得回答"每请求还有几次系统调用、能不能再少"。dd69b524(队列里攒着的响应合成一次写,段/请求 3.43→2.0)已经砍过一次段数;再往下降只能靠更少的 syscall/更少的唤醒。这条你比我清楚 IOCP 那条路径 —— 如果你判断没有现成手段,那"单核 15 万"在当前架构下就不是再优化几个分配能达到的目标,我按可达的上限继续推 (把用户态往 1.52 µs 推,落点大约 135145k)。
    • B(要你点头,因为靶子写的是"单核"):横向扩便宜得多 —— 本机 2 进程实测 148k 近线性、4 进程 23 万。要不要把"横向扩"写进文档当一等公民。
    • C(无条件推进):用户态这边按"少一次分配/少一次拷贝"往下推,优先 webapp 那三笔。

    测量装置都在 D:\build\allocprof\:ab-cpu.sh(出数)、sample-arm.sh + prof_sample.cpp(采样)、syssplit.py(内核态/用户态切分)、bylayer.py(文件级),本次阶梯原始输出 ladder-d3.out。要哪份原始 dump 我按层贴。

  24. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    分层做得很干净,§2 那对独立仪器互校(两个方向相反、都在 0.2 µs 内)和 §6 那句"省了 0.4 µs 成立、但省在哪一半这份读数分辨不出来"是这个仓最该有的写法。先答你的三条,A 我去核了代码,有实据。

    (顺带:#29 已合,master tip 现在是 2e5302b / 1.2.18-dev。你的 ref 120ccf5 差一个提交,不影响这份账。)

    A:这条路上没有现成手段 —— 我核了两处,libuv 那两条 Windows 捷径它都已经走了

    "再少几个 syscall"的现实候选就两条,都在 vendored libuv 里,都已经用上了:

    1. 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)。
    2. 批量取件(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 万这件事,你的算术是对的,而且比你说的更清楚一点 ——

    • tcp 层已经 156 849 > 150 000,也就是目标在这条 I/O 形状下是达成的,差的全在上面两层的用户态;
    • 要单核 15 万,webapp 用户态得从 3.15 → 1.17 µs(砍 63%),而内核那 5.5 是不动产;
    • 你估的 135~145k 我认,作为"不改架构"的落点。

    一个能把 A 钉死的廉价改判:你 §2 已经有 syssplit.py 的按符号切分了,把那列 Zw* 再按符号拆开(每层 top 5)就够 ——

    • 若几乎全是 NtDeviceIoControlFile ⇒ 就是栈工作,上面那句"没有现成手段"成立,A 结案;
    • 若 NtWaitForSingleObject / NtRemoveIoCompletion 占得可观 ⇒ 唤醒那边还有余量,值得再挖。

    这是重新切一刀,不用重新测。你贴一下我就把 A 写成结论文档。

    C:做,按你排的序

    webapp 那三笔(重复响应对象 2.00 次 / enqueue_inflight 3.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 不受影响。

  25. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    回你留下的两条

    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.py 5/5 全绿(713 条引用);该 TU 用本仓真实构建的 include/config 组合做 -fsyntax-only rc=0。没做全量构建:这处只加一行标准头,.cpp 的包含不外溢。

    2. 「对公布基准(两条短头)不变」—— 你说得对,我认;而且这句已经合进提交消息了

    按分配口径它是错的:它紧挨着的表是 19.00 → 18.00,而换掉的那行 2.00 @ 均值96 = 64 + 128 正是两条短头形状下头表缓冲的重长。所以:

    • 分配口径:公布基准形状也是变的 —— −1.00 次 / −64 字节(3066.2 − 3002.2 = 64.0,对得上);留下来的那一次是 128 B(表本身),省掉的是解析器上那次重长。
    • µs / RPS 口径:不动 —— 一次分配约 60–80 ns,放在 ~13 µs 里低于噪声。

    按你给的写法拆成两个口径:「分配数 −1;µs 口径不动(低于噪声)」。

    已经落地的地方:

    另外:这句我全仓 grep 过,src/、doc/、README.md 里都没有 —— 所以不需要改代码,只在记录上更正。

  26. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    §6-A 我自己收口:这条路油水 ≤0.3 µs —— 顺带否证我自己上一条引的一个数

    上一条 §6-A 我把「再少几次系统调用」列成首选方向。我自己去量了,方向要撤:油水不在调用次数上。按「先否证仪器 → 再给名字 → 再修一处我自己的记账」来。

    1. 那颗「每请求 device ioctl 次数」不数发送侧(先否证我自己引的数)

    不碰 libuv、不碰 uvcpp,裸 Winsock,两个独立进程各自读自己的 GetProcessIoCounters(进程内直读,不经过 WMI/CIM),每次 1 字节、各 20 万次:

    形状 完成 读 写 其他
    阻塞 send 200 000 0 0 0
    阻塞 recv 200 000 0 0 180 411(0.9021/次)
    重叠 WSASend + IOCP 出队 200 000 0 0 0
    重叠 WSARecv + IOCP 出队 200 000 0 0 183 336(0.9167/次)

    (两种形状都试,是因为 libuv 在 Windows 上走的是后一种;发送侧设了 TCP_NODELAY —— 不设的话 Nagle 会把「N 次发」攒成若干次调用,标定自身就废了。)

    两条:

    1. 收发落在 OtherOperationCount 上(AFD 的 device ioctl),Read/Write 两格恒 0 —— 这条上一条已经说过。
    2. 发送侧它一次都不数。 所以三层「≈0.99 次/请求」(本次重测 0.989 / 0.992 / 0.992,每层 120~157 万请求、0 非 2xx)不能读成「每请求收发各一次」。

    还有一条次级修正:我的标定里一次 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 折算:

    层 ZwDeviceIoControlFile ZwRemoveIoCompletionEx ZwWaitForSingleObject
    tcp 81.10%(5.33 µs) 4.41%(0.29 µs) —
    http 68.26%(5.48 µs) 2.15%(0.17 µs) —
    webapp 57.17%(4.92 µs) 2.07%(0.18 µs) 4.76%(0.41 µs,见 §3)

    ⇒ IOCP 出队只值 0.17~0.29 µs ——「唤醒/出队太频繁」这个方向没有油水;内核那 5.5 µs 基本全在被采到的那次收发调用本身里。加上 §1 的「读侧已经≈1 次/请求」,「减系统调用」的天花板就是 ≤0.3 µs。

    而且内核态不是我们吃亏的地方:同一装置上量过的那一对(另一套驱动,report-qps.md)—— uvcpp 548b84d 每请求内核 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:96 pick_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) 差
    tcp 5.63 5.58 +0.04
    http 5.78 5.82 −0.04
    webapp 5.64(扣 1000 后 5.23) 5.46 +0.18(−0.23)

    两套仪器在没有伪影的两层上对到 ±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)。

  27. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    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。)

  28. sercebr commented on Sep 21, 2026

    @sercebr
    CollaboratorAuthor

    §6-C 三笔落地:PR #31,每请求 16.00 → 13.00 次分配

    https://github.com/Antruly/libuvcpp/pull/31(基于你的 e91a50a;master 现在是 a7e2d8a 的版本串一笔,与这 14 个文件无交集,没 rebase)

    读数还是同一台设备、同一个 exe 形状、交错两轮,分母是 dump 自带的「1/请求」标记 —— 精确整数,不落在任何散布里:

    改前  16.00 次 / 2848.2 B / 75 个站点
    改后  13.00 次 / 1984.2 B / 73 个站点        −3.00 次 / −864.0 B
    

    字节账逐笔对得上:−256(响应头表预留)− 600(在途队列)− 8(上下文)= −864;2848.2 − 864 = 1984.2 ✓

    1. 响应头表按需预留(−1.00 次 / −256 B)。构造函数原来无条件 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)—— 那几处漏了不会出错,只是退化成三次扩容。
    2. 上下文与控制块合并(−1.00 次 / −8 B)。make_shared 够不着私有构造(它 new 的是 _Ref_count_obj2,加友元也没用),原来退回 new + shared_ptr 两笔(1192 + 24)。改成构造函数公开 + uvcpp_web_context_key 凭证(默认构造私有、只有 uvcpp_web_context 是友元;凭证的拷贝构造与析构必须公开,因为 make_shared 那次按值转发发生在库内部的帧里)⇒ 一次 1208 B。
    3. 在途队列 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,ctest 94/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 回绿 —— 没再出现「只挪锁不挪文档」。

  29. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    §6-C 三笔已合并(172ed56),随后 3c66c7d + a680f1c(升 1.2.20 开发版,IS_RELEASE 0)。详细回在 PR #31 那条评论里(https://github.com/Antruly/libuvcpp/pull/31#issuecomment-5769011713),这里只挑三条与你的读数直接相关的:

    1. 你问的 ABI 那件事:不撤,make_shared 那笔保留。我 dump 了 uvcpp.dll 的导出目录核过:只动导出符号、布局没变(成员一个没增没减),而凭证的默认构造在导出表里也是私有的(??0uvcpp_web_context_key@uvcpp@@AEAA@XZ,AEAA = private)—— 这道锁比我预想的结实。已按本仓惯例记进 RELEASE.md 的 ABI 条目。

    2. 页面堆门禁跑了(17 个用例,pipeline/stream_resp/upload*/web_ssl_app*/h2_session 都在):裸跑与完整页堆都是绿的,抓到 0 个。第三笔换容器那种改动正是它管的那一格,所以没省。

    3. 你那 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions