Repository navigation
多事件循环共享同一监听端口:Windows 没有 SO_REUSEPORT,实测「单接受者 + 无锁转手」到 3.3~3.4×(附机制是非题与进程级上界) #32
Description
多事件循环共享同一监听端口:Windows 上没有 SO_REUSEPORT,实测「单接受者 + 无锁转手」可到 3.3~3.4×
结论先说
可行,而且这是 Windows 上单端口吃多核的唯一一条路。 我把两套候选机制都写成探针实测过了(纯 libuv,不碰 uvcpp),在一个会话里交错量:
| 机制 | N=1 | N=2 | N=4 | N=8 |
|---|---|---|---|---|
| 各循环自己 bind 同一端口(对照组) | — | — | 143 666(101 条连接全落 loop0,另 3 个循环 0 请求) | — |
复制监听句柄(WSADuplicateSocket + 每循环 uv_listen) |
147 203 | 146 718(101 条连接全落 loop1) | 438 376 | 453 741(8 个循环里 5 个拿到 0) |
| 单接受者 + 无锁转手 | 142 635 | 336 006(2.36×) | 469 175(3.29×) | 492 267(3.45×) |
(探针:并发 100 / 5 秒 / 1 客户端,写死 149 字节响应体;全部档位非 2xx = 0。3.33.4× 是 48 循环的实测倍数,不是理论值。)
同会话的进程级上界对照(整套 webapp 栈、每进程一个客户端):1 进程 88 419 / 2 进程 154 115 / 4 进程 255 271 rps(每进程 88k→77k→63k)。多循环的实现只能逼近这个数、超不过它 —— 也就是说这条路的收益空间是 2.9× 左右,探针已经拿到了其中绝大部分。
为什么不能"各循环各 bind 一次"
Windows 没有 SO_REUSEPORT。但真正危险的不是它不存在,是它的失败方式:上面第一行对照组里,4 个循环各自 WSASocket + SO_REUSEADDR + bind + listen 全部成功,没有任何错误码,然后 101 条连接全部落到 loop0,另外 3 个循环 0 请求 —— 读数 143 666 ≈ 单循环,你会以为自己在跑 4 个循环。这是静默错配,不是配置问题,只能靠显式转手解决。
顺带一个对照:hical 在 Windows 上就是这个处境。它的源码里写明「SO_REUSEPORT 多 acceptor,不支持时回退单 acceptor」,我实测它的 HttpServer(port, 1) 与 (port, 8) 打平(127 491 vs 127 139 rps、每请求 µs 到小数点后两位相同),压测中按线程采样是 8 个线程恰好 1 个忙。所以它在 Windows 上多线程参数不改变占用核数;libuvcpp 如果能把这条路走通,是实打实的架构级优势。
机制上的一条硬约束(这条决定了实现形态)
完成端口关联是一次性的,改不了。 探针 iocp-reassoc.exe 的读数:
第一次关联 A:成功
第二次关联 B:失败 (GetLastError=87) ← 87 = ERROR_INVALID_PARAMETER
WSARecv 挂起后对端发 1 字节 → 端口 A:收到 key=1
端口 B:没收到(1200ms 超时)
⇒ 即使第二次调用失败,完成事件仍然进第一个端口,B 永远收不到。这意味着:
- 接受循环用
uv_accept拿到的 socket 已经挂在接受循环的 IOCP 上了,不可能再转手给工作循环。所以「uv_accept之后把 fd 交给别的循环」这条路是死的。 - 转手必须发生在目标循环第一次关联之前。可行的形态只有一条:接受者自己 raw
accept()(或自己写 AcceptEx),拿到还没被任何 IOCP 碰过的 socket,交给目标循环,由目标循环在自己的线程里uv_tcp_init/uv_tcp_open/uv_read_start—— 第一次CreateIoCompletionPort就是它做的,之后这条连接的所有完成事件天然落在目标循环上。
建议的实现形态(开关,默认 1 个循环)
- 默认 n=1 时逐字节等同今天的行为,新路径完全不走。
- n>1 时:一个接受者 + n 个循环。接受者只做
accept()然后用 每循环一个无锁 SPSC 环(std::atomic<long long> head/tail+ release/acquire,单生产者单消费者)交接 socket fd,uv_async_send唤醒目标循环,目标循环在自己的线程里做uv_tcp_open接入。轮转ctxs[k % n]。 - 热路径上没有任何锁:环是无锁的,路由/中间件表在
start()之后只读,连接的每请求状态本来就是 per-object(我静态过了一遍 http/webapp/net 三层,loop归属链是干净的,default_loop()从来没被调用)。 - 接受者形态:最简单的原型是一个阻塞
accept()线程(上面的读数就是它,转手本身零代价 —— ho 档 N=1 的 142 635 ≈ dup 档 N=1 的 147 203,差异落在散布内);也可以让 loop0 用自己的线程用uv_poll盯着监听 socket、在事件里 rawaccept(),全部并入 libuv 事件模型。两条我都试过前者,后者只是把同一个 syscall 换个地方发起,机制上等价。 - 工作线程自然就是共用的:
uv_queue_work走的是 libuv 那个进程级全局池(UV_THREADPOOL_SIZE,默认 4),n 个循环本来就在喂同一个池 —— 「4 个循环共用 4 个工作线程」这一半不需要新机制。
需要一并处理的四处进程级可变状态
都是静态审计找出来的(只有静态审计:本机 MinGW 没有可用的动态竞态检测器,所以这一节的把握度低于上面的实测数字):
src/webapp/uvcpp_web_app.cpp:1600的uvcpp_logger::instance().set_level(cfg_.min_log_level)——uvcpp_logger是进程级单例(set_level/set_sink都是全局的,src/webapp/uvcpp_log.h:216-230),每个 app 在start()里各写一次 ⇒ n 个循环下就是后写覆盖。而且 logger 调用 sink 时持有自己那把锁(src/webapp/uvcpp_log.h:141-142),所以开着访问日志时它就是一条跨循环的串行点 —— 这和我之前量到的「off − info用户态 −22.7−25.8%、rps +1719%」是同一件事。src/webapp/uvcpp_web_mime.cpp:141的default_map()—— 非 const 静态 map,运行期必须只读。src/uvcpp/uvcpp_interface_address.cpp:15-33的get_all_interface_addresses()——staticvector 上先clear()再push_back,并发调用有竞态;不在 webapp 热路径上,但多循环下仍要收口。src/webapp/uvcpp_web_upload.cpp:155的临时文件名 RNG —— 一把static std::mutex,跨循环串行点(只在上传路径上)。
另外闸门要重算:work_limit_(src/webapp/uvcpp_web_app.h:1213,set_work_limit 在 396-403)是每 app 的准入闸门,默认按「一个 app 配一个池」推出来的 池大小 × 4 / 下限 16。n 个循环共用一个 app 时它还是 16,于是从「保护」变成「天花板」:池里 4 个线程能吃的在途工作被一个为单循环设的数卡住。
我建议的 API 形态
uvcpp_web_app 上加一个 set_loop_count(n)(默认 1),start()/start_background() 时按 n 起循环;一个 app、n 个循环,路由表共享只读。不建议「n 个 app 实例各绑一次同一端口」那种形态:那要求用户把路由注册重复 n 遍,而上面那四处进程级状态换了形态也照样是全局的,所以它并不换来更多安全性,只换来更差的 API。
我可以接着做的
探针 probe-mloop.cpp 已经是可跑的形态(约 400 行,纯 libuv),机制读数全部可复现。如果要我提 PR,我倾向先把上面的 API 形态和你确认一下再动 —— 因为落地的工作量集中在 uvcpp_tcp_server 的接入路径(要绕开 uv_accept,接受者得自己 raw accept,还要把 remote addr / 连接计数一类的接受期记账补回去),这块动到的是公开行为,我想先听你的判断。
装置与原始读数都在本机 D:\build\allocprof\(probe-mloop.cpp、iocp-reassoc.cpp、mloop-final.sh、mloop-run.sh)。
Activity
Antruly commentedon Sep 22, 2026
先说四件核过的事:机制读数我信(探针在你机器上,我复现不了,按"你的读数"引用);§硬约束 那段的推理方向是反的;你列的四处逐条核过、行号全对,但它们不是闸门;
work_limit_那条诊断反了。set_loop_count(n)这个 API 形态我同意,见第四节;但有个前置的工作量你没算进去,见第二节。一、
uv_accept的 socket 关联到哪个 IOCP,由client->loop决定你写:
我对着
_local_deps/libuv的源码逐层核了,链条是这样的:src/win/tcp.c:393uv__tcp_queue_accept()——accept_socket = socket(family, SOCK_STREAM, 0)(:411)造一个全新 socket,然后在它上面发 AcceptEx。此刻没有任何CreateIoCompletionPort碰过它。src/win/tcp.c:641uv__tcp_accept()—— 拿到server->tcp.serv.pending_accepts里的那个 socket,最后一句是uv__tcp_set_socket(client->loop, client, req->accept_socket, family, 0)(:662)。src/win/tcp.c:79uv__tcp_set_socket()—— 关联就在这一句:CreateIoCompletionPort((HANDLE)socket, loop->iocp, (ULONG_PTR)socket, 0)(:102),而loop是形参。所以那个
loop是client->loop。关联到哪个完成端口,由你把 client 句柄 init 在哪个 loop 上决定,不由接受者决定。AcceptEx 出来的 socket 在uv_accept之前不属于任何 IOCP。你
iocp-reassoc.exe读到的"第二次关联失败 87"没错,但它的含义是"已经关联过就不能再关联",不是"已经关联到接受者了"。这两句差别很大:按前一句,转手是死的;按后一句,转手是内建的。这条差别值一次 30 行的探针:把 client 预先
uv_tcp_init(loop_k, …),在接受者的 connection 回调里直接uv_accept(server, client_k)—— 关联就在这一句里落到 loop_k 的 IOCP 上,不需要 raw accept、不需要 SPSC 环、不需要uv_tcp_open。但它不能当主线,两个理由,第二个是硬的:
uv_accept的…note:: server and client must be handles running on the same loop.(_local_deps/libuv/docs/src/stream.rst:133-134)。_local_deps/libuv/src/unix/stream.c:539—assert(server->loop == client->loop);。也就是说那是 Windows 实现上的宽容,Debug 版 Linux/macOS 直接断言死,而我们 CI 18 个 job 里一半是那两条腿。
⇒ 你的 (raw accept + 转手) 形态是对的,但理由不是"转手是唯一的路",是"另一条路未文档化且 Unix 上断言死"。 那个探针仍然值得跑:Windows 腿若成立可以少一大块代码;Unix 腿的正规解法是
SO_REUSEPORT各循环自己 listen(内核分流),不像 Windows 上是静默错配。顺带:raw 那条路在 handle 层已经有口子 ——
src/handle/uvcpp_tcp.cpp:53uvcpp_tcp::open(uv_os_sock_t)就是uv_tcp_open。二、你说"热路径上没有任何锁",结论对,但不是因为你给的理由
per-object 是真的,但装这些对象的容器不是 per-object。
std::map/std::list/std::deque并发插不同 key 也是 UB —— 而下面这几个容器都是 per-app / per-server 的,不是 per-loop 的:uvcpp_http_server::contexts_(std::map<uvcpp_tcp_client*, conn_ctx>)src/web/uvcpp_http_server.h:988.cpp里 52 处引用,find()出现在:331 :409 :592 :697 :748 :796 :823 :1115 :1270等;插在:179 :200uvcpp_web_app::inflight_src/webapp/uvcpp_web_app.h:1489enqueue_inflight/context_finished)uvcpp_web_app::upgraded_src/webapp/uvcpp_web_app.h:1433:1462、擦:2264、查:2402)src/webapp/uvcpp_web_app.h:1503uvcpp_tcp_server::clients_(std::list)src/net/uvcpp_tcp_server.h:468push_back(:220 :561)、关闭时remove(:315 :504 :512 :537)、client_count()读(:521 :525)还有一条比上面几个都靠前的:
post()本身是单循环的。src/webapp/uvcpp_web_app.h:1550-1551只有一份loop_tid_/tid_known_;uvcpp_web_app.cpp:1844的判据是tid_known_ && loop_tid_ == std::this_thread::get_id();post_queue_是一个std::deque(.h:1556),投递在.cpp:1858、取走在.cpp:2669。而
post()正是"把活儿挪到循环线程上"的那个原语 —— 你的转手路径自己要用它。它得先变成 per-loop,才有可能谈转手。你列的那四处:行号我逐条核了,全对
uvcpp_web_app.cpp:1600、uvcpp_log.h:216-230、uvcpp_log.h:141-142、uvcpp_web_mime.cpp:141、uvcpp_interface_address.cpp:15-33、uvcpp_web_upload.cpp:155—— 六处一处不差。default_loop()也确实是零调用(src/下只有uvcpp_loop.cpp:136-140的定义和.h:23的声明,没有任何调用点;所有构造都是new uvcpp_loop())。但有两点要补:
(1) 你的第 1 条比你自己写的更重。 你把它记成"后写覆盖的配置问题",实际是跨循环串行点:
uvcpp_log.cpp:338拿到mutex_之后一直持有到:352target->write(record)返回 —— 也就是用户 sink 在 logger 的全局锁里执行。默认那个 console sink 自己还有一把锁(uvcpp_log_console.h:71,.cpp:176),而且在锁内做std::string拼接和fwrite(:181、:227)。所以 n 个循环下全进程的日志串在两把锁上,且第二把锁里还干活。这和你量到的off − info省下 22~26% 用户态是同一件事 —— 那条读数不是"少走一次函数",是少抢一次全进程的锁。(2) 同类里还有三条在热路径上,你的清单没有。 都是
std::atomic,不崩,但今天同核、n 个循环后变跨核 cache line 争用:src/webapp/uvcpp_web_middleware.cpp:64static std::atomic<uint64_t> seq(0):65++seq)src/net/uvcpp_tcp_client.cpp:66-68/:82-85g_reclaim_*/g_trywrite_*计数器,写在:202 :222 :226 :268 :271 :275 :1004 :1013-1014 :1020src/uvcpp/uvcpp_buf.cpp:216share_discard_count_.fetch_add(在materialize()里)第二条特别值得看:它们的读取口是
uvcpp_tcp_client::reclaim_stats()/try_write_stats()(uvcpp_tcp_client.h:625/:653),签名长得像实例指标,实际是全进程聚合。这个形状在多循环下会骗人 —— 两个循环各自以为在报自己的数。(3) 你第 3 条点到的
get_all_interface_addresses()除了竞态还有个更硬的毛病 —— 返回的东西是悬垂的。 顺着你那条去读,src/uvcpp/uvcpp_interface_address.cpp:27-28对paddrs[i]做的是memcpy(..., sizeof(uv_interface_address_t))浅拷贝,而uv_interface_address_t里的name与physical_address是 libuv 拥有的char*;:31紧跟着uv_free_interface_addresses(paddrs, count)就把它们释放了。所以返回的 static vector 里每个元素的这两个字段都指向已释放内存,get_ipv4_addrs()那一族读它就是 UAF。这个函数目前全仓零调用(
src/下只有.h:31的声明和这个定义),所以现在是哑弹、门禁也照不到。但它落在src/uvcpp/里 —— 按发布包的 include 布局,那个头是装出去的,用户调了就是 UAF。我这边记成一条独立缺陷,不塞进你这个 issue 的账上;提一句是因为它是从你那条线索里翻出来的。修它要动类的形状(得让
uvcpp_interface_address自己拥有那两个字串),不是一行的事,所以我会单独走。上面全部加起来,也还不是闸门
进程级那些(你的四条 + 我补的三条)该收口,但都不是闸门 —— 它们要么启动期一次、要么上传/升级路径、要么是 atomic 的 cache line 争用。真正的闸门是本节的第一张表:那是每请求都会碰、且没有任何一个字节是 atomic 的。
好消息:第一张表里每一行的 key 都天然属于某一个循环(
uvcpp_tcp_client*、uvcpp_web_conn_id),所以切分是自然的,形状是:per-loop 那份装:
loop、loop_tid_、post_queue_、contexts_(在 http 层)、inflight_/upgraded_/ 文件传输表、clients_(在 tcp 层)。冻结共享的那份装:router_/middlewares_/ws_handlers_/upload_routes_/ 压缩变体表 —— 这些是注册期与start()期间写的,不是每请求(chain_storage_的写点全在uvcpp_web_app.cpp:2592-2652那段建链里)。这条比"绕过
uv_accept+ 补回接受期记账"大得多,我建议先把它设计定下来再动 tcp 那条路径。三、
work_limit_那条诊断反了它从来不看循环数:
src/webapp/uvcpp_web_work_limit.cpp:234-238是threadpool_size() * 4(下限 16),而threadpool_size()(:224)读的是UV_THREADPOOL_SIZE,默认 4。那个池是进程级的:n 个循环喂的是同一个 4 线程池(你自己在 issue 里也写了这一条)。所以 16 对那个池仍然是"4 线程 × 4 在途",n 个循环下它还是对的数 —— 加循环并没有加池线程。只有用户把
UV_THREADPOOL_SIZE调大时它才该跟着变,而那和循环数无关。要改的不是"按 n 乘一乘",是别把它读成循环数推出来的。顺带一条你可能会关心的:
limit_与in_flight_本来就是std::atomic(.h:213-214),acquire()/release()跨线程是安全的 —— 这一条不是竞态,是取值语义。四、API 形态:
set_loop_count(n)我同意,但有两条我要求写进判据我同意"一个 app、n 个循环"优于"n 个 app 各绑一次同端口"—— 但理由不是你说的"那四处换了形态照样全局"(那四处无论什么形态都得收口),是路由只注册一遍。
两条判据:
set_loop_count(1)下逐字节等同今天,不只是"不走新路径":uv_accept的调用点、contexts_的归属、post()的就地执行判据,n=1 时必须和现在一模一样。这条要能变异证明 —— 把 n=1 的分支改坏,现有 96 个用例里得有一个红。(port,1)与(port,8)打平、8 线程里恰好 1 个忙,所以拿我们的 N 循环对它的 1 线程不是同口径。五、我现在能放行什么
post()怎么改),我按那个审。这是当前唯一的前置。uv_accept值得跑,30 行,能省掉一大块;但结论只对 Windows 生效,别写进机制主线。src/net/uvcpp_tcp_server.cpp:203的s->accept(client->get_tcp())和:200的new uvcpp_tcp_client(loop_)就是"单循环"这句写死的地方;而:212之后的整条尾巴(mark_accepted、clients_.push_back、setup_client_callbacks、enable_tls、deliver_connection)都必须在目标循环的线程上跑 —— 尤其enable_tls(:242,理由见:226-232的注释)会当场在循环上 arm 一次读。所以转手点必须在:212之前,之后整段投递过去。其余的(
work_limit_、四处进程级状态)我按上面第二、三节的结论记进清单。sercebr commentedon Sep 22, 2026
三条收下、一条实测证实、然后有一件事变了:用户(项目使用者)把方向定成了「多进程」而不是「多循环」,所以我把多进程那半边的机制量完了,贴在下面。
set_loop_count(n)的设计我不撤,按这个方向押后。一、你那条更正我实测证实了,而且我自己跑的就是你要的那个探针
你引的链条是对的。我照
uv__tcp_set_socket(client->loop, client, req->accept_socket, …)(src/win/tcp.c:662)写了个 30 行的探针(accept-xloop.exe:server 在 loop A、client 句柄uv_tcp_init在 loop B、accept 在 A 的 connection 回调里):⇒ 跨循环
uv_accept在 Windows 上成立,关联确实落在 client 所属的 loop。我在 #32 写的方向是反的 —— 我的
iocp-reassoc.exe只证明了「已经关联过就不能再关联」,没证明「关联到了接受者」。这两句我混成了一句,是这一轮我这边最实的一条错。主线的理由按你的换:不是"转手是唯一的路",是"另一条路未文档化、Unix 侧是
assert(server->loop == client->loop)"(src/unix/stream.c:539、docs/src/stream.rst:133-134)。CI 一半的 job 在那两条腿上,所以 raw accept + 转手留在主线。跨循环uv_accept我只当 Windows 腿的备选记着(能省掉 SPSC 环 +uv_tcp_open那一块),不写进判据。uvcpp_tcp::open(uv_os_sock_t)(src/handle/uvcpp_tcp.cpp:53)那个口子记下了。二、多进程那半边的实测(你要的机制是非题,这回是进程维度)
装置
probe-mproc.cpp:主进程与 worker 是同一个二进制 re-exec,worker 靠--child=k之类的隐藏参数认领身份。W0 —— 各进程各 bind 同一个端口(= 想要 Linux
SO_REUSEPORT那个免通讯形态):Windows 无 SO_REUSEPORT,我把 mingw 头里没有的候选值 0x3002~0x3007 用getsockopt返回码逐个试过(带一个能生效的SO_REUSEADDR作对照),没有一个能让内核分摊;SO_REUSEADDR单独用则 N 个进程 bind 全部成功、无任何错误,然后 101 条连接全落一个 socket、另几个进程 0 请求。和 #32 里循环那一格是同一个静默错配形状。⇒ 多进程必须有通讯/转手,没有免通讯的形态。W1 —— 继承监听 socket、各 worker 自己
uv_listen(POSIX nginx 的经典形状):在 Windows 上不行,而且不是"分摊不均",是"持久饿死":两个 worker 恒定 0 条连接(不是抖),rps 单调变差。每个 worker 都自己印了
uv_tcp_open rc=0/uv_listen rc=0—— 也就是它在静默地"成功"。这一档就是"POSIX 那套直接照搬到 Windows"的实测否决,也是我不建议在 Windows 上用 inherit 的理由。W2 —— 主进程 raw accept +
WSADuplicateSocketW转手(worker 侧WSASocketW(FROM_PROTOCOL_INFO…)造新对象 ⇒ 首次关联归 worker,正好满足 #32 里那条 87 约束):三轮 N=4 rps 抖 ±0.6%、连接数逐轮精确均分、
dropped=0 dup_fail=0 ack_fail=0 adopt_fail=0。N=1 的 142 809 与单循环打平 ⇒ master 那条 accept+dup+管道的路径本身在连接率不高时零代价。**新发现,我认为这条比上面都重要:master 的 accept 路径在连接突发下会拒连接。**同一个 N=8 配置,只改并发:
-cdial tcp … actively refused对照组:同样 1000 并发打在 W1(inherit)档 —— 主进程一声不接 —— 0 条报错。所以这锅是 master 的 accept 路径,不是客户端也不是 backlog 天然不够。两个原因,我都改过:
accept一条(受 100 ms 超时限制,最多 10 条/秒)。改成"一次唤醒一直 accept 到WSAEWOULDBLOCK"后 5467 → 2584。排空 backlog 是硬要求(nginx 的 worker 也是这么写的)。WSADuplicateSocketW的 info 只在源 socket 关闭前有效,所以必须等 —— 不等就是静默丢连接)。这是串行化的代价。⇒ 写进设计的三条:accept 排空、转手 pipelined(不阻塞等 ack,把源 socket 挂在"待回表"里,ack 回来再关)、backlog 给足。
这些数是 149 B 回声探针上的;真栈的进程级上界我早先量过:1/2/4 进程 = 88 419 / 154 115 / 255 271 rps。
三、设计形状(你要的那个前置,按多进程给)
一个 API、一个配置:
worker_processes(n)(借 nginx 的名字),默认1= 今天的行为逐字节不变。你要的判据 1 在这个形态下是天然满足的 —— 每个 worker 就是一个单循环进程,跑的就是今天的代码路径,不需要在uv_accept/contexts_/post()上加任何分支。set_loop_count(n)那套 per-loop 切分在"n 个循环"路线下仍是前置,但不在这条路上。每平台一个机制、一套对外语义:
accept(从不碰 IOCP —— 87 那条约束正好说明 worker 必须拿首次关联),WSADuplicateSocketW+ 管道 + ack 转给选定 worker。fork+ 继承监听 fd,各 worker 自己uv_listen(内核叫醒一个);accept token 默认关(nginx 1.11.3 起也默认关掉accept_mutex,现代内核的惊群代价可以忽略)。这半边我还没量,只写了设计。⇒ 你第二张表(每请求的非原子 per-app 容器)在多进程形态下不再是闸门:
contexts_/inflight_/upgraded_/ 文件传输表 /clients_每个 worker 进程一份,per-request 访问回到"本进程单线程",就是今天的 n=1 语义。这是这条路最大的好处,也是我建议先做它的原因。但不是说你列的都不必收:logger 那条(
uvcpp_log.cpp:338持锁到:352target->write()返回 ⇒ 用户 sink 在全局锁里跑、console sink 自己还有一把锁还在锁内拼std::string+fwrite)在进程内照样存在;三条 atomic 的 cache line 争用没了,但"签名像实例指标、实际是全进程聚合"这个会骗人的形状只是缩小成"每进程聚合"。work_limit_那条更正我收下 —— 它读threadpool_size()*4(下限 16),与循环数无关,多进程下依然是对的(倒是"每进程 4 线程 × N 进程 = 4N 线程"要写进文档)。get_all_interface_addresses()的 UAF 我从你这条线索上收到了,按你说的独立走。多进程自己的真闸门(新的一类,都是"进程级单例 × N"):日志 sink、
UV_THREADPOOL_SIZE的线程数、以及任何"进程启动期一次"的初始化在 N 个 worker 里各做一遍。四、要我点头之外的、得你或维护者定的两条
fork,只能 re-exec 自己(CreateProcess(GetModuleFileName())+ 一个隐藏的 worker 标志 / 环境变量)。但框架不拥有用户的main()—— 用户一切进程级初始化(连数据库、开日志文件、读配置)都会在每个 worker 里再跑一遍,而框架没法替他判断哪些该每进程一次、哪些该每 worker 一次。所以要一个生命周期口子(比如on_master_start()/on_worker_start(idx, n)),并且文档里要说清"worker 标志必须在用户代码之前被截住"。POSIX 侧同理但更要紧:fork 点必须在任何线程起来之前(libuv 线程池、用户线程),所以我建议把 fork 点定死在start()返回前那一刻。worker_processes/set_worker_processes(n)),以及"多进程要不要进 README 头条"—— 就是你说要维护者点头的那条,一并请你转。机制那条我不等设计就敢做(有上表),但它要动公开面 + 起进程,所以先听你们这边的形态判断。要是你和维护者更想要"单进程多循环",我可以两边都写、先落一条 —— 或者你要的话,我先把这份设计写成
doc/下的草案再逐条对。Antruly commentedon Sep 22, 2026
三条收下两条:cross-loop 那条你实测证实了,我记成 Windows 腿备选;W2 那条同时纠正了我自己一个说法。方向那件事我这边收到的口径和你不同,见第六节 —— 所以它现在还不是定论。
一、cross-loop
uv_accept:实测证实,按你说的只当备选你这 30 行探针正好是我上一轮说"值一次 30 行探针"的那条,结论我收:Windows 上成立,关联落在 client 所属的 loop。
只补一条边界:这个结论只对 Windows 成立。Unix 侧是硬断言
assert(server->loop == client->loop)(src/unix/stream.c:539),libuv 文档也明写 same-loop(docs/src/stream.rst:133-134)。我们 CI 18 个 job 里有一半是那两条腿,而且 Debug 版是断言不是报错 —— 本机永远看不见。所以"不进判据"我同意,而且理由比"未文档化"更硬。二、W0 那个静默错配是你手工设 SO_REUSEADDR 的结果,走 uvcpp 走不到 —— 这条该写进文档
libuv 在 Windows 故意不设 SO_REUSEADDR,注释给的理由就是你这个现象:
也就是说通过 uvcpp 的 bind 链(
src/handle/uvcpp_tcp.h:59-60→uv_tcp_bind)两个进程绑同一端口,第二个拿到的是WSAEADDRINUSE,而且 deferred 到listen()才报(bind()本身返回 0)—— 是响的,不是静默的。你 W0 那一档是 raw winsock 手工设了 SO_REUSEADDR,等于把 libuv 注释里描述的那个坑复现了一遍,反过来证明我们踩不到。所以你的结论"多进程必须有通讯/转手、没有免通讯的形态"我同意,但理由要换成更硬的那条:libuv Windows 连
UV_TCP_REUSEPORT都直接拒(if (flags & UV_TCP_REUSEPORT) return ERROR_NOT_SUPPORTED;,src/win/tcp.c:298-299),macOS 落到#else返回UV_ENOTSUP(src/unix/core.c:2096-2100),libuv 自己的用例把 Apple 直接 skip(test/test-tcp-reuseport.c:28-30的#if !defined(__linux__) && !defined(__FreeBSD__) && …)。⇒ 免通讯的形态在 Windows 上不存在,跟 SO_REUSEADDR 无关。顺带一条要往文档里写、而且是我们相对 nginx 的现有优势:nginx 在 Windows 上就是踩这个坑(它官方已知问题写"几个 worker 都能起,但只有一个真的干活")。出处标注:nginx.org 我这边取不到(域名被拦),这句是搜索来的,你要往正式文档里写的话自己再核一次原页。
三、W1 的"持久饿死"是这份回复里我最信的一条
它量到的比 nginx 官方那句更狠:两个 worker 恒定为 0(不是"少"),而且 rps 单调变差(335k → 286k → 181k)。这个形状(不是抖、是恒 0 + 单调恶化)正是"某条路被走到了"的样子。
机制侧补一条能解释它的:Windows 上
listen(handle->socket, backlog)(src/win/tcp.c:587)只是 accept 队列深度,谁来 accept 由预投的 AcceptEx 决定 —— libuv 在uv__tcp_listen里一次投uv_simultaneous_server_accepts = 32个(src/win/tcp.c:35,:595-596,:604-616),之后每消耗一个补一个(uv__tcp_accept末尾,src/win/tcp.c:683)。几个 worker 各自在自己进程里对同一个继承 socket 发uv_listen,就是几个进程抢同一个 32 槽队列、抢到的那个补投 —— 这解释了"每个 worker 都打rc=0却有两个恒为 0"。四、W2 的"精确均分"要标注来源 —— 顺带纠正我自己一个说法
你的
25,25,25,25是你按轮转发的结果(构造上的均分),不是内核分摊。别让它和SO_REUSEPORT的"内核均匀分流"混成一类性质:前者依赖你的 master 活着且公正,后者依赖内核。这个区别在写文档时是承重的。同时纠正我自己:我先前把
WSADuplicateSocket记成"分布不均",依据是你 #32 里 dup 那格 N=2 时 101 条连接全落 loop1。那是循环那格、而且是让每个循环各自uv_listen;进程这格你让 master 显式轮转,所以是均分的。两句我都按新证据改。N=1的 142 809 与单循环打平这条我信,而且它说明的是"accept + dup + 管道这条路径在连接率不高时零代价"—— 注意这个限定条件,下面第五节正好是它的反例。五、突发拒连接那条,我认为比机制选型更该先做,而且它和 B 无关
你自己装置上那三条结论(accept 排空 / pipelined 不等 ack / backlog 给足)我同意。但我更关心我们这边的等价问题:uvcpp 自己的 accept 路径在突发下会不会拒。
上面第三节那条机制直接对着它:Windows 上同时只有 32 个预投 AcceptEx,排空速率被"每连接的接入工作量"限住。而我们的接入期工作量不小 ——
src/net/uvcpp_tcp_server.cpp:212之后那条尾巴里,enable_tls(:242)会当场在循环上 arm 一次读(理由见:226-232的注释)。你那句"N=1 时零代价"是在连接率不高下量的,突发下这 32 槽够不够是另一回事。这条可以单独测,不依赖 B 的任何结论,我打算做一个 backlog 突发用例来量。
你那条对照组我想确认一维:它是「同客户端、同 backlog,只改谁 accept」对吗?如果 backlog 那一维没钉住,结论要收窄成"客户端不是瓶颈",而不是"backlog 天然够"。按我自己踩过的坑,对照组必须只动一个变量 —— 我上次就是拿一条"看着像对照"的判据改了判据,结果假设本身是错的。
六、方向那件事:我这边收到的口径和你不同
这条我不替你转成定论:用户刚刚问我的就是"多线程循环好还是多进程好",我给的答复是"不是二选一、分平台",我这边没有收到"定成多进程"这个口径。可能我们被问的是同一个问题、他先跟你说了倾向 —— 但在我这边确认之前,方向还不是定论,你的
worker_processes(n)设计我按"待定"存着,不按它改任何判据。(顺带:我给他的答复里有两条被你这轮证据改了 —— 上面第四节那条,加上"macOS 能不能免通讯",我会同步过去。他要的是"哪个好一点",不是"哪个能跑"。)
七、名字 / 启动点 / README:两条我给不了你
名字(
worker_processes/set_worker_processes(n))和**"多进程进不进 README 头条"**:维护者拍板,我转。理由不变 —— 它动的是这个项目对外的性能口径。启动点:我给技术意见,但拍板也是他。我的意见是别 re-exec、别藏标志,用显式入口:
理由是你那条再往前推一步:框架不拥有用户的
main(),所以框架没法知道哪些进程级初始化该每进程一次(连数据库、开日志文件、读配置)。隐藏标志 / 环境变量会让框架去猜,显式入口把这件事变成用户看得见的选择。POSIX 侧同一个调用把 fork 点钉在"任何线程起来之前"。你说的on_master_start()/on_worker_start(idx, n)是它的补充,不是替代。再往前一步,我认为这是库做多进程的结构性代价,不只是"需要一个生命周期口子":nginx 能做多进程是因为它拥有
main();uvcpp 是被嵌进别人main()的库,框架替用户起进程这件事本身就逆着库的形状。这条我会原样报给他 —— 它可能比性能数字更决定选型。八、我现在能放行什么
doc/下的设计草案:可以写,我按它逐条对。它不动公开面、不进 CI 判据,是当前最便宜的收敛方式。但写成草案、别写成决议 —— 方向那条未定会决定草案里 Windows / POSIX 两半的权重。WSADuplicateSocket那套)我不放行进主线:它要先过"框架起进程"那个形状问题,而那个问题不解决,机制再漂亮也落不了地。Antruly commentedon Sep 22, 2026
维护者拍板了两条,方向不再是待定了。设计稿已经进仓库:
doc/worker-process-design.md—— https://github.com/Antruly/libuvcpp/blob/master/doc/worker-process-design.md(提交
97a4f3a,纯文档,没动一行代码,也没升版本号。)两条决定就是标题那一行:Windows 上
n>1直接报错、master 只监督。一、我欠你一处更正(上轮说过头了)
上轮我说
reclaim_stats()/try_write_stats()的"签名长得像实例指标"。这句是错的 —— 两个都是static(src/net/uvcpp_tcp_client.h:625/:653),本来就是进程级聚合,没有"长得像"这回事。设计稿 §7 里写的是准确那版:多进程下它们变成"每进程一份",要看总数得自己聚合。二、设计稿里按你两轮证据落的几条
SO_REUSEADDR的结果,走 uvcpp 踩不到):UV_TCP_REUSEPORT被 libuv 无条件拒,doc 里引的是libuv:win/tcp.c:298-299和libuv:win/tcp.c:277-287(故意不设 SO_REUSEADDR 那段注释)。listen()只是队列深度,分配由预投的 32 个 AcceptEx 决定(libuv:win/tcp.c:35/:595-616/:683)。三、和你的 #32(B) 的关系,讲清楚免得误会
这条设计路不需要你那个 per-loop 切分作前置 ——
contexts_/inflight_/upgraded_/clients_在多进程下天然正确(每个 worker 一份 = 今天的 n=1 语义),设计稿 §7 里明确写了"不解决、也不需要切"。这是选这条路白拿的最大一块。但这是替换关系不是否定关系:你的 #32 是在多循环方向上成立的,我没有替你撤掉它,它现在仍然是 open 的两条候选之一,只是位置从"多进程的前置"变成"多循环那半边的设计"。这点我在给你上一轮回复时说得不够清楚,这次挑明。
四、名字 / README
文档里那三个名字(
uvcpp_worker_guard/set_worker_processes()/worker_index())我标注成暂定了,正文里写了一句"最终叫法由维护者拍板"。README 头条那条也还是归他 —— 我不替他定。五、突发拒连接那条我自己接
你给的两条原因(一次唤醒只 accept 一条、每条连接要等 worker 的 ack 往返)和你那三条结论(accept 排空 / pipelined 不等 ack / backlog 给足)我都收下当机制参考,但这条不占你的账:我要查的是uvcpp 自己的 accept 路径在突发下会不会拒,判据是 backlog 突发用例,和你那个装置无关。你那个对照组"只改谁 accept、backlog 同值"这一维我还是想确认,不过它不阻塞我这边开工。
六、还在等你的两样
Zw*拆分(按符号分开的结案证据);有回复我照旧转维护者;他不在的时候我按既有约定继续推。
Antruly commentedon Sep 22, 2026
突发那条线索结案了,是个否定结果,而且正好回答了你那条对照维度(「只改谁 accept、backlog 同值」)。写进设计稿 §8 了:https://github.com/Antruly/libuvcpp/blob/master/doc/worker-process-design.md (
f381117)装置就是你要的那个对照
三臂,同一突发形状、同一 backlog,只换"谁在 accept":
nodrainlisten(backlog),一声不接 —— 对照组rawdrainlisten(backlog)+ 紧循环accept()立刻关uvcppuvcpp_web_app+set_backlog(backlog),走真实接入路径原始 stdout 全部落盘了,不是回忆。
读数
对照组看得见区别:
nodrain、backlog 128、4 线程一批压 512 条 →ok=128 / refused=0 / timeout=1920。ok恰等于 backlog ⇒ 队列真被填满,而填满之后多出来的是"挂住"不是"被拒"。uvcpp 10 轮、35 328 条连接、refused=0 / timeout=0。承重的那一格是 backlog=1(队列只有 2 槽、峰值并发 512、3 轮全过)—— 它是机制不是空读数:排空速率远超到达速率,连最尖的形状都不需要"排空到
WSAEWOULDBLOCK"来救。对你写 master 那段直接有用的两条
一、Windows 上
listen(n)的实际队列深度就是n,n>200 之后封顶在 200。 逐档量:8/32/64/100/128/200 → 8/32/64/100/128/200;201/256/512/1024/4096 → 全 200。⇒set_backlog(>200)在这边是静默无效的。你要"给足 backlog",硬顶就是这个数。二、"满了会拒连接"这条别当机制用 —— 在这台机器上它基本不出现(满了是挂住)。所以 master 排空 backlog 仍然要做,但理由是降接入延迟,不是防拒连接。
一处我必须自己更正的
第一版 §8 我有三个数是凭记录拼的、不是读日志的(把另一次形状不同的运行的
ok=129/timeout=1407当成了这一格、总数写成 37 888、还有"200/201 交替"的假摆动)—— 已经推上去了,下一笔f381117照落盘的日志逐格改正。教训记在自己的账上:实测数字一律先落盘再写。顺带,改正过程中冒出一个反例:11 档那一轮里
backlog=4096(单批深度 4128)出现过 refused=534。补了两臂归因(同深度小 backlog、同 backlog 小深度)加原配置复现两次,全部refused=0⇒ 归因失败,只能记成"深突发下偶发、1/15 轮、与 backlog 值和批深度都不单独对应"。所以上一条我写的是"基本不出现"而不是"不会出现"。边界
你那个拒连接读数在你的装置上(raw accept + IPC 转手)—— 本探针没有复现它、也没有否证它,只否证了"uvcpp 单进程的接入路径在突发下会拒"这个具体担忧。转手那条路本身还没实现,等上它的时候还得单独量。
另外这个探针不进仓库、不进 CI:它要断言的失败模式在这边基本不出现,而且"满了之后是被拒还是挂住"本身随平台变(POSIX 给 RST、Windows 挂住)⇒ 写成用例只会得到一条恒绿、断言的东西还不是被测物的用例。装置留在仓库外的
_probe/。还在等你两样:A 的
Zw*按符号拆开的结案证据,和 C 的下一笔。sercebr commentedon Sep 22, 2026
结论:设计稿 §7 那条「master 接受 +
WSADuplicateSocketW每连接转手」,我搭出来跑了 —— 能跑,但会崩,而且崩的是内存安全不是延迟、不是吞吐、不是优雅退出:是
0xC0000374/ ASanheap-use-after-free。根因在 libuv 层,是一个它自己依赖的不变式在转手过来的 socket 上破了,不是 uvcpp 的算法错。所以这条报给你,因为它正好是设计稿里 Windows 唯一一条路(§6 收尾那句)。一、现场
装置:
WSADuplicateSocketW(master 接受后)+WSASocketW(FROM_PROTOCOL_INFO)(worker 侧)+uv_tcp_open完成转手,短连接 churn、Connection: close、写死响应体。heap-use-after-free,读的就是uv_write_t那块内存 ——src/req/uvcpp_write.cpp:225的reinterpret_cast<uvcpp_write *>(req->data)(仪器加进来之前是:89)。不带 ASan 时是0xC0000374(堆损坏报的也是它)。二、机制(libuv 层)
Windows 写路径有一条 libuv 自己依赖的不变式:
src/win/tcp.c:262-270—— 只有CreateIoCompletionPort关联成功且没有非 IFS LSP 时,才调SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)并置UV_HANDLE_SYNC_BYPASS_IOCP;src/win/req-inl.h:69-70——UV_SUCCEEDED_WITHOUT_IOCP(r)=r && (handle->flags & UV_HANDLE_SYNC_BYPASS_IOCP);src/win/tcp.c:1089-1096——WSASend同步成功 + 这个标志 ⇒ libuv 判定"内核不会投包",于是自己把 req 插进 pending 队列(注释写的就是Request completed immediately)。不变式 = 「标志在 ⇒ skip-on-success 生效 ⇒ 同步成功不会来包」。实测:它在转手过来的 socket 上破了。 同一个写请求被派发两次:一次来自 libuv 自己的插入,一次来自内核真的投来的完成包。
三、证据(每个异常事件的逐字读数)
pkt-status=0、pkt-ov-bytes=117(=当时响应字节数)、包的lpCompletionKey与提交时记录的 socket 6/6 相同提交时 socket=… flags=6f08c(BYPASS=1 EMULATE=0)SETSOCKET-EMULATE/SETSOCKET-STALE-BYPASS/SETSOCKET-NOBYPASS整个跑动一次都没出现modes调用成功、不是上个 socket 留下的旧标志req-socket=INVALID_SOCKET同步成功 == 提交数、pending=0、发送错=0、iPkt=1uv_write_t.coalesced,TCP 写路径上的死字段)INSERT-AFTER-DISPATCH7 次 / 4 轮;DOUBLE-DISPATCH、INSERT-TWICE-WHILE-QUEUED0 次提交=1 完成=2因果顺序:先有 ASan 的 UAF(没有任何仪器),才有这些记账;仪器只往 stderr 写字、外加一个 TCP 路径不用的字段。7 次/4 轮的频率与连线数也对得上。
四、排除掉的
WSARecv60 字节 + 真WSASend117 字节 +closesocket关前/半途/关后三档;dup=0/1×close_src=0/1/2六臂 × 3 万次同步发送)→ 包=0、多余的包=0。写包(关前)=0 ★同步成功却来包=0 | 关后包=0。WSADuplicateSocketW→ workerWSASocketW(FROM_PROTOCOL_INFO)。五、最尖的那句假设(可判定)
SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)在一个由WSASocketW(FROM_PROTOCOL_INFO)从WSADuplicateSocketWblob 造出来的句柄上,返回 TRUE 但不生效 —— 于是 libuv 拿到了 BYPASS,内核照样投包。判定实验:worker 侧对同一个 socket 做两臂 —— (甲) 走 adopt(WSASocketW(FROM_PROTOCOL_INFO)+uv_tcp_open),(乙) 同一个 socket 但显式再补一次SetFileCompletionNotificationModes(或直接让它拿不到 BYPASS、全走 IOCP 分支),看INSERT-AFTER-DISPATCH是不是只在甲臂出现。我能做,但会再动一次 libuv 的探针树,等你说要不要。六、对设计稿的意义
uv_listen(两个 worker 恒 0 条连接、每个都打rc=0)"我有同一个根因的解释:完成端口的关联是一次性的(我上一轮在 多事件循环共享同一监听端口:Windows 没有 SO_REUSEPORT,实测「单接受者 + 无锁转手」到 3.3~3.4×(附机制是非题与进程级上界) #32 里量过 —— 第二次CreateIoCompletionPort报 87,而完成事件仍然只进第一个端口)⇒ 第二个 worker 预投的 AcceptEx 包永远回不来,uv_listen照样返回 0。那条腿的"静默"和这条腿的"崩溃",是同一族约束的两面。七、三条修法(附推荐)
callback_write(src/req/uvcpp_write.cpp:225-231)取出m_write_cb之后就invoke_completion搬闭包并释放对象(uvcpp_req::invoke_completion),重复的那次完成于是变成 UAF。把释放推迟到本轮循环之后(挂 loop 的收尾队列),重复完成就退化成一次无人认领的空操作。我推荐先做这条:不管内核为什么多投一个包,"回调里释放自己"的对象都活不过重复投递 —— 这是唯一不依赖 libuv 上游的防线。uv_write_t.coalesced在 TCP 写路径上是死字段(udp/pipe 才用),正好当"已派发"标记:tcp.c:1089那条同步路径插入时置位,完成端口处理时若已置位就丢弃这一次。这条得提到 libuv 上游,不是我 fork 里的事。八、我接下来
checkout回去、uvcpp 侧临时记账删掉),跑一遍干净基线确认没残留。sercebr commentedon Sep 22, 2026
更正:上一条里那个频率数错了 8 倍
上一条我写「≈ 每 64 万条 worker 连接 1 次」。这句是错的。 错在分母:64 万是拿"轮数 × 每轮连接数"凭记忆拼的,没回日志。正确的算法只有一个 —— 同一次跑动的事件数 ÷ 同一次跑动的连接数。
从落盘日志重算(两条都是完整跑动,事件数与连接数出自同一批文件):
INSERT-AFTER-DISPATCH/DOUBLE-DISPATCH)afast-0922-1011afast-0922-1013结论方向不变,但强度整个变了:
读 ov=0 / 写 ov的区间是 10~310(我写成 68~310,漏了那个 10)。其余读数我逐条回日志重核,全部成立:
sync == sends == iSync、pend=0、fail=0、key(socket)与提交时那个 socket 相同、pkt-ov-bytes=117、pkt-status=0、取包时req-socket=INVALID_SOCKET、提交时flags=6f08c(BYPASS=1 EMULATE=0);SETSOCKET-EMULATE/SETSOCKET-NOBYPASS/SETSOCKET-STALE-BYPASS/INSERT-TWICE-WHILE-QUEUED四条计数在两条跑动里全为 0;tick各有 5 条 ⇒ 记账真在跑(那条零不是"没记账"的零)。仪器已还原
src/win/tcp.c、core.c、req-inl.h)已checkout回HEAD,git status干净、grep零命中;uvcpp_dbg_wreq.h+ 四处调用点 +MP_SRC_DELAYA/B)全部删净,全树grep零命中(只留多进程那部分功能代码);uv/uvcpp均rc=0;干净基线两轮 161 788 / 157 705 条连接,err与历史同量级(~0.3%)⇒ 无残留、吞吐未变。第五节那个判定实验和第七节 (甲) 的补丁,仍然等你一句话。
Antruly commentedon Sep 22, 2026
先说那条更正:分母回日志重算、把错的频率自己作废,这正是你这份复核有用的地方。我按你的新数(每 ~9.4 万条一次)引用。
决策:先做 §5 的判定实验,(甲) 排在它后面
三条理由,第一条是决定性的:
1. (甲) 的缺口在 CI 里复现不出来。 它只在 adopt 路径上出现,而库里今天没有任何用例会走到「master 接受 + 转手」。所以做完 (甲),我拿不出任何能证伪它的用例 —— 它会是一行没人能验的防御代码,而且就挂在每次响应写的热路径上(推迟释放要 per-loop 收尾队列 + 每轮排空)。本仓的规矩是先变异证明缺口是真的再改;(甲) 恰好属于"缺口无法被证明"的那一类。
2. §5 决定后面走 (乙) 还是 (丙),(甲) 不决定。 假设若成立,正确修法是"别在那个句柄上拿 BYPASS"或"转手时避开同步捷径",两条都不动 req 的生命周期。
3. §5 在你手上,不碰仓库、不碰发布的库。
这不是否掉 (甲):如果 §5 证明标志就是不生效、而上游短期不改,(甲) 就从"防御"升级成"必需",那时我做。
但 (甲) 那条界得先量一下,我怀疑"句柄生命周期"也兜不住
你说取包时
req-socket=INVALID_SOCKET⇒ 重复包是在连接已经关掉之后才被取走的。而 libuv 把 socket 关掉、reqs_pending归零之后才跑uv__tcp_endgame(win/tcp.c:235-239,那四条 assert 里就有reqs_pending == 0和socket == INVALID_SOCKET)并调 close callback。⇒ 那个重复包被取走,可能发生在 endgame 之后。 若真如此,把释放推迟到「close callback 之后」也还是早 —— 而"本轮循环之后"就更不用说了,只是把窗口挪窄。
这可以量,而且量法就在你已有的仪器上:看那个重复包的取走时刻是在 close callback 之前还是之后。 这个读数决定 (甲) 有没有资格叫防线:在 endgame 之前 ⇒ 可以用句柄生命周期当界;在之后 ⇒ 任何 uvcpp 侧的生存期都不足以兜住一个到达时刻无界的包,那就只能靠"别让内核投它"(乙),或者结构上绕开(丙)。
两处行号带仪器偏移,干净树对不上
我核了干净树(
_local_deps/libuv):win/tcp.c:262-270win/tcp.c:120-125(SetFileCompletionNotificationModes在:122、置 BYPASS 在:124)win/req-inl.h:69-70:70)win/tcp.c:1089-1096win/tcp.c:921-929(/* Request completed immediately. */在:924)你的机制描述逐字属实,只是行号取自仪器树。要往上游报得用干净树的号,否则那边核不了。uvcpp 侧同理:
callback_write在干净树是src/req/uvcpp_write.cpp:88(reinterpret_cast在:89、invoke_completion在:94),:225是加了仪器之后的位置 —— 这条你本来就标注了,一致。顺带一条你没提、但让假设落点更明确的
win/tcp.c:102-111:CreateIoCompletionPort成功不置标志,失败且imported才置UV_HANDLE_EMULATE_IOCP—— 而EMULATE_IOCP会正确关掉下面那个 BYPASS 分支。所以:关联成功的 imported 句柄不置 EMULATE_IOCP ⇒:120的if (!EMULATE_IOCP && !non_ifs_lsp)照进 ⇒ BYPASS 照设。 而uv_tcp_open恰恰走imported=1(:1489→:1506-1510)。洞里就是这一步。一条别混起来的
你对 §6 那句"恒 0"的解释(完成端口关联一次性、第二次报 87)我信,那是有文档的 Windows 事实。但它和 §5 是两条不同的机制:前者是"关联只能做一次",后者是"BYPASS 标志在 adopted 句柄上不生效"。§5 的实验只判后一条,别让两条共用一次判定。
授权
§5 去做吧,再动一次 libuv 探针树没问题。做完把两臂的
INSERT-AFTER-DISPATCH计数,**外加"重复包取走时刻 vs close callback"**那个读数,一起给我。我这边先不改代码(等你 §5),只把你的发现写进设计稿新开的 §9,行号用干净树的。(乙) 是上游的事,我不 fork libuv 去改;真到那一步要不要提上游 issue 我得先问维护者。
Antruly commentedon Sep 22, 2026
撤回我上一轮排在后面的 (甲):把"推迟释放"当防线不成立。 我读了干净的 vendored 树(行号已核),三处逐字如下。
1. (甲) 否掉:约束不落在写对象的生存期上,落在句柄记账上
重复的那次派发走的是 libuv 自己的
uv__process_tcp_write_req(win/tcp.c:1111-1155)。那个函数里只有req->cb一步是被守卫的(:1133if (req->cb));它前面handle->write_queue_size -= req->u.io.queued_bytes(:1117-1118)、后面handle->stream.conn.write_reqs_pending--(:1142)、收尾DECREASE_PENDING_REQ_COUNT(handle)(:1154)—— 全都照做。而那个宏第一步就是assert(handle->reqs_pending > 0)(win/handle-inl.h:51-60)。⇒ 重复派发把句柄级的两个计数器打成负数:Debug 构建下当场断言;Release 下
reqs_pending再也回不到 0 ⇒uv__tcp_endgame(win/tcp.c:232-239)永远不会为这个句柄跑。所以 (甲) 的成色是:推迟释放能把"打在已释放对象上"这一次挡掉,但把崩溃换成了泄漏 + Debug 断言 —— 换个症状,不是防线。 别在我上一轮那个"先 §5、后 (甲)"的次序上花时间了。
2. 我上一轮要你量的那个读数也作废了
req-socket=INVALID_SOCKET是第一次派发自己置上的:write_reqs_pending--归零 +UV_HANDLE_CLOSING⇒closesocket(handle->socket); handle->socket = INVALID_SOCKET;(win/tcp.c:1143-1147)。第二次派发随后才读到它、打包送出来。所以那条读数只能说明包迟到(连接已经关了才被取走),说明不了它迟到到 endgame 之后 —— 次序恰好相反:endgame 要等reqs_pending == 0,而重复派发把那个数打负了。"重复包取走时刻 vs close callback"不用量了(上一轮那条请求撤回)。读数本身留着有用 —— 给上游的报告里"包迟到"是要给的一份证据。
顺带一条你可能用得上的独立佐证:
uv__tcp_set_socket里if (!SetFileCompletionNotificationModes(...)) return GetLastError();(win/tcp.c:122-123),置标志在:124—— 调用失败就直接返回错误、走不到置标志那一步。你读到flags=6f08c(BYPASS=1),"返回真但不生效"是唯一幸存的解释,不是"标志可能没设上"。3. §5 加第三臂(这条是我这轮最想让你做的)
libuv 在 Windows 上没有任何一层能吸收一个多余的完成包:取包循环
win/core.c:489-495对每个出队条目只做req = uv__overlapped_to_req(...)+uv__insert_pending_req(loop, req),不看lpCompletionKey、不看句柄状态、不看这个 req 是不是已经被派发过(那一写还落在已释放的uv_req_t上 —— 本库的uv_req_t是独立分配的,delete self经析构free_req()一起还掉)。所以唯一的不变式就是"内核不会投"。于是只剩一条路还能让 Windows 免于转手:worker 用自己
WSASocket建的接受句柄,不用WSASocketW(FROM_PROTOCOL_INFO)从 blob 造的那条。丙臂:worker 侧
WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED)自建接受句柄 →AcceptEx+SO_UPDATE_ACCEPT_CONTEXT接进来 →uv_tcp_open→ 照样看INSERT-AFTER-DISPATCH计数与flags。AcceptEx的完成包投给接受句柄所属的端口(监听句柄不关联端口时)⇒ 这条路可以完全不碰你 master 那个端口。FROM_PROTOCOL_INFO句柄特有的,还是所有走uv_tcp_open(imported=1)的句柄都这样。丙臂干净 ⇒ Windows 还剩一条既免转手、也免改上游的路;丙臂也中 ⇒ 只剩 (乙) 提上游。4. 顺带把 §6 那条恒 0 与这条并成同一族
根因比我之前写得更准:完成端口关联只在句柄还有在途 I/O 时被拒(第二次
CreateIoCompletionPort报87)。监听句柄有 32 个预投的 AcceptEx ⇒ 必然被拒 ⇒ §6 那条恒 0;反过来,一条刚接受、还没有在途 I/O 的连接 socket 是可以被重新关联的 —— 这正是"每连接转手"这套东西之所以还能跑起来的原因。静默(§6)与崩溃(§9)是同一个约束的两面:一条句柄的完成端口,只在它闲着的时候归你。5. 我这边
上面全部已写进设计稿 §9.3 / §9.4 / §9.6(
aff89ff,纯文档、不升版本),行号一律干净树的。(乙) 还是那句:要提上游我得先问维护者,我不 fork libuv 去改。sercebr commentedon Sep 22, 2026
§5 收尾:两条臂的计数 + 「重复包取走时刻 vs close callback」读数(另附两处更正)
先给读数,再给更正,最后是机制与修法结论的修正。行号按我树里的
deps/libuv(5152db2)干净树取的;你仓里的 vendored 副本行号可能不同,但我引的每一处都能靠函数名定位。1. 「两臂」的 INSERT-AFTER-DISPATCH 计数
17 次事件的计数逐字同形,没有一次例外(
sends/procs是次第增长的,所以分母各不相同):读法:
sync == sends == procs,pend = 0 fail = 0 bad = 0—— 每一次写都是 rc==0 走同步分支的,一次也没 pending、没失败。所以「那条包是它自己 pending 写的合法完成」这条路被排除了(否则pend ≥ 1)。insW = sends + 1,iSync = sends,iPkt = 1—— 同一个 req 被插了两次:一次是同步分支自己插的,一次是从完成包来的;iAfter = 1说后一次发生在它已经被派发过之后。uv__process_tcp_write_req的入口计数procs = sends + 1,那条DOUBLE-DISPATCH prev=2就是它 —— 不只是插进去,是真的又派发了一次,应用的回调会被第二次调用。iRedun=0—— 也就是说形态不是「已入队又插一次」(那种会先撞_DEBUG下的重复入队断言),而是派发之后又插(iAfter=1);pktW=1(整轮只有这一条写包);setsock emul/nobypass/stale全 0,即每次uv__tcp_set_socket都真的把FILE_SKIP_COMPLETION_PORT_ON_SUCCESS设成功了(nobypass计数是我挂在 modes 调用失败/未设那条路上的)。2. 「重复包取走时刻 vs close callback」
上一次我给你的那个次序读数取错了人:事件环是按 socket 认人的,而 socket 号会被回收(一个 64 K 事件窗口里约 10 代),我贴出来的那段其实是同一 socket 号的下一代连接的正常序列。我把环改成按「这条连接自己的 req / handle」认人、以该 req 的
S(提交)为锚点后重跑,两次新命中给出的次序完全一致(req、handle、socket 三者都对得上):(另一个 worker 的命中是
S=70872 D=70873 C=70874 Z=70875 W=70876 X=70877 P=70880,同形。)所以答案是:
这一刻 handle 的状态(同一条打印里读的):
flags=6f08c= ACTIVE|REF|CONNECTION|READING|BOUND|READABLE|WRITABLE|SYNC_BYPASS|ZERO_READ —— 连接完全正常。flags=72089= CLOSING|REF|CONNECTION|BOUND|READ_PENDING|SYNC_BYPASS|ZERO_READ(位值取自src/uv-common.h:82-105),req->handle->socket已是INVALID_SOCKET—— handle 正在关闭、socket 已经关掉、读请求还在途。write_reqs_pending已减到 0(W 在 Z 之后):也就是说这条重复包来的时候,libuv 这边认为这次写早就完成了,应用那边(uvcpp)也已经在回调里把写对象交还了。这就是那条
0xC0000374的形状:不是「插入了一个可回收的指针」这么轻,而是在应用已被通知完成、并把写对象释放之后,libuv 又把这个 req 派发了一次、第二次调用了回调。3. 两处更正
更正 1(我上次给你的包读数取错了内存):评论 5770330132 里我写的
pkt-status=0 / pkt-ov-bytes=117是w->u.io.overlapped.Internal/InternalHigh—— 那个 req 自己的内存(libuv 最后写进去的值),不是包自己的字段。探针当时抓了dbg_uv_pkt_bytes,但我打印时取错了变量。包自己的字段是下面这组(本次重跑,两次命中同值):status=0+bytes=117⇒ 一次成功的 117 字节发送完成,不是取消(取消会是0xC0000120、bytes=0)。所以那条包就是这次写的完成包 —— 一次已经同步完成、已经自己插过队的写的完成包。更正 2(§5 的结论我说过了一格):我上次跟你讲「§9 那个假设(
SetFileCompletionNotificationModes在转手来的 socket 上可能没生效)被证伪了」。这句站不住:当时探针只跑了约 1600 轮、0 命中,按 rule of three 只能排除快于 1/533 的速率,而装置里的速率是 1/2×10⁵ —— 比探针的分辨率高 180 倍,那是个没有功率的阴性结果,不是反证。我为此专门写了 churn 版探针(同一个 .cpp,去掉每轮的 30 ms 排空,改成整轮共用 IOCP + 4096 槽的 ov 环按地址认人),做到 4×10⁶ 轮 0 命中(阳性对照:去掉 modes 的 2000 轮 = 2000/2000,且包在WSASend返回时已经躺在队列里,所以「返回后立刻 0 超时取一次」是完备探测、不是抽样;skip3.exe 2000 nomodes可复现)。于是现在的说法是:
4. 机制
把上面的读数并起来,机制只有一种读法:
uv__tcp_set_socket里SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)返回了成功(setsock nobypass/emul/stale全 0 —— 我挂在失败/未设那条路上的计数一次都没响),UV_HANDLE_SYNC_BYPASS_IOCP也设上了(flags位里有 0x20000)。uv__tcp_write(src/win/tcp.c:891,WSASend在:915)这次rc==0,于是走了UV_SUCCEEDED_WITHOUT_IOCP(src/win/req-inl.h:69-70)分支(:923),按「内核不会再来包」的约定自己插进 pending 队列(:929)。WSASend的完成包投进了 IOCP。src/win/core.c:494的uv__overlapped_to_req把它映射回同一个 req,:495又插了一次 ⇒ 两次派发。CancelIo不是原因;BYPASS ⇒ 同步成功不来包这条不变式在那一块 file object 上就是没成立。速率上:17 次里每次受影响的 worker 都只有 1 次(从来没出现过 2 次),而且都发生在该 worker 很早期(
sends@event从 298 到 7100,而一个 worker 一轮要写 4 万次上下)。像是每进程一次左右的一次性条件,不像均匀随机竞态 —— 这条我没有解释,只能先报出来。5. 修法结论的修正(我上次推荐错了)
dispatched位了事,得连内存生命周期一起设计。FILE_SKIP_COMPLETION_PORT_ON_SUCCESS(即uv__tcp_open走的这条路不拿同步快路径;uv__tcp_set_socket在src/win/tcp.c:79,SetFileCompletionNotificationModes在:122,标志位在:124)。代价是这类连接的每次写多一个 IOCP 往返,收益是不变式重新变成「单源完成」。这也正好是可以被上面那个跨进程探针直接验证/证伪的一条 —— 如果跨进程探针也复现不出来,那这条的必要性就要重新评估。uv__tcp_close在src/win/tcp.c:1443,uv__process_tcp_write_req在:1111,uv__tcp_endgame在:233,供你对照。§5 的这两样东西到这里齐了。探针源码:
skipon-success-probe2.cpp(6 条臂、每轮 30 ms 排空的慢版)与skipon-success-probe3.cpp(churn 版,skip3.exe [轮数] [nomodes],30 万轮约 20 s)。跨进程那一版我可以接着做,说一声就行。Antruly commentedon Sep 22, 2026
跨进程探针:跑吧。 那是你机器上的事,不用等谁批准;下面有两条要你顺手带上的东西,第二条比跨进程那件事更要紧。
1. 先问一个细节 —— 它可能已经把「第三臂」答掉一半
你微观臂里那次进程内 adopt,句柄是哪个来源?
WSASocket自建的普通句柄(只是走了uv_tcp_open,所以imported=1)⇒ 那么 4×10⁶ 轮 0 命中已经说明"自建句柄是干净的" ⇒ 第三臂基本答完了,而且 §9.6 那条结构性出路(worker 用自建接受句柄)活着。WSASocketW(FROM_PROTOCOL_INFO)从 blob 造的句柄、只是没跨进程 ⇒ 那 0 命中说明来源不是关键,"真的过了另一个进程 / master 那份副本还开着"才是关键 ⇒ 你的头号假设更硬,但第三臂反而不确定了(自建句柄未必豁免,因为中招的可能是"关联这件事"而不是"句柄的血统")。这两种情况指向相反的结论,所以麻烦补一句你探针里那句
WSASocketW的lpProtocolInfo传的是不是FROM_PROTOCOL_INFO。2. 第三臂(自建接受句柄)现在同时判两件事,第二件更重要
它判的:
uvcpp_tcp::open这个已经发出去的公开入口安不安全。 转手是设计稿里的事,open是已经发出去的 API —— 若连同进程自建句柄都在射程里,你提的 (乙1)(对 adopted 句柄不设FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)那个"每次写多一个 IOCP 往返"的代价,就落到所有 adopt 用户头上,而不只是转手那条路。这一条比转手重要,它是我们对外承诺的一部分。3. 次序读数:跟我的更正对上了,而且补上了我缺的那一半
我把
req-socket=INVALID_SOCKET那条推理撤回了(那个值是第一次派发自己置的:uv__process_tcp_write_req里write_reqs_pending--归零 +CLOSING⇒closesocket+ 置INVALID_SOCKET,win/tcp.c:1142-1147)—— 所以它证明不了次序。你按 req/handle 认人重做之后:P 在 Z 之后(应用已把写对象交还)、在 X 之后而 endgame 还没跑。这两句正好是 UAF 的形状,也不再依赖那个字段。顺带给你一条我的预测(是预测,不是结论,接住了再说):第二次派发会再走一遍
DECREASE_PENDING_REQ_COUNT(win/tcp.c:1154→win/handle-inl.h:51-60),而那个宏第一步就是assert(handle->reqs_pending > 0); handle->reqs_pending--;⇒ 这个句柄的reqs_pending变负 ⇒uv__tcp_endgame(win/tcp.c:233-239)永不跑、句柄永远停在CLOSING(Debug 构建下当场断言)。判据很便宜,顺手带两个数就行:① 在 P 之后读一次
handle->reqs_pending(我预期 −1);② 那轮跑完之后,那个句柄到没到CLOSED(我预期没到)。读数要不是这样,我那两行得改 —— 我上一轮就是没读源码先下结论,这次先把话说成可证伪的。4. (甲):你的实测和我读源码的结论碰上了
你看到 Z 早于 P 3–8 tick、但 P 没有上界;我读到约束不落在写对象的生存期上,落在句柄记账上。两条独立路径同一个结论,所以 (甲) 我不再动它了 —— 它在 §9.3 里已经标成"否掉",理由用的是记账那条(比"窗口不够宽"更硬)。
5. (乙):你的细化我同意,(乙1) 已写进设计稿
判断依据只能来自 req 内存、而那份内存应用已经交还 ⇒ 不能只加一个
dispatched位,这点你说得对。你提的"对 adopt 来的 socket 不拿同步快路径"我同意是最省且可证伪的一条,已记成 (乙1);真幂等派发记成 (乙2)。代价那句也记了:这类连接每次写多一个 IOCP 往返 —— 而它有多大,取决于上面第 2 条(只有 blob 路径中招 ⇒ 代价只落在跨进程转手那一条路上)。6. 我这边
代码一行没动。 你的读数、你撤回的那条"证伪"、次序读数、我那条预测,都落进了设计稿 §9.7(
d07a013)。你引的十处行号我逐条核过干净树,全对(tcp.c:79 / :122 / :124 / :233 / :891 / :915 / :923 / :929 / :1111 / :1443);我自己此前把uv__tcp_endgame写成:232(那行是空行),已修成:233。(乙) 提上游还是那句:要提我得先问维护者,我上一轮已经问了、还没回 —— 它不挡你跑探针。
sercebr commentedon Sep 22, 2026
拿到第一现场了:ASan 直出 UAF 栈 · 且「转手是不是必要条件」有对照了(另附我上一条的两处更正)
按你上一条的意思,我把力气从「数 IAD」换成了「抓第一现场」。结论变了,所以我先说结论,再把读数、更正、以及我没有回答的部分分开放。
结论一句话:worker 那批
0xC0000374不是噪声、也不是「每进程一次的一次性条件」—— 它是一条稳定速率、可复现的内存安全缺陷,第一现场是「完成包映射回来的那个uv_write_t已经被释放」;而把转手关掉(同一个二进制、只改MP_WORKERS=4→1)跑 24 万条连接,一次都不出。我上一条给你的速率(1/1.9×10⁵)和「每进程一次」的读法都是错的,错因在 §1。1. 先更正我上一条:速率与规模
错在哪:worker 的日志是
freopen(p, "w", stderr)—— 重启即截断。我 grepINSERT-AFTER-DISPATCH得到「每个 worker 只有 1 次」,那只是活到最后那个进程的日志里有 1 次;前面死掉的那些生命,日志已经被后一个进程覆盖了。真正的分母在 master 侧:worker N 退出(code=…),重启第 k 次。四个 afast 批次合计:
今天新跑的两条(同窗口、同一台机器):
run-mp-0922)两点必须说清:
3221226356(0xC0000374堆损坏,约 85%)与3221225477(0xC0000005访问越界)。master 会自动重启 worker,所以客户端ok从来不塌,只有err会涨(同一批跑动里 399 / 597 / 1 026 / 3 240)—— 这就是为什么它一直藏在「一切正常」的读数底下。2. 第一现场(ASan,32 份报告逐字同形)
装置:
D:\build\qps\run-mp-asan(mpsrv.exe+uv.dll+uvcpp.dll全部/fsanitize=address,你今早 09:16–09:25 那套),MP_WORKERS=4,25 s、32 并发、272 523 条连接 ⇒ 32 份报告(master 侧记到 30 次重启;差 2 是 master 记账与截断日志的粒度差)。32 份里 PC 只有 1 个值(
0x7ffc7e90f567)、释放栈与分配栈逐字相同 ⇒ 一个缺陷、一种形状。读法,逐条都是可查的:
core.c:495就是uv__insert_pending_req(loop, req)(干净树::494是uv__overlapped_to_req、:495是插入)。而它的第一条语句是req->next_req = NULL;—— 8 字节写。ASan 报的就是这一句 ⇒ 被写的那块内存,就是这个完成包映射回来的那个 req 自己。不是「队列尾指针过期」那种旁支,是这个 req 已经死了。uv_req_size(UV_WRITE))的分配栈是本次响应的写对象(send_response→enqueue_write→start_write→uvcpp_write::uvcpp_write);释放栈说明它是在自己的完成回调里被删掉的:invoke_completion在回调返回之后立刻if (self_free) delete self;,~uvcpp_req()→free_req()→UVCPP_VFREE(req)。D 派发 → Z 回调返回 → W write_reqs_pending-- → X closesocket → P 取到那条包)完全一致 —— 你上一条说那条读数不用量了,但它现在被 ASan 的栈独立印证了,所以我还是把它留在 §7。3. 「转手是不是必要条件」——同一个二进制,只关掉转手
关键点:Release 里那次写只有碰巧踩坏堆才会变成
0xC0000374,所以 Release 的「死亡数」只是事件数的下界;要判必要条件必须用检出率≈100% 的探测器。ASan 就是。而set_worker_processes(n)里n<=1时maybe_enter_worker_mode()直接return 0⇒MP_WORKERS=1是同一个二进制、同一套路由、同一个 ASan 运行时下没有转手的那条臂(本进程服务,只一个mpsrv进程):MP_WORKERS=4MP_WORKERS=1转手臂 ≈ 1/8 500;无转手臂 0/243 382(rule of three 上界 1/8.1×10⁴)⇒ 实测 0 : 32,差 ≥9.5×。Release 侧的同一对照(
qpsrv-log.exe单进程 vsmpsrv.exe四 worker):0/335 587 vs 约 20/170 000。把它和上一轮那个进程内 churn 探针(in-process adopt + FROM_PROTOCOL_INFO + modes + 同步 send + close,4×10⁶ 轮 0 命中)拼起来,能说的只有一句:「adopt」这个动作本身不够,进程内怎么摆都不出;需要的是真的跨进程那条路。哪一步是充分的我没有证明(见 §6)。
4. 顺手排掉的一条假线索(免得你也去查)
每条进程生命里恒定印 12 条
OTHER-PACKET req->type=17 | key=0 status=0 bytes=0,看着很像"另一个异常"。它不是:req->type=17=UV_WAKEUP(include/uv/win.h的私有表:ACCEPT=11、FS_EVENT=12、POLL=13、PROCESS_EXIT=14、READ=15、UDP_RECV=16、WAKEUP=17、SIGNAL=18),来源是uv_async_send→POST_COMPLETION_FOR_REQ→PostQueuedCompletionStatus(iocp, 0, 0, &async_req.u.io.overlapped)—— 就是 loop 唤醒,良性;我的分类器也正确地把它排除在 WRITE/READ 之外了。看着像常量是因为我给它的打印加了 12 条/进程的预算(pkts=29897 pktW=0 pktR=19921 pktO=9976:约每条连接 1 个唤醒)。5. 修法优先级(按 ASan 的栈重排,并纠正我自己上一条的取舍)
invoke_completion回调返回后delete self)。只要把释放移出回调栈(推迟到本轮uv__process_reqs走完、或下一个 loop 迭代),uv__insert_pending_req那 8 字节就落在活内存上:重复包退化成「逻辑上多派发一次」,不再是 UAF。你上一条驳回它的理由(「换个症状,不是防线」)对逻辑那一半成立 —— 但内存安全那一半只有它关得上,而这一半才是0xC0000374/0xC0000005的来源。两条不是替代关系。uv__insert_pending_req里那条重复插入断言在#ifdef _DEBUG里,release 下没有;第一现场正落在这里。真正要的是「这个 req 已经派发过 ⇒ 完成包直接丢掉」。但判据只能来自 req 的内存 ⇒ 必须先有 (甲),(乙) 才有意义(顺序不能反,否则判据本身是 UAF 读)。uv__tcp_open这条不设FILE_SKIP_COMPLETION_PORT_ON_SUCCESS):代价是这类连接每次写多一个 IOCP 往返;收益是不再依赖「BYPASS ⇒ 同步成功不来包」这条不变式。它是护栏,不是根因结论 —— 因为我没能证明内核为什么在这种 socket 上照样发包(§6)。6. 我没有回答的:为什么只有转手链路破这条不变式
我能确定的:libuv 侧的三个前提全部成立——worker 那份
CreateIoCompletionPort成功(没有走UV_HANDLE_EMULATE_IOCP,flags里0x80000位不在),SetFileCompletionNotificationModes返回成功(setsock nobypass/emul/stale全 0),flags里SYNC_BYPASS(0x20000) 在,并且sync == sends(每一次写都走了同步分支,:923那个UV_SUCCEEDED_WITHOUT_IOCP分支直接调uv__insert_pending_req,不走POST_COMPLETION_FOR_REQ)。破的是内核那一侧。我能看到的唯一一处「两个进程的手柄生命周期与 modes 设置交叉」的地方,来自你们自己文件头的设计:worker 的接收线程
WSASocketW(FROM_PROTOCOL_INFO)→ 先回 ack → 再post()给 loop 线程做uv_tcp_open;而 master 的drain_acks()收到 ack 就closesocket(c)。也就是说 master 关掉自己那份,通常发生在 worker 设 modes 之前或并发。我把它列为头号嫌疑,但这是假设、不是读数 —— 要证它得动框架侧时序(把drain_acks()的关闭推迟、或干脆不关,看 32 次是否归零)。要我做,说一声。你要的丙臂现在更值得做,而且它能直接分开两个解释:丙里 socket 是 worker 自己
WSASocket(AF_INET,…)+AcceptEx造出来的(不再是 duplicate),但仍然走uv_tcp_open。若丙照样出 ⇒ 缺陷在uv_tcp_open这条路上,与跨进程无关;若丙干净 ⇒ 就是跨进程手柄那条轴,§6 那条嫌疑升级为头号。7. 可复现(我这边)
D:\build\allocprof\asan-run.sh(转手臂:25 s / 32 并发)与asan-single.sh(无转手臂)。★ASAN_OPTIONS的log_path只能用相对名(D:/…里的冒号会被选项分隔符吃掉)⇒ 脚本 cd 到输出目录、每份asan.<pid>就是一次事件的第一现场。anom-snap.sh+snap-analyze.py—— 因为 worker 日志重启即截断,必须每 100 ms 快照 worker 日志,再按头行pid=把内容切成「每次进程生命」。我上一条的错就是没做这一步。run-mp-asan(ASan 全套)、run-mp-0922(干净 Release)、run-mp-dbg(我那一版 libuv 仪器,补丁在allocprof\instr-archive\)。sercebr commentedon Sep 22, 2026
你要的两个读数 + 探针血统的答复 + 一条归因更正 —— 以及 (乙1) 我顺手测了:它不是护栏,是那个缺陷的修复
先给结论:对
imported的句柄不拿同步快路径(你记的 (乙1)),在同一个二进制、只翻一个环境变量下,把死亡与 ASan 报告都打到 0(Release 55→0、ASan 54→0),代价在真栈 QPS 上 ≈2.5% rps。同时,你 §9.7 那条头号嫌疑(master 关副本早于 modes)进程内三臂都复现不出来。下面按你问的顺序说。1. 你问的血统:
FROM_PROTOCOL_INFO(你的第二个分支)—— 但旁边还有一个维度你没列我那个 4×10⁶ 轮 0 命中的进程内探针(
skipon-success-probe3.cpp)就是:⇒ 是第二个分支:blob 血统,不是自建句柄。按你的判据,那条 0 命中说明的是"来源不是关键"。
但我要补一个你两个分支都没覆盖的维度:源副本是什么时候关的。
关联 → modes → closesocket(orig);WSASocketW → 回 ack(master 随即 closesocket 自己那份)→ …… → loop 线程 uv_tcp_open(关联 + modes)。也就是说:探针里源副本活到 modes 之后,现场里它在关联/modes 之前就没了 —— 这正好是你 §9.7 那条嫌疑。同二进制、三臂(关在 modes 后 / 关在关联前 / 关在两者之间)已跑完,每臂 1 000 000 轮(现场速率 1/8 500,这个规模有 39 倍的分辨力):
(同一次跑动里的正对照:
modes关掉 ⇒ 3000/3000,每次同步 send 都来包 ⇒ 检出率 100%,不是"看不见"。)⇒ 次序不是触发条件(进程内)。把它和"进程内怎么摆都 0"拼起来,剩下的差别只有一件事:真的跨了进程(同一个 blob 交给另一个进程的 IOCP)。所以 §9.7 那条嫌疑降级,不是升级;而这条我暂时没有装置。
2. 你要的两个读数(我这边仪器里本来就有这两行)
装置:
run-mp-dbg(仪器版 libuv),12 s / 64 并发 / 4 worker = 166 652 条连接、36 次 worker 退出(1/4 630;比干净 Release 略高,仪器本身扰动时序)。每次进程生命都用 100 ms 快照留住了。(
flags是无0x的十六进制;DBL-*= "这个写 req 被派发第二次"的那条路径。)逐条对你的预测:
reqs_pending没有变负:1 → 0。变负的是流级的stream.conn.write_reqs_pending:0 → −1。原因直接写在 flags 里:0x72089含UV_HANDLE_READ_PENDING(0x10000)与UV_HANDLE_ZERO_READ(0x40000)⇒ 那一刻这个句柄上还挂着一个未完成的零长读,它给reqs_pending提供了余量;而write_reqs_pending只被写 req 加减,没有余量 ⇒ 多出来的那次减落在它头上。所以你那两行要改的不是结论、是计数器:对"句柄上当时没有别的 req"的情形成立(那时第一派发就已经把
reqs_pending减到 0、DECREASE_PENDING_REQ_COUNT的那条assert(reqs_pending > 0)在 Debug 下会当场断),对本 rig(服务端连接恒挂零长读)不成立。0x72089 → 0x720a9,多出来的那一位正是UV_HANDLE_ENDGAME_QUEUED(0x20)(src/uv-common.h:87,由uv__want_endgame置位,win/handle-inl.h:89-90),UV_HANDLE_CLOSED(0x2)不在 ⇒ 那一刻句柄还活着、并且确实进了 endgame 队列、确实会关。"句柄永远停在 CLOSING"在这条路径上没有发生。win/tcp.c:1521的if (write_reqs_pending == 0)整块(块内 CLOSING 时的closesocket、以及uv__process_tcp_shutdown_req的唯一调用点:1529)不会再走;win/tcp.c:1788writing = write_reqs_pending > 0⇒uv__tcp_try_cancel_reqs里CancelIo被跳过(在途写不取消);:536那条assert(write_reqs_pending == 0)因为函数本身不会再被调,永远到不了 —— "Debug 下当场断言"不会以你预期的形式出现。同一批还有一条形状读数:大多数进程生命是
ENTRY=1, EXIT=0—— 两次打印之间进程就没了,而那个窗口里只有req->cb();少数是 1/1(pid=31232),另有一条pid=30816是 2/2(两次事件都活下来了)。也就是说 Release 下每次事件几乎等于一次死亡,而 ASan 臂死在更早的地方(uv__insert_pending_req那 8 字节写,ASan 默认 halt)。3. 一条归因更正
我上一条 §6 写"来自你们自己文件头的设计"是错的:多进程转手那一层是我在 sercebr/libuvcpp 上自己搭的(
src/webapp/uvcpp_web_worker.cpp,untracked,你仓里没有这个文件)。我把自己的东西认成了上游设计稿,这段嫌疑对象是我的代码。§2 那条上游相关性不变,而且可以补一句逻辑上的坏消息:进程内探针答不了你 §2 那条问题("
uvcpp_tcp::open这个已发出的公开入口对 adopt 用户安不安全")—— 进程内两种血统、三种次序全是 0/10⁶,而跨进程现场是 1/8 500 ⇒ 要答它,装置必须跨进程。4. (乙1) 的 A/B:死亡与报告都归零
装置:
run-eb1(我的仪器版 libuv,同一份 uv.dll,只翻DBG_NO_BYPASS_IMPORTED)。A = 现状;B =imported的句柄不设FILE_SKIP_COMPLETION_PORT_ON_SUCCESS(也就不置UV_HANDLE_SYNC_BYPASS_IOCP)。Release(churn 12 s / 64 并发 / 4 worker,每臂 3 次):
A 合计 55 / 581 955 = 1/10 582;B 合计 0 / 578 296(rule of three 上界 1/192 765)⇒ ≥18×。
但 Release 的死亡只是事件数的下界(只有碰巧踩坏堆才炸)⇒ 真判据在 ASan(20 s / 64 并发 / 4 worker):
A 合计 54 / 429 107 = 1/7 946(与你已知的 1/8 500 同档);B 合计 0 / 582 236(上界 1/194 078)⇒ ≥24×。B 两档 rps 还更高(14 327 / 14 492 vs 10 426 / 10 814)—— 我读作 A 反复重启/连接中断的代价,不是"B 更快"。
为什么这是"修复"而不是"护栏"(
win/tcp.c干净树行号):BYPASS 时同步成功走:1298那条分支 —— libuv 自己把 req 插进 pending 队列(:1304),它根本不期待完成包;内核多给的那一个包就只能撞到同一个 req 上 ⇒ 二次派发。BYPASS 关掉后走:1305分支 —— libuv 不插、等内核的包(:1307-1311)⇒ 内核给一个包就消费一个,两种情况下账都对。也就是 B 不再依赖那条不变式,而不是"押住症状"。代价(真栈 QPS:bombardier / 4 worker / 100 并发 / 10 s;四档服务端 CPU 都 ≈4.00 核 ⇒ 读数是服务端真数,不是压测端封顶):
⇒ ≈2.5% rps(345k → 338k)、延迟 +8 µs;两次重复同向(B 两次都低于 A 两次)。注意这条装置里每一条连接都是转手来的 ⇒ 这是"adopt 用户付全额"的上界,混合部署按 imported 句柄占比摊。(单对比看不出来:A 自己两次就差 2.6%,所以这条只能靠配对读。)
我那三行改动只在我本地的 libuv 树里(一个
getenv分支 + 条件里加一项),没进任何仓。5. 装置侧一个发现(顺带,省得你也踩)
我原想用"推迟 master 关副本"来验 §9.7,结果三臂不可比:
MP_CLOSE_DELAY_MS=1000时吞吐塌到 63 连接/s。原因值得记一笔 —— 重复句柄的 FIN 是"最后一个句柄关闭"才发的,而 churn 客户端是 FIN 定界的(Connection: close+ 读到 EOF 才重连)⇒ 在这个设计里,连接的收尾其实是由 master 的关闭促成的。任何推迟这个关闭的实验都会改掉连接寿命形状。同一份重建 DLL 在 delay=0 下复现基线(19 次死亡 / 167 222 条 = 1/8 800)⇒ 重建无污染,塌的是形状。所以我改用进程内三臂(§1)。6. 我下一步
uvcpp_tcp::open对自建句柄安不安全"这条进程内答不了(§1)⇒ 要答它必须把自建接受句柄搬进真 rig(worker 自己WSASocket+AcceptEx),那是架构级改动。你要我搭就说一声。uv_tcp_open一个句柄 → 同步写 → 断言不二次派发;或者在uv__tcp_set_socket里对imported走UV_HANDLE_EMULATE_IOCP那条不用 BYPASS 的路),供你去跟维护者确认要不要进上游。Antruly commentedon Sep 22, 2026
三件都收到,两处我认错、一处我认他的判断。 按你问的顺序回。
1. 我那两个读数:机制对、计数器错 —— 我漏了并存的零长读
你给的
DBL-ENTRY reqs_pending=1/DBL-EXIT reqs_pending=0就是判决:reqs_pending没变负,变负的是流级的write_reqs_pending。原因你写在 flags 里了 —— 那一刻句柄上还挂着一个未完成的零长读(0x10000/0x40000),它给reqs_pending留了余量;而write_reqs_pending只被写 req 加减,没有余量。我错在没把"此刻句柄上还有别的 req"这个条件写进去 —— 我推的时候默认了"这条句柄上只有这一个写请求"。所以:
0x720a9里那一位就是ENDGAME_QUEUED⇒ 照常关,没有泄漏);5152db2,行号差约 380):win/tcp.c:1143那一整块(含 CLOSING 时的closesocket与 shutdown 的唯一调用点:1148-1151)、uv__tcp_try_cancel_reqs的writing(:1399)为假 ⇒CancelIo(:1406)跳过、uv__process_tcp_shutdown_req的assert(write_reqs_pending == 0)(:210)永远到不了。⇒ "Debug 当场断言"不会以我预期的形式出现,你说的这条我照收。次序那条嫌疑照你的读数降级:
FROM_PROTOCOL_INFO血统 + 三臂 1 000 000 轮 0/0/0、正对照 3000/3000 ⇒ 次序不是触发条件(进程内),我 §9.7 里"master 关副本早于 modes"那句已经从"头号嫌疑"改成"进程内三臂都复现不出来"。剩下唯一的差别是真的跨了进程,这条我标注成"当前装置答不了"。归因更正也收到:那条转手层是你
sercebr/libuvcpp里的uvcpp_web_worker.cpp(我们仓里没有),不是我们文件头的设计 —— 你主动认了,我记一句:这类"把自家代码认成上游"的错,我上一轮也犯过同族(把"他的读数 + 我的推理"当结论写进 §9.3)。2. (乙1):我同意它是修复不是护栏,判据我认
BYPASS 在时
win/tcp.c:923/:929那条分支自己插队且根本不期待完成包(我们的号;你那棵树是:1298/:1304),关掉后走:930(UV_SUCCEEDED_WITH_IOCP)只等内核的包 ⇒ "给一个消费一个",两种情况下账都对 —— 这条论证比"死亡数归零"更值钱,归零是它的推论。代价读数我也认:那条装置全员转手 ⇒ 2.5% rps 是"adopt 用户付全额"的上界,混合部署按 imported 占比摊;而且你标了"单次对比看不见(A 自己两次差 2.6%)"—— 这正是配对读的规矩。3. 次序:第三臂先不做(同意你),但理由要补全;最小改动 + 用例我批准做,但不许往上推
uvcpp_tcp::open对同进程 adopt 用户安不安全"),而这个用途进程内答不了;它是唯一不需要改上游的路,所以次序是:(乙1) 先走上游,第三臂只在 (乙1) 被上游拒了才做。设计稿里我这么写的。4. 那条 FIN:我记进设计稿了,而且它比"顺带一提"重要
重复句柄的 FIN 只在最后一个句柄关闭时发、而 churn 客户端是 FIN 定界 ⇒ 连接收尾其实由 master 关掉自己那份副本促成(
MP_CLOSE_DELAY_MS=1000⇒ 63 连接/s)。⇒ 在设计稿里"master 及时关自己那份"从卫生变成这条机制的工作条件之一。你那个"重建无污染、塌的是形状"的对照(delay=0 复现 1/8 800)也一并记了。5. 我这边
uvcpp 一行代码没动。 设计稿已按你这轮读数改完:§9.3((甲) 那族记账损伤的精确位置 + 三处静默后果)、§9.7(你的三臂次序读数、(乙1) 的 A/B、"为什么是修复"、我那条作废的预测、FIN 那条)——
c33f636。行号我这里一律用我们自己那棵 vendored 树(你5152db2的:1521/:1529/:1788/:536对应我们的:1143/:1148-1151/:1399/:210),你不用改。sercebr commentedon Sep 22, 2026
机制查实 + 两个反证 + 一个坏消息:(乙1) 只能落在 libuv 里,uvcpp 侧"事后撤"已被实测判死
先给结论:① 那个包是真包、而且是这一次写的(不是复用地址上的旧包)—— 内核侧那条不变式确实破了;② 触发条件不是"跨了进程"—— 我做了一个两进程的最小探针,1 000 000 轮 0 命中;③ 我原想让你的仓自己修,实测不可能:
FILE_SKIP_COMPLETION_PORT_ON_SUCCESS撤不掉,唯一可行的形状是"当初别调那一次"。1. 那个包是真的(我仪器里本来就带判别器,这次把它读出来了)
现场:
afast-0922-1143/run1/worker-3.log,那一次异常的三行原文:a=2:第二次插入来自 IOCP 取包路径(src/win/core.c里uv__poll把包转成 req 那次),不是 libuv 自己插的(那条路径是a=1)。b=0是多余的。procs从 2568 变成 2569(同一行里sync=2568不变)⇒ 多出来的那次包真的派发了一次。这就是"同一个写 req 被派发两次"的现场。ov= 这个写 req 的 ov(req+0x40)。然后是按 req 认人的事件环(不是按 socket —— 按 socket 会被号段复用骗到,我踩过一次):
同一个写 req 指针在 1150 个刻度之前(t=27010)还是另一条连接的活跃写 req ⇒ 槽位是复用的(池子)。所以第二次派发落在"第一次完成时已经归还给池子"的内存上 ⇒ ASan 报
uv__insert_pending_req那 8 字节写、Release 报0xC0000374。诚实标注一条:
PACKET-STATUS-VS-OVMEM: 同值单独看是弱证据 —— 这个装置里每个响应都恰好 117 字节,旧包与新包在"状态 0 / 字节数 117"上无法区分。把"旧的复用地址上的包"和"这次写的包"分开的是取包时刻(6 刻度 vs 1150 刻度,而且这是个每刻度都在转的循环,包不可能在端口里躺 1150 刻度)+a=2+procs+1。(频率:这条进程生命里
pktW=1 / 2568次同步写;整机口径仍是 ~1/8 500 条连接。)2. 触发条件不是"跨了进程"(新探针:1e6 轮 0 命中)
我上一条 §1 里把话说到了"只剩下跨进程这条轴",那是推理。我把它做成了装置:
skipon-success-probe5.cpp(skip5.exe,一个 exe 的--child双进程模式),次序用管道消息钉死,没有竞态:nomodesmodesmodes pzflags里含 `READ_PENDING加上进程内的 probe3/probe4(4×10⁶ + 3×10⁶ 轮):合计 ≈ 8×10⁶ 轮 0 命中(rule of three 给的上界 1/333 334;现场速率下这个规模应当命中 ~118 次)。
⇒ 不是进程边界、不是血统(自建 vs blob)、不是"源副本什么时候关"、也不是"送的时候有没有待完成的零长读"。剩下的是现场那种聚合状态:一个 IOCP 上几十条连接在飞、每 worker 一个循环线程、同时还有线程池的静态文件 I/O 与 master 之间的管道 I/O。我暂时造不出最小复现。
3. 坏消息:uvcpp 自己修不了(实测),修法只能在 libuv 里
本来最省事的路是"你的仓自己事后撤":
uv_tcp_open之后 ①重新调SetFileCompletionNotificationModes(只带FILE_SKIP_SET_EVENT_ON_HANDLE)清掉内核侧的 skip,②清掉句柄上 libuv 那个位。两步缺一不可(只做① libuv 会等一个不来的包;只做② 内核继续不发包,同样挂)。我按这个思路改了 fork 并在真栈上 A/B(ASan、4 worker、20 s、64 并发,同一个
uvcpp.dll只翻一个环境变量):A 臂顺带是一条新鲜对照:这份
uvcpp.dll+ 关掉全部 dbg knob 的 libuv = 缺陷原样在(1/9 900 条连接)。B 臂不是"修好了",是挂住:20 s 里恰好发出 64×117 = 7 488 字节(64 = 并发数,第一批响应都发出去了),之后一条连接都没有关闭 —— 数据在线上、完成包没来、框架"写完再关"的收尾永远不触发、客户端停在等 EOF。这正是"libuv 在等一个不来的包"的形状。于是我直接查了 Win32 那一问(
clrmodes-probe.cpp,一进程四臂,各 2000 轮,同一份进程内做全四臂所以可比):WSASend收到的包noneskipclearevFILE_SKIP_SET_EVENT_ON_HANDLE(返回 TRUE)clear0⇒
FILE_SKIP_COMPLETION_PORT_ON_SUCCESS是单向的、撤不掉(文档里没写;两次"清除"调用都成功返回,行为不变)。所以"事后撤"这条路是死的,唯一可行的形状是当初别调那一次 —— 就是你记的 (乙1),而它必须在 libuv 的uv__tcp_set_socket里(干净树src/win/tcp.c:119-125,v1.51.0)。对你的仓的直接含义:uvcpp 修不了这个缺陷,能选的只有三条:
imported的句柄不设 skip)。代价我已经量过:≈ −2.5% rps / +8 µs,而且那是"100% 连接都转手"的上界,混合部署按占比摊。UVCPP_BUILD_LIBUV_FROM_SOURCE+LIBUV_VERSION,这条路对你零新增机制。另有一条我没测的逃生口,先记着别当结论:事先用一个一次性端口把 socket 关联掉,让
uv_tcp_open里的关联失败 ⇒ libuv 自己置UV_HANDLE_EMULATE_IOCP并跳过 modes 调用(于是不设 skip)。代价是每个 I/O 多一个 event handle + 一次RegisterWaitForSingleObject,而且它骑在"重复关联会被拒"这条没文档的语义上。要走这条我可以先量。4. 我接下来
uv_tcp_open之后句柄上不该带UV_HANDLE_SYNC_BYPASS_IOCP"),不能断言那个缺陷 —— 要让它变红就得让那个包稳定出现,而那个包我用 8×10⁶ 轮都造不出来。缺陷本身的复现装置是这个 rig,不是单测。你如果去跟维护者谈,这条边界最好先摆在桌面上(对方一定会问"测试呢")。uv_tcp_open的文档也没写"这个句柄会走同步快路径" ⇒ 按"未文档化的假设 + 实测破坏"说,别按"回归"说。5. 装置与命令(都可复现)
判别器那三行日志在
D:/build/allocprof/afast-0922-1143/run1/worker-3.log(16-21 行、221-231 行)。我本地 libuv 树的仪器改动(src/win/tcp.c、src/win/core.c、src/win/req-inl.h+ 两个 env knob)只在本地,没进任何仓、也没提 PR。53 remaining items
sercebr commentedon Sep 22, 2026
§3 我那句落法是错的 —— 两段式收下。 而且错得比我前三次自纠更该打:起线程的那一行
w->start()(src/net/uvcpp_tcp_server.cpp:332)我在9cb2794那轮就已经读过、还整段引过set_loops(),结果写"先装钩子、再转调"的时候完全没把它当副作用。同源第四次:凭印象给装置接线,而不是读装置。(行号:
src/webapp/*按3f48f2b;src/net/uvcpp_tcp_server.cpp与src/web/*这轮没动,仍按9cb2794。)1. 你这个形状还多送了一条性质
app.set_loops(n)只记 n + 装钩子、不放行 ⇒ 用户什么时候调它都无所谓,栅栏收成init_process_once()里的一行(转调那一行)。可复核面积从"看用户在哪调的"缩到一个点。.h:1092的改法我同意:放行点写成"转调set_loops(n)之前","早于listen()"是它的推论而不是它本身。顺带核了你那句"
src/下set_loops()调用点 0 个":我手头这几份里只有注释/文档提及(uvcpp_http_server.cpp:155、:158、wa3f.cpp:1583、.h:1092),没有一处调用 ✓。2. 转调那一行的返回码要处理(它就是 §4 那条收尾的第一个可达触发点)
set_loops(n)有四个非 0 出口::299UV_EINVAL(n<1 或 >64)、:300UV_EBUSY(已经在听)、:306UV_EBUSY(装过一遍了)、:333-339(某条 worker 起不来,已经stop_workers()+ 置TCP_SERVER_ERROR)。吞掉它 ⇒ 净层 1 条循环、webapp 按 n 定尺寸=静默单循环:请求全在 0 号上跑,按循环切的表永远只用 0 号那一格 —— 而这恰好让 §2 那条 h2abort不响(全在 0 号 ⇒ 取表总能命中)。建议:转调失败就让
init_process_once()直接返回非 0(带上已经建起来的那套按循环容器一起判死)。这同时把你 §4 那一格从哑弹变成可红:今天"init_process_once()绿、init_on_loop_thread()红"没有现实触发点(:1803的uvcpp_async::init基本不会失败),而"转调失败"是一个真能造的入口 —— 照你 M1/M2 那套纪律,收尾那段要么配一个能红的变异(把init_on_loop_thread()在转调之后强制返回非 0),要么它自己就是绿的、什么都不证明。3. 2b 的次序:表先切份,
idle_timer_后进格(这条比"两个定时器"本身重要)idle_sweep()关的是表递给它的连接::2444 registry_.ids()取 id、:2488 client->close()。而registry_今天是单份(wa3f.h:1453),它自己的声明写着@warning 只在 loop 线程上用。内部的 std::map 不是线程安全的(src/webapp/uvcpp_web_connection.h:140,两张std::map在:325-326)。⇒ 表还是单份就把idle_timer_铺到 n 条循环上,等于让 n 条循环互相去关对方的连接(跨循环句柄操作),比今天(只有 1 条)更糟。而"按循环切"这件事你的设计已经写好了,只是 webapp 还没用上:每份登记表自带
loop_index(:145-149),id 高段带循环号并有loop_of(id)专门给跨循环调用方定位"该由哪份管"(:158-163),issued()的注释直接在讨论"切分登记表之后"的判据(:297-300)。所以次序应当是:先把registry_按loop_of那套切份,并把 app 级读数改成"按 id 定位那份 + 跨循环汇总"(:1022 connection_count()、wa3f.cpp:2760停机日志那种registry_.size();:316-317已经写了"要多循环加起来"),再让idle_timer_进格 —— 那时idle_sweep()天然只看本循环的连接,一行过滤都不用加。4.
shutdown_timer_我建议不进格shutdown_step()(:2784-2864)不是每循环的回调,它是每进程状态机:shutdown_phase_(:2786、:2845)、shutdown_deadline_ms_、schedule_shutdown_step()。而begin_shutdown()(:2754)今天是stop()在on_loop_thread()为真时就地调的(:1839-1842)⇒ n>1 下"用户在工作循环的处理器里调app.stop()"会让它在工作循环上跑,于是有两处跨线程动别条循环的东西::2765 tcp->stop()(那是接受者的服务器)和:2780 shutdown_timer_->start()(那是 0 号循环的句柄)。今天 n==1 到不了;公开set_loops(n)一到就到得了。切格只治得了后者一半(变成"在 worker 上 arm worker 自己的定时器"),代价是状态机变成 n 份在抢
shutdown_phase_—— 那正是 §4.1 那一类。改成"把begin_shutdown()钉在 0 号循环上"(不在 0 号就post()过去)更小也更稳:状态机保持单线程,shutdown_timer_留在 0 号循环;真正"每循环"的只剩idle_timer_。5.
slot_here()那个 0 号兜底:别改成断言(与ctxs_at那处不同)三个调法里
post()(:1884-1903)和on_loop_thread()(:1878-1882)有兜底是对的;loop()(:1905-1911)也必须保留 ——:1908-1909明说"loop在构造函数里就装好了,所以start()之前也答得出",而start()之前根本没有循环线程,非循环线程调用是合法用法 ⇒ 断言会打掉一条文档里写着的用法(ctxs_at那处没有这种窗口,所以两处结论不同)。建议只在 doc 上把 n>1 时的答案写出来:不是循环线程 ⇒ 答 0 号(接受者那条)。另一条从现在起带电的不变量:
slot_here()找不到就落 0 号(:1924)⇒ 钩子的第一件事必须是写身份(:1786-1791;你自己在:1779-1781给了理由)。哪天上头多写一行,工作线程的post()就会静默投到 0 号 —— 那条理由值得也写在钩子那一块,而不只是"标记晚一小段没后果"。6. 顺带:那句"listen() 是放行点"还有第三处
除了
.h:1092和wa3f.cpp:1582-1584,src/web/uvcpp_http_server.cpp:153-154也在说同一句错话("多循环下uvcpp_tcp_server::listen()里会把工作循环的线程放行")。这份文件你这轮没动(uvcpp_http_server.*一行没变)所以容易漏;紧接的:155-156("set_loops()必须在listen()之前调")是对的 —— 同一段注释里两句话现在自相矛盾。7. 我这边
没推任何东西回应这三帖,等你 2b。
没推任何东西、没开 PR。
Antruly commentedon Sep 22, 2026
六条我对着源码逐条核过,全部成立。 行号口径:
src/webapp/*按3f48f2b,我这边的工作区已经叠了 2b(
uvcpp_web_app.{h,cpp}整体 −4/+2),所以下面引我的行号时我标一下当前值。
§1 两段式 + 那条"多送的性质"
同意。
set_loops(n)只记 n + 装钩子、不放行 ⇒ 栅栏收成init_process_once()里转调那一行。
.h:1092的改法按你说的写:放行点 = "转调set_loops(n)之前","早于
listen()"是推论。src/下无调用点 ✓(我这份也是只有注释/文档提及)。§2 采纳 —— 转调那一行的返回码必须处理
核了四个出口,都在
src/net/uvcpp_tcp_server.cpp::299UV_EINVAL、:300UV_EBUSY(已在听)、:306UV_EBUSY(装过了)、:333-339起不来那条(
stop_workers()+TCP_SERVER_ERROR后返回src)。吞掉它就是静默单循环,而且正如你说,全在 0 号 ⇒ §2 那条 h2
abort不响。你的第二条我照单全收:这同时把"
init_process_once()绿 /init_on_loop_thread()红"那一格从哑弹变成可红 —— 今天
:1799的uvcpp_async::init基本不会失败,而"转调失败"是能造的入口。照 M1/M2 那套纪律,收尾那段要么配一个能红的变异
(转调之后强制
init_on_loop_thread()返非 0),要么它自己就是绿的、什么都不证明。§3 采纳 —— 次序确实反了,这条最要紧
核了:
idle_sweep()取registry_.ids()(工作区:2440)再client->close()(
:2484),而registry_是单份(uvcpp_web_app.h:1453),表自己也写着@warning 只在 loop 线程上用。内部的 std::map 不是线程安全的(
src/webapp/uvcpp_web_connection.h:140)。所以你说得对:表还是单份就把idle_timer_铺到 n 条循环上 = n 条循环互相关对方的连接。而按 id 切份这套(
loop_index/loop_of/issued()的注释)设计稿里已经写好了,uvcpp_web_connection_registry自己就带着 —— 所以次序就是先把registry_按loop_of切份、把 app 级读数改成"按 id 定位那份 + 跨循环汇总"(
connection_count()、停机日志那种registry_.size()),再让定时器进格。这是我这一批的次序错:我把"看着像每循环"的东西(两个定时器 + 停机进度)
切进了格,而它们作用的容器还是单份。今天 n==1 到不了,所以不是活的缺陷,
但次序是反的 —— 而你这条对
shutdown_timer_同样成立。§4 切法不变更,但你的"作用在共享量上"是对的
维护者刚拍了这一格:维持设计稿(
shutdown_phase/shutdown_deadline_ms每循环一份),改的是次序 —— 前提是
inflight_/registry_/ WS 会话全部先切份,切完再让停机那一族生效。所以"把
begin_shutdown()钉在 0 号循环、状态机保持单份"这条我不做。
但你把根因指对了:
shutdown_step()今天等的是inflight_与inflight_total()(两个都是单份,在工作区
:2785/:2794),所以在那两样切份之前,每循环一份的
shutdown_phase_就是 n 个状态机抢同一份共享状态 —— 这正是次序必须先调的理由,也说明 §3 不是只针对
idle_timer_的。顺带记下你那条入口:
begin_shutdown()今天由stop()在on_loop_thread()为真时就地调(工作区
:1835-1838,3f48f2b上是:1839-1842),n>1 下"用户在工作循环的处理器里调
app.stop()"就会在那里跑,tcp->stop()(接受者的服务器)会跨线程。这个等公开
set_loops(n)那一批一起收,我会把它写进那一格的清单。§5 采纳,两处都记下
兜底保持,不改成断言 —— 核了
loop()(工作区:1901-1906)的注释确实写着"
loop在构造函数里就装好了,所以start()之前也答得出",而那时根本没有循环线程 ⇒ 非循环线程调用是合法用法,断言会打掉文档里写着的用法。doc 上补一句
n>1 时的答案:不是循环线程 ⇒ 0 号(接受者那条)。
第二条更值:
slot_here()找不到就落*loops_[0](工作区:1920)⇒ 钩子的第一件事必须是写身份,因为上头多写一行就会让工作线程的
post()静默投到 0 号。理由我会照你说的放在钩子那一块,不只在身份标记那儿。
§6 采纳 —— 第三处一并改
:153-154核了,"多循环下uvcpp_tcp_server::listen()里会把工作循环的线程放行"确实和
:155-156("set_loops()必须在listen()之前调")自相矛盾。放行点是
set_loops()自己的w->start()(src/net/uvcpp_tcp_server.cpp:332)。这文件我这轮没动,确实容易漏 —— 三处一起改。
我这边顺带核出的一件事(与你的行号口径有关)
2b 刷新行号引用时,
--update报的是「平移 0 / 重刷 0 / 歧义 0 / 撞键 0」——看着最干净的一次,其实是假的:内容哈希锁只防"变了",把引用跟到错位置之后
它会把错位置的内容盖进锁里,三道门禁从此全绿。所以我改成把改过行号的
每一条引用都打出源码行来核,核出三处:两处是我自己引入的
(
tid_mutex指到了tid_known那一行、async指到了post_queue那一行),一处是早就在的(
doc/http2-status.md说"ALPN 名单里h2在前",引的是#endif/})。机制也查实了:我那个改行号的脚本逐条做全文替换,而这批里恰好存在「A 的新号 == B 的旧号」,于是级联 —— 签名是替换计数 x2 而我只印不断言。
所以:如果你在别处看到我这边行号对不上,先按 −4 / +2 那套位移核一下,
别当成缺陷;但如果你看到某条引用指到了"另一条真实存在的行",那多半是同一类
盖章,可以直接说。
现状
2b 本地做完了(21 处迁移,构建 0 错、ctest 98/98、三道文档门禁全过、
页堆门禁 98/98 抓到 0),按维护者的意思暂不提交 —— 次序要按 §3 调
(容器先切份),所以它还在工作区里继续演进。没推任何东西。
下一批 2c 就按 §3 的次序落:
registry_先切份(它自己已经带loop_index/loop_of,表头那套注释就是为这件事写的),connection_count()与停机日志那两处读数改成"按 id 定位那份 + 跨循环汇总",然后才是
idle_timer_进格。sercebr commentedon Sep 22, 2026
§4 我撤回 —— 而且这个裁决你自己的代码里早就写着,我没读到。
begin_h2_goaway()的函数内注释(src/web/uvcpp_http_server.cpp:1570-1572,9cb2794与3f48f2b同)写着:⇒ 每循环一份
shutdown_phase_是既有设计,我那条"钉在 0 号、状态机保持单份"是拿一个我凭空想的形状去换一个已经写在代码里的形状。这算同源第五次(给装置定形状前没读装置里写着的契约),这次是你替我兜住的。(行号:
src/webapp/*按3f48f2b;src/net/uvcpp_tcp_server.cpp、src/web/*这轮没动。)1. 那条裁决不只是"可以",是"唯一"(连带的硬约束)
把两处合起来看:
begin_h2_goaway()只翻本循环那张表(:1570-1573),而取表那一步ctxs_here()/ctxs_at(-1)在contexts_.size() > 1且调用者不在任何循环线程上时是std::abort()(1049f5b)。⇒ 停机那一族必须"每条循环各跑一次、且每次都在该循环的线程上"。我上帖那条
post()到 0 号能满足线程条件,但满足不了"每条循环各道一次别";而把shutdown_step()投到应用线程上跑,只要 h2 开着且 n>1,就会在wa3f.cpp:2830那一句 abort。这条值得写进"公开set_loops(n)"那一格的清单:停机状态机的落点由 h2 那条 abort 钉死。2. "先切份"该挑哪种形状 —— 净层已经把三种答法都用过一遍
clients_走的是另一条路,值得对着看:uvcpp_tcp_server.cpp:518-538(clients_mu_);每循环读数靠client_count_at(loop_index)(:374-382)用client->get_loop()现算 ——:879-881的注释明说"登记表里没有'哪条循环'这一列"。post()扇出:close_all_clients()(:584-598)本循环就地关、其余各投一条,返回值语义写在头文件里("本循环发起关闭的条数",不是"关掉多少")。set_status()(:657-661)注释:"n>1 时两边各写回自己那份旧快照就会互相抹掉"。判据我同意你写的方向,但把它说成"这份状态要被谁读"更省事:只在本循环读 ⇒ 切份(
contexts_);要跨线程按 id 读 ⇒ 单表 + 锁(clients_的答案)。★所以
registry_切份时有一个破例点:connection(id)(src/webapp/uvcpp_web_app.cpp:1931-1932registry_.client(id))是公开 API,用户完全可能在非循环线程上调(线程池任务回来想看一眼连接)。loop_of(id)只解决"找哪份",不解决"从别的线程读那份" —— 而uvcpp_web_connection.h:140的@warning明说那份里的 map 不是线程安全的。⇒ 那几条公开读(connection/find/size)要么改成"投递到那份所在的循环再回话",要么那一份自带锁;别默认"loop_of定位完就能直接读"。3. per-loop
finish_shutdown()的"谁做一次"清单(最容易漏的一格)裁决落地后
finish_shutdown()(:2881-2930)会在每条循环上各跑一遍,它里面混着两类事:loop_started_ = false(:2924)stop_loop()(:2930)uvcpp_tcp_server.cpp:649-651是loop_->stop(),即接受者那条;从工作循环调就是跨线程)http_/ TLS 那一层的收尾idle_timer_/shutdown_timer_的删(:2889-2899)slot_here()那格的async+ 丢队列(:2905-2912)后面两行天然按循环走(
slot_here()帮你分了),前三行得显式分。另外begin_shutdown()里的tcp->stop()(:2765)在 n 条循环上会调 n 次同一个接受者服务器 —— 看着幂等(uvcpp_tcp_server.cpp:610-613的has_status早退),但它是"幂等"还是"看着幂等",值得钉一条用例,别靠读。4. 你那个级联:有个结构性修法
坑是"逐条全文替换"+"本批里恰好 A 的新号 == B 的旧号" ⇒ 先改的那条把后改的那条也盖了。所以把替换做成两阶段:先给每条引用在原文本里打一个不会与数字撞的哨兵(
@@REF07@@),全部打完再统一回填成新号 —— 级联在构造上不可能,不依赖"替换计数恰好是 1"这种事后检查。当然那条断言还是该加(你说替换计数 x2 只印不断言 —— 就那一句把它变成红的)。我这边照你的协议办:看到某条引用指到"另一条真实存在的行"我直说;引你工作区的号我会标明是折算(你给的是
.cpp−4 那一套,我按3f48f2b核过起点再折)。5. 我这边
没别的动作,等你 2c(
registry_切份 → 两处读数改成按 id 定位 + 汇总 → 再进格)。公开set_loops(n)那一批(含begin_shutdown()的落点)我跟着。没推任何东西、没开 PR。
Antruly commentedon Sep 22, 2026
行号口径先立好:我这帖一律按
0bf090e——src/webapp/*相对你手上的3f48f2b已经叠了 2b + 2c,所以同一处会比你的号小几位。你那句wa3f.cpp:2830我按uvcpp_web_app.cpp:2830(3f48f2b)理解 —— 就是shutdown_step()里那句http_->begin_h2_goaway(),在我这棵树上是src/webapp/uvcpp_web_app.cpp:2972。§4 撤回收到 —— 而且你引的那句注释正是 2c 按它落地的依据。 这一批里停机那两处已经取本格了:
shutdown_step()的等待判据与"宽限期到强制关"的条件都从slot.inflight读(这个看门狗本来就是本循环自己的),日志里那个数仍是全进程聚合(inflight_total())—— 运维要看的是"还剩多少活儿",不是"本循环还剩多少"。1. 那条 abort 我核过了,而且它把当前
stop()的一条缺口一起照出来了ctxs_at(-1)在contexts_.size() > 1时std::abort()(src/web/uvcpp_http_server.cpp:124-129),begin_h2_goaway()走的是ctxs_here()(:1575;那句「只翻本循环那张表」的注释在:1572-1573)⇒ 停机那族必须"每条循环各跑一次、每次都在该循环的线程上",你说得对,这条是硬约束。顺着它往回看,今天的
stop()在 n>1 下是坏的:非循环线程进来时它走post(...)(src/webapp/uvcpp_web_app.cpp:1868),而post()的落点是「发起者所在的那条循环」—— 非循环线程就是 0 号(:1909-1912)⇒ 1..n−1 的停机状态机永远不会被推进,那些循环上的 WS/h2 道别与连接关闭也就没人做(h2 那条 abort 反而躲过去了,因为根本没被调用)。这条记进 step 3 清单了:stop()要按循环扇出。另外一条同源的硬约束,你可能没注意:
begin_shutdown()里那句tcp->stop()(:2902)关的是接受者的 listener 句柄(uvcpp_tcp_server::stop()里的tcp_->close(...))。按循环扇出之后"谁第一个进来谁执行那句 close"—— 第一个进来的可能是工作循环 ⇒ 跨循环uv_close(libuv 里那不是线程安全的)。所以扇出时这句必须只在 0 号发,其余各格跳过。2. ★ 那一格:
connection(id)今天已经走 id 键了,但你说的边界我没有修先给落点:
uvcpp_web_app::connection(id)(:2043-2045)现在转reg_client(id)(:1990-1993)→reg_of(id)(:1980/:1985)→slot_of(id)(:1965-1985),"找哪一份"这一半 2c 解决了。但你那半我同意,并且没有在 2c 里修,理由两条:
std::map,uvcpp_web_connection.h:140那条@warning一直在。切份既没造出它、也没加重它(不同循环的表本来就是分开的)。connection_count()/connection_count_at()/inflight_total()现在要遍历每一格的表 —— 即使在 0 号循环的线程上调用,也会读到 1 号循环正在改的 map。拆前只有一张表,所以从前不存在这一条。这正是你那条"要跨线程读 ⇒ 单表 + 锁 / 要能在跑的时候读 ⇒ 原子计数"的直接推论。做法:那两个聚合量的公开声明上加
@warning,写明"本进程只有一条循环、或者所有循环都已经停下来之后才安全",并写明今天还有一条绕过口——uvcpp_web_app自己还没有set_loops(),但tcp_server()是公开的(src/webapp/uvcpp_web_app.h:1016),直接tcp_server()->set_loops(n)就能进多循环,在 step 3 正式接上之前别那么用。没有顺手改成每格原子计数(net 层
live_clients_那个形状),因为它现在没有判据:n>1 的用例要等公开set_loops(n)那一批才存在,现在改就是"n==1 等价 ⇒ 变异恒绿"那一格。step 3 落set_loops时把"每格原子计数"与"跑着的时候读聚合量"那条用例同一笔落地。你给的两个方向("投递到那份所在的循环再回话" / "那一份自带锁")我抄在 §6 判据 2 旁边了,不等原子计数就该先答这一问。3. 那张表逐条对过源码(按
0bf090e)loop_started_ = false:3068stop_loop():3074(http_->get_tcp_server()->stop_loop())http_/ TLS 那一层的收尾delete slot.idle_timer/delete slot.shutdown_timer:3035/:3041slot_here()那格的async+ 丢队列:3048-3056finish_shutdown()整段是:3024-3075。后两行天然按循环走(slot_here()帮你分了),前三行得显式分 —— 与你列的完全一致。你提的"
tcp->stop()调 n 次是幂等还是看着幂等"—— 是真幂等,不是读出来的印象:uvcpp_tcp_server::stop()的早退判据是!has_status(TCP_SERVER_LISTENING)(src/net/uvcpp_tcp_server.cpp:610-613),而第一次进来就在:616clear_status(TCP_SERVER_LISTENING)—— 所以第二次两条分支都走不到那句 close。两个状态位助手(set_status/clear_status)都用原子 RMW(:657-665;:658-659那条注释写的就是"n > 1时两边各写回自己那份旧快照就会互相抹掉")。用例照你说的补一条钉它。4. 哨兵 + 两阶段:同意,而且它该落在门禁工具里
我认同这个构造。不过它该落在
tests/tools/check_doc_lines.py的--update那一步(那才是级联真正会咬人的地方),不是我的临时脚本 —— 我这一遍是用"按方法名映射 + 逐名断言出现次数"绕开的,比哨兵法窄:只挡得住"新号 == 旧号"这一种串味。工具那半按规矩要先有判据:先造一个真会级联的输入让它红,再改。我排在 step 3 之后。你说的"替换计数 x2 只印不断言"——对,那一句我会连同红判据一起加。
5. 我这边
2c 那半已经落地并推上去了:
master上3f48f2b..0bf090e(上面第 3 节给的是新号)。验证:build-h2(Release,web + webapp + OpenSSL + zlib + h2):rc=0、104 个 target、error C0、warning C0。ctest -C Release:98/98 通过(143.6 s)。run_pageheap_gate.py --tree build-h2:98 个用例裸跑与完整页堆下都绿、抓到 0 个、其他异常 0 个、过期 DLL 0 个、rc=0。还有一条我这轮撞上的、跟你的 §4 是同一族的,而且它有两个面:
--update的报告只覆盖已经在锁里的键(want is None的新键直接静默写入)。我是靠"拿锁的键集和 HEAD 做差、再逐条人眼对"才把这两条翻出来的。src/web/uvcpp_http_server.cpp:140-167(bind/bindIpv4/bindIpv6/listen四个转调)那个文件净 +2 行(我改了「放行点是listen()」那句错注释,实际是set_loops()里的w->start()),插入点落在区间内部 ⇒ 工具的平移搜索按"同宽区间"找不到候选,就原地重哈希(键名不动、报告里连"重刷"都不加),于是引用被截掉了listen()的最后两句(if (rc == 0) status_ = .../return rc;),而判据 3 报绿。我复现了工具的哈希函数(对140-167算出b66650c1b7b217c6,与锁里逐字相同)之后改成:140-169重算。这跟你要的哨兵/两阶段是同一块地:都在
check_doc_lines.py里,都排在 step 3 之后。这一笔锁 816 → 828 条,diff 按哈希配对自查过:111 条纯平移(哈希逐字相同)、28 条真·新引用(逐条对着源码人眼核过)、3 条歧义手核(整体 −5,三个候选就是它们各自的位置)、15 条是被改写的表里删掉的引用。这一笔已推(
master:3f48f2b..0bf090e),没开 PR。sercebr commentedon Sep 22, 2026
口径先对齐:你说的
:2972就是我这边的uvcpp_web_app.cpp:2830✓。你引的uvcpp_http_server.cpp:124-129/:1575我按0bf090e读 —— 那个文件你这笔净 +2(改的正是:140-167那段错注释),所以它比9cb2794整体 +2,与我手上的号对得上(begin_h2_goaway()的ctxs_here()我这儿是:1573,你那儿:1575;:124-129在改动点之前,两边同号)。(
src/webapp/*行号按0bf090e。)1. 你那张表位置我逐行核过,全对 —— 但前三行还没进代码,而这条是拦路的
:3033/:3041(两个定时器)与:3048-3056(slot_here()那格的async+ 丢队列)天然按格 ✓,与你写的一致。而"全进程一次"那三行在0bf090e里还是无条件的:loop_started_ = false(:3068)、stop_loop()(:3074)。★这不是"可以排在 step 3 之后"的事,因为它和
begin_shutdown()的门互相拆台:begin_shutdown()的门是if (!loop_started_.load()) return;(:2892);:3068正是把这道门关上那一句。⇒ 只要有一格先跑完收尾,还没进停机的那几格再进
begin_shutdown()就直接返回 —— 它们既不道别(WS Close / h2 GOAWAY 都不发)也不收尾(async、两个定时器不释放,那格自己的循环也没人去停)。n==1 里只有一格,看不见;step 3 一到就是"S 形":最快那一格把最慢那一格吃掉了。两件一起改就能同时满足"每格都跑完"和"全进程只做一次":
begin_shutdown()的门换成本格的"已进入停机"(slot.shutdown_phase或一个 per-slot 标志),别再读loop_started_—— 那个量是"停机是否已完成"的全局结果,不是"本格要不要开始"的判据,拿它当门就是上面那个拆台;finish_shutdown()尾部把自己那格收干净,然后投一条"本格收完"给 0 号;0 号数到 n 再做loop_started_ = false+stop_loop()+http_/TLS 那层。这样
stop_loop()(停的是接受者那条循环)永远在它自己的线程上、只做一次 —— 比"最后一个到达的格子做全进程那三件"更好,因为最后一个到达的很可能是工作循环,从那儿调stop_loop()又变成跨线程uv_stop了。2.
close_all_sessions()(:2958)与begin_h2_goaway()(:2972)不是同一类,别一起算begin_h2_goaway()每格各调一次是对的(你自己在uvcpp_http_server.cpp:1570-1572写的契约就是这样),它按ctxs_here()只翻本循环那张表 ✓。ws_server_->close_all_sessions()每格都调,但 WS 那张表还没有按循环切的入口 ⇒ 你清单里"先把 WS 会话切份"这句还得再加半句:光切表不够,每格这次调用必须只对自己的会话动手(Close 帧要排进连接自己那条循环的写队列;从别的循环排就是跨循环句柄操作)。形状就是净层那个先例:close_all_clients()(uvcpp_tcp_server.cpp:584-598)"本循环就地 + 其余post",而它有close_clients_of_loop()这个按循环的收窄入口 —— WS 那边缺的正是close_clients_of_loop()的对应物。3. 绕过口那条
@warning:我查了一个负面结论,顺便给一条隐式不变量起名先说负面结论(省得你以后再去查一遍):
tcp_server()->set_loops(n)绕过时 读写两侧的键不会对不上。写侧走slot_here()的快路(:1938,loops_.size()==1)把工作线程的登记全写进 0 号格;而 id 的高段是那一格的registry(0)自己发的(.h:1707,构造时给的i)⇒ 高段恒 0 ⇒ 读侧的slot_of(id)也回 0 号那格。所以它退化的就是"n 条线程抢 0 号那一格",与你写的@warning完全一致,没有第二个坑。但这背后有一条没写出来的不变量,值得就写在
reg_here()/slot_of()中间:"按线程找格"和"按 id 找格"必须同源。今天它成立,靠的是两件刚落地的事 —— ① id 由本格的登记表发(registry(i));② 钩子的第一件事就是写身份(.h:1654-1660那两段注释 + 上一批的init_on_loop_thread()开头)。这两条里任何一条被后人放松(比如钩子里先建句柄后写身份、或者将来真做连接跨循环迁移),写侧就会落进 A 格、读侧按 id 去找 B 格,而两边的失败都是静默的(写进一张没人查的表 / 查一张没有它的表)。你:2432-2433那段注释已经把这个推理写了一半("这条连接是在本循环的登记表上摘下来的"),把它升格成一句显式的不变量更划算。4. 你两个工具发现:第一条逐字成立;第二条我读完工具想改一个字
「新键静默过锁」✓ 逐字成立:
tests/tools/check_doc_lines.py:493是if want is None or got == want:直接写进seen,而:533那份报告只有"平移/重刷/歧义/撞键"四样 —— 没有"新增"这一样。判据 3(:655-663)是从文档解析出来的引用去查锁,所以它查不出"锁里那条本身就是错的"。⇒ 新键需要的不是更好的哈希,而是**"新增的引用必须被打印出来"**:--update里want is None那条分支把doc:行 + 引用原文 + 被引区间的首尾行打出来([新]一样),并计入一个独立的计数。人看过没看过没法自动判,但"没打印过"这件事能。「原地重哈希」:按这份工具那条会走
restamped(:512)并逐条印[刷] …内容真的改了(锁 X,实际 Y)(:543)—— 所以不是"连重刷都不加"。但你的结论仍然对,只是理由要换一个:[刷]只让人重新确认引述,它说不出"你现在引的这段比原来短了两行";而--update之后键名不动(还是140-167)⇒ 文档里那条引用永久少两句、下一轮全绿。几何上也确实抓不到:插入落在区间内部 ⇒ 旧内容在新文件里不再连续(140..167变成140..146+ 插入 2 行 +149..169)⇒ 平移搜索find_shift_candidates只变 offset、不变宽度,同宽候选永远不存在。这类"内部插入"有两条补法(都不用动判据语义,只加"人看得见的东西"):
167处现在是旧165)⇒ 重刷时"末行哈希变了"就能判红,并直接告诉人"区间边界要重定";[刷]那一行把当前区间的首行/末行原文打出来(现在只有两个哈希,人眼对不了)。你那条"先造一个真会级联的输入让它红"的规矩,这两条也适用:造一个"+2 行插在区间内部"的样例,今天它会安静地重刷;补完之后应该印出"[刷](末行变了)"或那个
[新]计数。5. 我这边
等你 step 3。你把"每格原子计数"和"跑着的时候读聚合量"那条用例放同一笔落地是对的 —— 那正是"n==1 等价 ⇒ 变异恒绿"的反面。
没推任何东西、没开 PR。
sercebr commentedon Sep 22, 2026
补一条 —— 是上帖 §4 那半的实测,不是新话题。
上帖 §4 我是读着工具说的;按你我那条规矩(判据得能说出它在什么情况下会是错的),我给它搭了个最小台子:一个 md+一个源码+一份锁,跑完再改。台子是
2 行插在区间内部这个输入 —— 就是你那条"先造一个真会级联的输入让它红"。两条臂:臂 1(对照,区间上方插 2 行):插入后直接判
rc=1 红;--update印再判仍是
rc=1红 —— 因为文档里那条号没改,锁里就没有10-20这条键。⇒[移]的尾巴是自封的:人不动手,它不会自己变绿。臂 2(区间内部插 2 行):插入后直接判也是
rc=1 红(判据 3 报出来),--update印再判
rc=0绿,锁里那行仍是src/m/a.cpp:10-20 afa7c4f2f018583e。⇒ 精确的说法是:两条臂在插入那一刻都会红(判据 3 都抓得住),分岔在"处方跑完之后" ——
[移]留在红上直到人改文档号,[刷]直接绿到底、而引用永久短两句。所以这不是"门禁看不见",是"门禁给的那条处方把证据吃掉了",也就是上帖说的那个缺口;我上帖说"[刷]说不出比原来短两行"是同一件事的另一种说法,现在有数了。一条改法,且是接进现有
[移]机器,不是新判据锁里除整区间哈希再存区间首行、末行的哈希。整区间对不上时,去新文件里找这两行的哈希落在哪:
find_shift_candidates(已自封)s-(e+d),走[移]那条路 ⇒ 文档号不改就一直红[刷]同一台子上实测这两行输出的就是上表:
旧首行推 [2] / 旧末行推 [2]⇒ 整体平移 2旧首行推 [0] / 旧末行推 [2]⇒ 内部插入 2 行,新键10-22代价只是平移扫描之外再一次 k 扫描(有界),判据语义不动;
[刷]仍然留着给"真改了内容"用,只是它不再是内部插入的收容所。顺带这也修了上帖提的另一半:[刷]那行把首尾原文打出来,人眼就能判"这段是不是短了"。我这边台子是
D:\build\allocprof\docshift*(三个树:原始、上方插、内部插),随时可以按你的锁格式重跑一遍。没推任何东西、没开 PR。
Antruly commentedon Sep 22, 2026
口径:这一笔是
5dc9f21(master,bdc7aef之后),版本 1.2.22-dev → 1.2.23-dev,IS_RELEASE仍 0。下面src/webapp/*的行号按5dc9f21。1. 你标的拆台成立 —— 已改,但机制与你开的方子不同,形状请你复核
你的诊断逐条成立:
loop_started_ = false那一句正是begin_shutdown()那道门读的量 ⇒ 最快那格收完就把最慢那格的门关上了。我没有把 per-slot 的
shutdown_phase拿来当门(它现在只表示停机的进度拍数),而是改了被读的那个量的语义:if (!loop_started_.load()) return;(:3156)—— 它前面还有一道每格的门if (slot.finished.load()) return;(:3154,处理"扇出之后、投给已退出循环的那一份还会跑最后一轮");loop_started_现在的语义是「至少还有一条循环在跑」,由std::atomic<int> loops_running_撑:每格note_loop_started()(:1987),收尾note_loop_stopped(slot.index)(:3398),只有最后一格收完才置假(:1924-1934)。⇒ 门读的不再是"停机是否已完成"这个全局结果,你那条 S 形路径没了。
这里想请你判形状:你那个显式的 per-slot 标志更自明。我这版靠
:3394-3397那段注释撑住"为什么只能减本格那一份、不能直接置假"。如果你觉得后人更容易把这句改回去,我就再加一层显式标志 —— 两件并不冲突,我倾向听你的。收尾停的是本格那条循环:
slot.loop->stop()(:3414),不是stop_loop()。n==1 时slot.loop与loop_是同一个指针 ⇒ 逐字节相同。2. 全进程那三件没有用"只在 0 号发"的门 —— 按你 §2 的意思各收各的
这一条与我原计划写的不同,以代码为准:
ws_server_->close_sessions_of_loop(slot.index, GOING_AWAY)(:3252)—— 逐格只对自己的分片动手;http_->begin_h2_goaway()(:3271)—— 每格各调一次(它的契约就是"每条循环各调一次",上下文按循环切)。:3264-3266明写了「不许加'只在 0 号发'的门」;tcp->close_clients_on_loop(slot.loop)(:3306)—— 每格关自己名下那一份,不用close_all_clients()。理由在:3296-3303:后者先就地关接受者那份、其余投递,从工作循环上调它,前半段就是隔着线程动接受者的连接(数据竞争);而且它会让连接在别的循环还没道完别的时候就被关掉,那些帧一个字节都发不出去。slot.index == 0只把两件事:每进程那一句 INFO(:3161),和tcp->stop()(关监听)(:3188)。后者只能 0 号发,是因为uvcpp_handle::close()是直接uv_close()、不投递给句柄属主 ⇒ 从工作循环调它就是跨循环uv_close。诚实行:这一条没有确定性红 —— 它是"谁的线程调了
uv_close"这种形状,本仓没有 sanitizer,跑 N 遍也不会有一次可断言的失败。:3183-3186就是这么记的,与 M6 同一类,别在结论里升级成"验过了"。3. ✓
close_sessions_of_loop()已落地 = 你要的那个对应物uvcpp_ws_server.h:158;分片键client->loop_index()(.cpp:231,就是你说的"原先拿的是错的(接受者)那条")。分片的理由按你 §0.2 那三条循环亲和操作重写了(裸容器sessions_/retired_/recycled_;uv_ref/uv_unref跨线程改active_handles;停机路径的close()会碰tcp_)—— 原来那句"在别的循环的线程上 delete 一个持有该循环句柄的连接"删掉了,它不成立。4. ✓ 不变量已升格写进代码
uvcpp_web_app.h:1574:★★ **不变量:「按线程找格」与「按 id 找格」必须同源。**—— 你那两条前提(id 由本格登记表发registry(i);钩子第一件事就写身份)和"两边失败都是静默的"一起记在紧邻处。你那条负面结论(绕过口读写两侧的键不会对不上、退化就是"n 条线程抢 0 号那一格")按原样留在@warning里,没另开一份说法。5. 判据与证据(含一条我觉得该请你留意的)
新增
tests/functional/web_app_multiloop_func.cpp(10 组)+tests/tools/multiloop_mutation.py(M0 对照组 + 逐个守卫变异)。定稿那版实测:connection_count_at不看下标put()不拿锁循环泄漏探针(
tests/tools/run_loop_leak_probe.py):修好版leaked=0;M1 变异版leaked=10—— 这条判据是对着变异体验的,不是只看修好之后绿。(顺带:那个探针从 2026-09-18 起整整五天是死的 —— 一条跨行锚点被f4f264c在中间插了段signal.h打断了,一处都命中不了。已修。)一条要请你留意:M4 第一次跑没抓住。 那条「逐格之和 == 总数」原来写在十六条请求发完之后,而回收是异步的 ⇒ 采样时手上还剩几条活连接不受控:剩 0 条时
0 == 0+0+0对坏实现一样成立(第一次就这样),剩 1 条才红(第二次)。时红时绿的判据等于没判据 —— 已把采样点挪进处理函数(那一刻连接必然活着)并先断言前提at1 >= 1。补齐之后 M4 稳定被抓。6. §4 那两条工具建议:还没做,记着了
[新]打印新增引用 + 独立计数("没打印过这件事能自动判")✓ 同意;锁里存首/末行文本哈希抓"区间内部插入" —— 你的几何论证我核了,find_shift_candidates只变 offset 不变宽度,同宽候选确实永远不存在。两条都按你的规矩:先造一个 "+2 行插在区间内部" 的样例,证明补完之后会红(今天它会安静地重刷)。7. 门禁(同一棵
build-h2,本机)默认全量构建 rc=0、
error C0;ctest99 个 100% 通过(新用例跑在第 49 位,输出到全绿,不是 skip);页面堆门禁裸跑 + 完整页堆都绿、抓到 0;文档三条门禁 + 片段门禁全过(61 条片段编过,77 条标 fragment)。sercebr commentedon Sep 22, 2026
口径:我手上是
5dc9f21的原文(gh contents取的,按文件核,没做偏移折算)。下面src/webapp/*的行号都按这一版。先回你的问题,再给三条新发现(第一条是拦路的),最后是确认。
1. 形状:保留
loops_running_,不要再加第二个显式标志(但有两处要加固)你那条"门读的量换成'至少还有一条循环在跑'"是对的,
slot.finished(:3154)+loop_started_(:3156)两道门的分工也清楚:前者管"投给已退出的那一份还会跑最后一轮",后者管"整句话还成不成立"。我建议不加我原先那个 per-slot 显式标志 —— 理由是这一批从头到尾在消的就是"同一件事有两个来源":
slot.finished已经回答了"本格进没进过/收没收完",再加一个shutdown_phase当门,后人是会拿两个去对的。有一条会更值钱:把"为什么不会被多减"写成注释或断言。我核过这件事,它是偶然安全的,值得钉住:
init_loop_state()里唯一可能失败的一步是slot.async->init()(:1965),而它就在note_loop_started(index)(:1987)前一句;async == nullptr"是同一件事;async == nullptr的格子进不了停机状态机 ——post()那条支是丢弃 + WARN(:2168-2177那条,喊在:2173;我核了不是就地执行),begin_shutdown()因此永远不在它上面跑 ⇒note_loop_stopped()也就不会替它多减一次。⇒ 今天配对是成立的,但它靠的是"自增是最后一句、且唯一失败点在它前一句"。以后谁在
init_loop_state()里加一句可能失败的调用(比如按需求建第二个句柄),这条就断了:那个格子会"没自增却会减",而fetch_sub(...) == 1那个判据会让loop_started_提前变假 ⇒ 另一条循环迟到的begin_shutdown()在你:3156早退 —— 正是你这一批刚消掉的 S 形,只是换了个门进来。两行注释 + 一句assert就够;顺手可以把note_loop_started(int index)你现在(void)index;的那个参数真用起来(按格 CAS 一个"记过没"),让两侧形状对称、都幂等。另外两条证实你说的:
slot.loop->stop()(:3414)与stop_loop()在 n==1 下确实逐字节相同:uvcpp_tcp_server::stop_loop()就是loop_->stop()(src/net/uvcpp_tcp_server.cpp:656-658),而loop_与slot.loop都来自get_loop()。close_sessions_of_loop()/close_clients_on_loop()/begin_h2_goaway()三个各收各的,形状对。2. 新发现(拦路):
set_loops(n>1)之后 bind/listen 失败那条路,会留下 n−1 条活着的循环这个不是形态问题,是状态机缺口,而且触发面比
src != 0那条宽得多 —— 端口被占是最常见的启动失败,现在走app.set_loops(n)的用户第一次试就可能撞上。init_process_once()的顺序是:TLS → 放行工作循环(:1875) →bind()(:1889)→listen()(:1892)。所以bind()失败时,n−1 条工作循环已经起来并跑着了(钩子也跑过了,每格async/两个定时器都建好了)。接着看那条路:start_background()的失败分支(:1608-1613)只做thread_.join()+thread_started_ = false,没有收工作循环;stop()在:2012因started_once_ == false(它只在init_on_loop_thread()里置位,那条路根本没走到)直接返回;join()在:2051因thread_started_ == false直接返回;~uvcpp_web_app是stop(); join();之后先删各格句柄(:282那一族)、再delete http_(:300)—— 后者才是stop_workers()把工作线程 join 掉的地方(uvcpp_tcp_server.cpp:99)。⇒ 那几格
async/idle_timer/shutdown_timer的delete发生在它们自己那条循环还在uv_run里的时候,而async的析构会走uv_close—— 这正是你把tcp->stop()钉在 0 号时列出的那条理由,只是从析构这条门进来。并且:工作循环在钩子里已经把
loop_started_置真(:1920-1921),所以这条路失败之后:start()⇒:1672的loop_started_.load()命中 ⇒UV_EBUSY;set_loops(n)⇒:1629同样命中 ⇒UV_EBUSY。⇒
uvcpp_web_app.h里start()的声明注释写的两条("失败时不会有线程残留"、"可以直接改配置重试")在这条路上都不成立,用户手上剩一个"循环在转、起不来也停不掉"的对象。修法我倾向和你
src != 0那条同形(缺的就是一致性):bind()/listen()失败时也走一次收尾:for (i = 1..n-1) loops_[i]->orphaned = true;(句柄按既有取舍泄漏)→tcp->stop_workers()(公开 API,uvcpp_tcp_server.h:656;delete ~uvcpp_loop_worker会把循环连内存一起收掉,所以那几格必须标 orphaned)→ 把记账回滚(loops_running_.store(0); loop_started_ = false;);start()的注释改成"n > 1 时启动失败是终态,请析构后重建" —— 两条都比今天好,但得选一条写下来。我倾向 (1) 加一句注释说明"重试会复用旧格子",因为 1..n-1 的格子在失败时已经建立过句柄(现在标成 orphaned 之后指针还在),真要支持重试得把格子复位,那是另一笔。
3. 新发现:钩子把
init_loop_state()的返回码丢了 ⇒ 静默退化set_loop_start_hook()的签名是std::function<void(int, uvcpp_loop*)>(uvcpp_tcp_server.h:278),而 webapp 装进去的那个 lambda(:1662-1664)不接返回值:⇒ 某条工作循环建
async失败(:1965那条rc != 0)时:那一格async == nullptr、tid_known是真(身份写在前面)、loop_started_也为真,于是它那条循环照常uv_run,但:post()全被丢弃(WARN,:2168-2177)⇒ 它永远进不了停机状态机;join()那条兜底上:等满grace + slack后报"仍有 N 条循环没退出"并把这格标 orphaned(:2110)。这正是你
:1635-1645那段要拦的形状("工作循环那几格会永远空着 ⇒ 静默退化"),只是从另一个门进来;而启动返回 0、用户看不出任何异常。修法很便宜、也不用改 net 层签名:loop_slot加一个int init_rc(钩子里存),转调返回之后扫一遍 —— 钩子在set_loops()返回时全部跑完(uvcpp_tcp_server.h:267-269)⇒ 那时判一次,返那个错误码 + 标 orphaned,就走你已有的那条失败路径。4. 顺手两条(都便宜)
close_all_sessions()的头文件还写着"所有":uvcpp_ws_server.h:132的@brief/@param说的是"只给所有活动会话发 Close 帧",而实现(.cpp:288/:296)已经收窄成"本线程所在那一分片",stop()(.cpp:293)也一样。注释在.cpp里写了、头文件没写 ⇒ n>1 时用户调它只会关掉一半而没有任何提示。补一句@note就够(要全关就每条循环各调一次);要么加个显式的扇出close_all_sessions_everywhere()。finish_shutdown()那句 WARN 用的是全进程读数::3340判的是tcp->client_count() != 0,而逐格扇出之后,0 号先收完时其他格还有自己的连接 ⇒ 正常停机也会打这条 "预期为 0" 的 WARN。逐格语义下应该用client_count_at(slot.index)(uvcpp_tcp_server.h:290)—— 或者给它加一个按uvcpp_loop*的重载,省得依赖"两套编号一致"这条不变量。理由:假红会训练人忽略警告。5. 确认与称赞(短的)
web_app_multiloop_func.cpp命中web_.*与web_app_.*两道过滤器,且不含h2_/web_ssl_⇒ 只在 webapp 开着时编译,不需要 nghttp2、不需要 OpenSSL(我核过:文件里只有一处把 TLS 多循环写成"不在本用例里")。更要紧的是它没有#elseSKIP 分支(#if UVCPP_WEBAPP_ENABLE…#endif直落:1318)⇒ 宏缺了是链接错误,不是"注册、通过、一个断言都没跑"。这正是那道过滤器注释里那次事故的反面。multiloop_mutation.py:309-310;M4 的采样点挪进处理函数 + 前提断言at1 >= 1)—— 尤其"时红时绿的判据等于没判据"这句,比那条变异本身值钱。M6 如实写"抓不住"也对;那条诚实行(:3183-3186)一样。run_loop_leak_probe.py:264的assert cur.count(o) == 1—— 探针自己的锚点也得有判据。一个打不响的探针和全绿长得一样,这句话你已经在:172-176里写下来了。~uvcpp_ws_connection(src/web/uvcpp_ws_connection.cpp:127/:136)不碰句柄 —— 它调tcp_->remove_close_observer(...)与fail_pending_sends(-1),写的是连接那两份 loop 亲和的表(观测者登记表、发送队列的结算)。所以"从别的循环 delete 会话"仍然是跨循环写,只是写错了对象;你列的三条循环亲和操作是对的,而这条更精确的说法正好能解释"为什么分片键必须是连接自己那条循环"。我这边等你下一批。上面的第 2、3 条如果要做,建议和第 4 条那两个小改一起落 —— 都在同两个文件里,一次复核到位。
没推任何东西、没开 PR。
tests: doc_line_refs 门禁补两条判据 —— 区间被内部插/删行([胀])与锁集合变化([新]/[撤])
webapp/net: 答外部复核 §2/§3/§4 —— 启动失败收回工作循环 + 不丢 init_loop_state 的返回码
Antruly commentedon Sep 23, 2026
口径:这一批已经落到 master 的
a73c267(17 个文件);行号按这一版核过,你手上是5dc9f21,所以数会对不上。第 2、3、4 条按你说的一起落。1. 形状:保留
loops_running_,两处加固都做了note_loop_started(int index)那个(void)index;现在真用起来了:loops_running_的两个增减点各由按格 CAS 把门 —— 自增侧loop_slot::started"记过就不再自增",自减侧finishedCAS 之后再加一句if (!slot.started.load()) return;"没记过就不许减"。slot.async->init()就在note_loop_started(index)前一句;async == nullptr的格子post()是丢弃 + WARN,进不了停机状态机)。这段写进loop_slot::started的注释了,含"以后谁在init_loop_state()里加一句可能失败的调用会发生什么"。assert:断言是"减错了会喊",CAS 是"减不动"。你要的是"把为什么不会被多减钉住",我理解成后者——注释说不服后来的人,CAS 说服得了。代价是一格一个atomic<bool>。2. 拦路那条:按你的 (1) 落,但门是新加的(你那条引用有个错,值得先说)
stop_workers()不是公开 API —— 它在src/net/uvcpp_tcp_server.h的private:段里(当前工作区:593是private:,:713才是stop_workers();你手上那棵树里这两个数要小几十行)。照"tcp->stop_workers()(公开 API)"写会编译不过。这也正是为什么落地的是一个新公开方法而不是"把stop_workers()公开":新增
uvcpp_tcp_server::rollback_loops()(公开、带门) ——stop_workers()+ 一道门。门是listen_called_,只在listen()成功那条路上置位:webapp 侧:
teardown_failed_start(int rc),四条失败路径共用(set_loops()返非 0 / 钩子里init_rc != 0/bind()/listen()),做两件事:tcp->rollback_loops();note_loop_stopped(i)回滚记账。②逐格记而不是
loops_running_.store(0):store(0)是拿"我猜是 n−1 笔"去盖,猜错就把别的账一起抹了;逐格那一圈正好把钩子里记的那一笔一一抵掉,而且对"只起来了一部分"(set_loops()中途失败,失败点之后的格子根本没跑过钩子)也成立。①比我一开始想的要多一件事——这一步是探针逼出来的,我第一版落错了。 我原来的写法是"逐格标
orphaned、故意泄漏"(理由同样是泄漏好过 UAF)。落地之后run_loop_leak_probe.py直接报test_web_app_multiloop_func 泄漏 1,句柄形状async(active=1) timer(active=0) timer(active=1)—— 正好是一个loop_slot的三个句柄。机理:rollback_loops()只停循环,属主建在那条循环上的句柄没人收 ⇒uv_loop_close()撞UV_EBUSY⇒~uvcpp_loop把整块循环内存记成泄漏(就是你说的那一族,占全部泄漏循环的 57%)。"故意泄漏三个句柄"不是我要付的代价,"漏一整块循环内存"才是。修法也不是加个标志:net 加一个与set_loop_start_hook()对称的公开钩子在 worker 线程上、
uv_run返回且 net 自己的on_exit(close_clients_of_loop)跑完之后、uv_loop_close()之前调(同样只对 1..n−1,同样必须在set_loops()之前装,装晚了只打一条 stderr)。属主把"收句柄"收进uvcpp_web_app::release_loop_handles(index),与正常停机那条路(finish_shutdown())共用同一份、幂等:于是两种结束方式收在同一个时间点:循环内存被释放之前,且在那条循环自己的线程上。既不会 UAF(不晚于释放),也不会漏整块(不永不)。顺序也是有讲究的:WS 分片要先于
async删(分片里那次uv_close得在本循环还活着的时候排出去)。orphaned这个标记没有删,但它现在只剩一处标记点 ——join()的有界超时那条(循环真的卡住了、退出钩子不会响),那种情况下本仓仍然选泄漏。这两件事重排之后,
teardown_failed_start()里没有"故意泄漏"这条分支了,所以"每格至多 3 个句柄"这句也一并作废 —— 失败路径的泄漏判据现在是探针报泄漏 0。重试:按你的 (2),但把它做成"挡得住"而不是"只写在注释里"。
start_terminal_⇒n > 1失败之后同实例再start()返UV_EBUSY+ 一条说明原因的 ERROR(不是让你去猜"是不是还没停干净")。teardown_failed_start()对n == 1早退 ⇒ 单循环逐字节不动,"失败后改配置重试"那条仍然成立。你说的"重试会复用旧格子"我同意,而且它就是终态的理由。但理由要说准:有了退出钩子之后,句柄本身在
rollback_loops()里就被收干净了(不是"留着泄漏"),所以挡重试的不是句柄,而是格子里的状态——每进程的那半(started_once_、TLS 上下文、已建好的监听器)本来就不是为复用设计的,loop_slot里那些计账/编号也不会自己归零。要支持重试得把整格复位,另一笔。文档跟着改了三处:start()的声明注释、doc/webapp-guide.md的「启动失败(n > 1)」一节(那里我明确写了"半截状态上重走一遍会怎样没有验过",没有替它编机理),以及doc/net-guide.md新增的 §4.5 —— 那两条新公开 API(set_loop_exit_hook()/rollback_loops())在 net 这一层是对外能力,只写在头文件注释里不够。3. 钩子丢返回码:按你说的落
loop_slot::init_rc+ 钩子里存 +init_process_once()在set_loops()返回之后扫 1..n−1(钩子那时全部跑完)。判到就return teardown_failed_start(init_rc),与上面同一条路。顺带在钩子里加了两句下标防御(越界 /
s.index != index直接 return)——钩子的下标约定是i + 1,越界写是踩内存。4. 两条都落了
uvcpp_ws_server.h:close_all_sessions()与stop()都补了@warning(只覆盖本线程所在循环那一片分片,不在循环线程上时是 0 号;要全关就逐格close_sessions_of_loop(i, code))。没加close_all_sessions_everywhere():框架自己的停机已经是逐格调,公开一个"全关"会把"关闭是循环亲和的"这条掩盖掉,用户下次就会在别处也假设它全关。finish_shutdown()改成client_count_at(slot.index)(你说的理由——假红会训练人忽略警告——我原文照抄进注释了)。按uvcpp_loop*的重载我没加:client_count_at(i)与connection_count_at(i)已经在用"格号"这一套编号,再加一个重载等于把两套编号都摆出来给后人挑。5. 验证:这一批的绿灯是哪几盏,以及我改了一次判据
build-h2(Release,web+webapp+OpenSSL+zlib+h2),grep -c "error C"= 0;ctest99/99 全绿。start()都返非 0,成片红(实测 8 条附带损伤),M7 与 M8 在"红不红"上分不开。改成判判别式:那条「有循环没退出」的判据 M7 不响 / M8 响——红绿之差换成了"目标判据响没响",并且把"为什么红绿这条判据是坏的"写进脚本头部,免得下次又被当成判据。run_pageheap_gate.py,本仓唯一看得见 UAF 的那道——这条路径动的是句柄生命周期,必须过)+ 循环泄漏探针(run_loop_leak_probe.py,test_web_app_multiloop_func 泄漏 0)。探针这条要说明:它的退出码恒为 0,判据是它打印的==== 汇总 ====那一段,所以"跑过探针"本身不算证据;而且它是先跑、再报的顺序 —— 上一版就是它把上面 §2 那条漏掉整块循环内存捞出来的。doc_line_refs.lock跟着源码行号刷新(这一批源码加了注释 ⇒ 引用条数平移,逐条核过是纯数字改动)。test_web_ssl_app_ws_func红了一条(rc=2 普通失败,不是崩溃)。核下来与我们这一笔无关,是那条用例自己的采样点不受控:场景 4 拿sink.upgrades == 1当"确实升级成了一条 WS 会话"的前提断言,却在connect_wait()一返回就采样 —— 而服务端是先把 101 写出去、App 的websocket()handler 在写完成回调里才跑(src/web/uvcpp_ws_server.cpp:214-259),两者之间隔着服务端循环的一拍。同文件的场景 1/3 在同一个断言之前先跑了wait_texts(1)、场景 2 有settle(600),只有场景 4 是裸采。所以:单独重跑那个 exe 绿、隔一轮跑全表也全绿(用它证明"偶发"),然后把采样点挪到状态必然成立的地方(有界等 5 秒,断言一字未动)。摘掉服务端那次on_ready(conn)做对照,它照样红 —— 判据没被这次修改弄钝。6. 你 §5 最后那条
~uvcpp_ws_connection不碰句柄这一点我核了,同意;你说的更精确那版(它写的是连接那两份循环亲和的表)也对。我在设计稿里列的循环亲和三条理由保持不变,分片键仍取连接自己那条循环。落地:
a73c267已推到 master(17 个文件),没开 PR。上面 §2 那条"第一版落错、被探针抓住"我特意留在提交信息里 —— 那是这一笔里唯一一处我原本的取舍是错的,写进去比事后只留结论有用。第 2、3、4 条在同两个文件里,一次复核到位了。
sercebr commentedon Sep 23, 2026
口径:
a73c267的原文,src/net/*、src/webapp/*、src/web/uvcpp_ws_server.h、tests/functional/web_app_multiloop_func.cpp、doc/net-guide.md逐个gh contents现取的,没做偏移折算。六条我全核了:两条建议在合下一笔之前处理,一条是结论不是问题,其余三条成立。1(建议先处理):
thread_main()里"start 钩子抛异常"那条路跳过了on_exit_,而那条路的注释说它跑src/net/uvcpp_loop_worker.cpp:108-121那个分支做的是:它上面那句注释(
.cpp:109-111)写的是:「与上面 async 建失败那条路同一套收尾:钩子可能已经建了东西,但它自己最清楚怎么收(属主的收尾在on_exit_里)」。这句话与这段代码互相否:
on_exit_在这个分支里一次都没被调。而且两条路本来就不该共用收尾 ——async_->init()失败那条路上属主还什么都没建(钩子还没跑),跳过是对的;走到hrc != 0,钩子已经跑了一半。后果按你写的那对契约推就出来了:
set_on_start()是「属主在这条循环上建自己的句柄」的唯一入口(.h:116-117),set_on_exit()是它们的唯一回收点(.h:102「关循环之前跑一次」)。这条路把循环loop_close() + delete掉了,而属主建在它上面的句柄没人收、指针也没被置空。webapp 正是靠它收的(uvcpp_web_app.cpp:1703-1704):slot.async非空、slot.loop悬空、slot.tid_known为真而那条线程已经没了;teardown_failed_start()不标orphaned(orphaned只标join()超时那条路);~uvcpp_web_app()(:290-297)于是走orphaned == false那一支 ⇒delete loops_[i]->async⇒free_handle → uv_close()打在已经释放的循环上 = UAF。可达性我如实说:今天只有
init_loop_state()里那三处new(.cpp:2073/:2084/:2089)抛才走得到 ⇒ 实际条件是分配失败。但这正是你给判据 8 定的那类形状(「进程内没有自然触发器 ⇒ 造变异给它」),而且它比判据 8 毒:判据 8 红了只是"停机不干净",这条红了是本仓唯一看得见 UAF 的那道门该响的地方。变异:在
init_loop_state()的slot.shutdown_timer = new uvcpp_timer(loop);(.cpp:2084)之前插一句throw std::bad_alloc();。要点是必须在note_loop_started()(:2096)之前抛 —— 那样started还是假,逐格回滚那圈正好抵得平(这一笔的记账是对的),红面就只剩句柄那一条,不会被记账的噪声盖住。修法:在这个分支里把正常退出路径的 2~4 步照抄一遍,再
loop_close():先on_exit_(),再按kExitPumpRounds泵几轮(.cpp:155-158那段)把uv_close的完成回调放掉 —— 泵这一步不能省:属主的钩子会uv_close几个句柄,不泵就uv_loop_close()撞UV_EBUSY,那按本仓自己的口径是"整块循环内存记成泄漏",等于从一个坑换到另一个坑。若你不想让"钩子抛异常"也跑属主钩子(怕它自己再抛),那至少把.cpp:109-111那句话改掉:它现在读起来像"这里已经收过了"。2(建议先处理):
rollback_loops()的返回码在唯一调用点被丢掉,而门一旦真响,记账先撒了谎doc/net-guide.md:442把它写成有意义的信号(0 = 收干净了;UV_EBUSY = 已经 listen() 过),但uvcpp_web_app.cpp:2022是if (tcp != nullptr) tcp->rollback_loops();—— 没人看返回值。更该改的是次序,不是那一行:
:2034-2035那一圈note_loop_stopped(i)与rollback_loops()是无条件的先后,可它的前提是"循环真的被收回了"。门一旦响,结果是loops_running_归零、loop_started_变假,而 n−1 条线程还在跑 —— 而loop_started_今天的语义是"至少还有一条循环在跑",它就在说假话。你说store(0)是"拿我猜是 n−1 笔去盖",这里形状一样,只是盖的是另一头。3(结论,不是问题):
listen_called_那道门今天恒真,而"逼不出 listen 失败"这件事本身是结论我原先想的是"端口被占"能逼出
listen()失败,实测是错的:Linux 上第二个 socket 也卡在bind()(Address already in use——SO_REUSEADDR不放过已经在 LISTEN 的 socket),我先用 python3 两个裸 socket 验过,再用你的用例那条路验了一遍。所以你用 TEST-NET-1 逼bind()失败是对的。把这句写进
rollback_loops()的注释("今天的调用方永远走不到UV_EBUSY那一支,它是给以后的人留的")比再加一个用例值钱 —— 因为用例造不出来这件事本身就是结论,不写清楚的话后人会以为那一支被验过。4:两处注释/日志还在用你已经改口的机理
uvcpp_web_app.cpp:1606-1608(start_background()的失败尾巴)写「这个对象就回到"没启动"的状态,调用方改完配置还能重试」——n > 1下已不成立(teardown_failed_start()置了start_terminal_),只有n == 1还成立。:1579-1581那条 ERROR 写「1..n-1 那几格的句柄指针还指着已经释放的循环,重试会一路泄漏」—— 你在 §2 里已经改口成"挡重试的不是句柄,而是格子里的状态",头文件也照那个写了,但用户真正看到的是这条日志。按它去排查会往错的方向找。5:其余四条核过,成立
note_loop_started()(:1976-1980)与note_loop_stopped()(:1990-1998)两侧都幂等,"没自增过就不许减"在finishedCAS 之后、fetch_sub之前,次序对。你那段"偶然安全"我复核仍然成立(async->init恰在note_loop_started前一句)。CAS 而不是断言这个取舍我同意,理由你写得比我想的准。uvcpp_ws_server.h:126-128/:139-143两条@warning与client_count_at(slot.index)(:3500-3505)都对;不加close_all_sessions_everywhere()的理由("全关"会把"关闭是循环亲和的"这条掩盖掉)我同意。!loop_started()(:1312-1315)、并写明"再start()返UV_EBUSY单独不足以证明回滚做了事"(:1322-1325)—— 这两句都对,第二句尤其。6:一处小事,优先级别不高
release_loop_handles()(:3484-3491)在锁里delete s.async; s.async = nullptr; s.post_queue.clear();。若别的线程抢锁在先(post()的async_ != nullptr检查已过、任务已入队并send()),那条任务会被随后的clear()丢掉,且一条 WARN 都不打(丢弃告警在async == nullptr那一支里)。循环马上要停,丢掉在语义上没错,所以我不建议改行为;只是"悄悄丢"与post()别处"丢掉会喊"不一致 —— 你看要不要在clear()前数一下条数、非零时打一条。一句总结:主体我认为是对的 —— 尤其"①不能只是把循环停掉"那一段,被
run_loop_leak_probe.py当场抓住、机理也写对了;上面第 1 条是唯一一条我建议在合下一笔之前处理的,第 2 条顺手就好。1.3.x L1:日志模块完善(LOGF、重载集、flush、SOAP/WSDL);修启动钩子抛异常时属主句柄不回收;版本升 1.3.33…
release: 1.4.0 —— 1.3.x 收口(1.3.1 → 1.3.33)
Antruly commentedon Sep 26, 2026
先说结论:第 1 条你抓对了缺陷,但后果要改判 —— 它是泄漏,不是 UAF。 第 2 条属实、我认,
但不在这一笔里(详见下)。第 3/4/6 我同意,排在同一笔后面。
口径:你复核的是
a73c267,我这笔落在a58892b上。下面给的行号按a58892b的干净树取,与你那边的号有偏移。
1.
on_exit_那条路 —— 缺陷属实,但打不出 UAF你指的那段代码确实一次都没调
on_exit_,这一点完全成立。a58892b上的形状是:uvcpp_loop_worker.cpp:108-124是那条分支(修复加在:117-123),正常退出路径在:143-148,两边现在都走:151的teardown_loop()—— 我照你的方子补了on_exit_(),并把"摘 async → 泵 →
loop_close"抽成共用的那一段,免得以后各写一遍走岔。set_on_start()的契约也写进了头注释(uvcpp_loop_worker.h:122):钩子抛异常时一样会跑
on_exit_。但后果不是 UAF,这个我实测过。有两道互相独立的防线:
uvcpp_loop::loop_close()只在uv_loop_close()返回 0 时才置closed_。属主的async还开着 ⇒ 返回UV_EBUSY⇒~uvcpp_loop走if (!closed_ && uv_loop_close(loop) != 0) { detach_handle(); return; }那一支 ——它故意把那整块循环内存记成泄漏(注释里就写着"泄漏一块仍然有效的内存,换掉一个悬垂")。
~uvcpp_web_app还有orphaned那一格跳过。⇒ 你推的最后一步"
delete loops_[i]->async的uv_close打在已经释放的循环上",今天走不到:那块内存是泄漏但有效的。这也是为什么我用 PageHeap 没能把它抓出来 ——
我一开始也按 UAF 去查,方向错了,是回头逐行读
~uvcpp_loop才改的口。三臂实测(
build-log,注入点就用你给的那个)init_loop_state()里slot.shutdown_timer = new uvcpp_timer(loop);之前throw std::bad_alloc()(在你说的
note_loop_started()之前,所以记账那圈是抵得平的)。on_exit_不跑、泵不跑on_exit_跑、泵不跑on_exit_跑、泵跑async。A 与 C 必须不同,否则判据没牙 ⇒ 它把A 和 C 分开了。
~uvcpp_loop那条泄漏支被打到。B 与 C 必须不同 ⇒ 它把 B 和 C 分开了,也就是说你写的**"泵这一步不能省"是实测出来的**,不是推断。这一条我照抄进了代码注释。
补一句装置上的教训:我第一版的"对照组"只替换了能锚到的两行,锚点上方那句
if (on_exit_) { on_exit_(); }活了下来 —— 于是那一轮跑的其实是第三种形状(钩子跑、泵没跑),给出的读数与源码直接矛盾。那个矛盾才是我回头去读
~uvcpp_loop的原因。2.
rollback_loops()的返回码与次序 —— 属实,下一笔你说得对,而且是两件事叠在一起:
uvcpp_tcp_server.cpp:473的if (listen_called_) return UV_EBUSY;让rollback_loops()可以拒绝回滚,而唯一调用点uvcpp_web_app.cpp:2098tcp->rollback_loops();把返回值丢了 —— 拒绝了也没人知道。:2110那圈note_loop_stopped(i)无条件跑,于是 n−1 条线程还在转,账已经被抵平了。
(顺带说清楚一处,免得我前面写得不准确:那圈不是
loops_running_.store(0)——它在
:2105-2109的注释里明确否掉了store(0),理由是"拿'我猜是 n−1 笔'去盖会把别的账一起抹了"。所以问题不在计数怎么减,而在它凭什么在
rollback_loops()被拒之后还照减。次序才是该改的地方,那个丢掉的返回值只是症状 —— 我同意你的判断。)
这一笔没动它:我先把 §1 收口再发版(
v1.4.0已打标签,含这一笔),§2 连着 §3/§4一起做(它要动的是次序,不是加一行判据,值得单独一笔 + 单独的用例)。
3.
listen_called_那道门 —— 同意,会写进注释"用例造不出来这件事本身就是结论"这句我认。我会把它写进
rollback_loops()的注释:今天的调用方永远走不到
UV_EBUSY那一支,它是给以后的人留的。你那两个裸 socket 的验法我也记下了。
4. 两处还在用旧机理的地方 —— 同意,两处都改
uvcpp_web_app.cpp:1646-1648,start_background()失败尾巴那句"调用方改完配置还能重试":
n > 1下已经不成立,只有n == 1还成立。uvcpp_web_app.cpp:1617-1623,那条 ERROR 里的"句柄指针还指着已经释放的循环,重试会一路泄漏":机理我已经改口成"挡重试的是格子里的状态",但用户真正看到的是这条
日志 —— 按它排查会往错的方向找。这条尤其该改,是我自己没跟上。
6.
release_loop_handles()里悄悄丢任务 —— 同意,但不改行为行为不动(循环马上要停,丢掉在语义上没错)。按你的建议,在
clear()之前数一下条数,非零时打一条 —— 与
post()别处"丢掉会喊"拉齐。第 5 条那四项复核我收到了,谢谢 —— 尤其"再
start()返UV_EBUSY单独不足以证明回滚做了事"那句,以及你把 M7/M8 的判据换成判别式。
a58892b这笔的用例里那两条判据已经按那个形状写。a58892b的 CI 全矩阵 18/18 绿(x64/arm64 × msvc/mingw/linux 六条腿 + 门禁),本机
build-log113/113、build-h2109/109。