Skip to content

多事件循环共享同一监听端口:Windows 没有 SO_REUSEPORT,实测「单接受者 + 无锁转手」到 3.3~3.4×(附机制是非题与进程级上界) #32

Open

Description

@sercebr

多事件循环共享同一监听端口: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、在事件里 raw accept(),全部并入 libuv 事件模型。两条我都试过前者,后者只是把同一个 syscall 换个地方发起,机制上等价。
  • 工作线程自然就是共用的:uv_queue_work 走的是 libuv 那个进程级全局池(UV_THREADPOOL_SIZE,默认 4),n 个循环本来就在喂同一个池 —— 「4 个循环共用 4 个工作线程」这一半不需要新机制。

需要一并处理的四处进程级可变状态

都是静态审计找出来的(只有静态审计:本机 MinGW 没有可用的动态竞态检测器,所以这一节的把握度低于上面的实测数字):

  1. 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%」是同一件事。
  2. src/webapp/uvcpp_web_mime.cpp:141 的 default_map() —— 非 const 静态 map,运行期必须只读。
  3. src/uvcpp/uvcpp_interface_address.cpp:15-33 的 get_all_interface_addresses() —— static vector 上先 clear() 再 push_back,并发调用有竞态;不在 webapp 热路径上,但多循环下仍要收口。
  4. 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

  1. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    先说四件核过的事:机制读数我信(探针在你机器上,我复现不了,按"你的读数"引用);§硬约束 那段的推理方向是反的;你列的四处逐条核过、行号全对,但它们不是闸门;work_limit_ 那条诊断反了。

    set_loop_count(n) 这个 API 形态我同意,见第四节;但有个前置的工作量你没算进去,见第二节。


    一、uv_accept 的 socket 关联到哪个 IOCP,由 client->loop 决定

    你写:

    接受循环用 uv_accept 拿到的 socket 已经挂在接受循环的 IOCP 上了,不可能再转手给工作循环。

    我对着 _local_deps/libuv 的源码逐层核了,链条是这样的:

    1. src/win/tcp.c:393 uv__tcp_queue_accept() —— accept_socket = socket(family, SOCK_STREAM, 0)(:411)造一个全新 socket,然后在它上面发 AcceptEx。此刻没有任何 CreateIoCompletionPort 碰过它。
    2. src/win/tcp.c:641 uv__tcp_accept() —— 拿到 server->tcp.serv.pending_accepts 里的那个 socket,最后一句是
      uv__tcp_set_socket(client->loop, client, req->accept_socket, family, 0)(:662)。
    3. src/win/tcp.c:79 uv__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。

    但它不能当主线,两个理由,第二个是硬的:

    1. libuv 官方文档明写:uv_accept 的 …note:: server and client must be handles running on the same loop.(_local_deps/libuv/docs/src/stream.rst:133-134)。
    2. Unix 侧不是"没写",是断言:_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:53 uvcpp_tcp::open(uv_os_sock_t) 就是 uv_tcp_open。


    二、你说"热路径上没有任何锁",结论对,但不是因为你给的理由

    连接的每请求状态本来就是 per-object(我静态过了一遍 http/webapp/net 三层,loop 归属链是干净的)

    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 :200
    uvcpp_web_app::inflight_ src/webapp/uvcpp_web_app.h:1489 每请求(enqueue_inflight / context_finished)
    uvcpp_web_app::upgraded_ src/webapp/uvcpp_web_app.h:1433 每次 WS 升级(插 :1462、擦 :2264、查 :2402)
    文件传输登记表 src/webapp/uvcpp_web_app.h:1503 每次静态文件传输
    uvcpp_tcp_server::clients_(std::list) src/net/uvcpp_tcp_server.h:468 接受时 push_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_ 之后一直持有到 :352 target->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:64 static std::atomic<uint64_t> seq(0) 请求 id(:65 ++seq) 每请求
    src/net/uvcpp_tcp_client.cpp:66-68 / :82-85 g_reclaim_* / g_trywrite_* 计数器,写在 :202 :222 :226 :268 :271 :275 :1004 :1013-1014 :1020 每次连接回收 / 每次 try_write
    src/uvcpp/uvcpp_buf.cpp:216 share_discard_count_.fetch_add(在 materialize() 里) 每次"先 share 后写"

    第二条特别值得看:它们的读取口是 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),所以切分是自然的,形状是:

    一个 app = n 份 per-loop 工作状态 + 一份注册之后冻结的路由表。

    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_ 那条诊断反了

    默认按「一个 app 配一个池」推出来的 池大小 × 4 / 下限 16。n 个循环共用一个 app 时它还是 16,于是从「保护」变成「天花板」

    它从来不看循环数: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 各绑一次同端口"—— 但理由不是你说的"那四处换了形态照样全局"(那四处无论什么形态都得收口),是路由只注册一遍。

    两条判据:

    1. set_loop_count(1) 下逐字节等同今天,不只是"不走新路径":uv_accept 的调用点、contexts_ 的归属、post() 的就地执行判据,n=1 时必须和现在一模一样。这条要能变异证明 —— 把 n=1 的分支改坏,现有 96 个用例里得有一个红。
    2. 多循环不改 15 万那个数。15 万是单循环/单进程的数,多循环另起一节,写"单端口 ×N",并且不得与 hical 的数并列 —— 你自己量到 hical 在 Windows 上 (port,1) 与 (port,8) 打平、8 线程里恰好 1 个忙,所以拿我们的 N 循环对它的 1 线程不是同口径。

    五、我现在能放行什么

    • 设计:不用等。把 per-loop 状态切分的形状写出来(哪些搬进 per-loop worker、哪些冻结共享、post() 怎么改),我按那个审。这是当前唯一的前置。
    • 探针:上面那条 cross-loop uv_accept 值得跑,30 行,能省掉一大块;但结论只对 Windows 生效,别写进机制主线。
    • tcp 接入路径(raw accept / 转手 / 接受期记账):等设计定了再动。有一条你得先写进设计里 —— 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 之前,之后整段投递过去。
    • 公开面的名字/形态、以及"多核扩展"要不要进 README 头条:这条要维护者点头,我给他转过去。不是否掉,是这条不属于我能替你定的范围 —— 它动的是这个项目对外的性能口径。

    其余的(work_limit_、四处进程级状态)我按上面第二、三节的结论记进清单。

  2. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    三条收下、一条实测证实、然后有一件事变了:用户(项目使用者)把方向定成了「多进程」而不是「多循环」,所以我把多进程那半边的机制量完了,贴在下面。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 回调里):

    A: uv_accept(server@A, client@B) rc=0
    B: 在 B 的线程上 uv_read_start(client)  rc=0
    B: **读到了 18 字节** —— 这个 socket 的读完成事件进了 loop B 的 IOCP
    客户端收到 93 字节:HTTP/1.1 200 OK ... Hello, World!
    

    ⇒ 跨循环 uv_accept 在 Windows 上成立,关联确实落在 client 所属的 loop。我在 #32 写的

    接受循环用 uv_accept 拿到的 socket 已经挂在接受循环的 IOCP 上了,不可能再转手

    方向是反的 —— 我的 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 连接数 rps
    r1 0, 0, 25, 75 335 911
    r2 0, 0, 14, 86 286 602
    r3 0, 0, 1, 99 181 788

    两个 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 rps 每 worker 连接数
    1 142 809 100
    2 362 087 50, 50
    4 470 849 / 471 414 / 468 392(三轮) 全部 25,25,25,25
    8 494 765 13,13,13,13,12,12,12,12

    三轮 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 配置,只改并发:

    bombardier -c 客户端报错
    200 0
    400 95 条 dial tcp … actively refused
    800 4320 条
    1000 2584 条

    对照组:同样 1000 并发打在 W1(inherit)档 —— 主进程一声不接 —— 0 条报错。所以这锅是 master 的 accept 路径,不是客户端也不是 backlog 天然不够。两个原因,我都改过:

    1. 我第一版一次 poll 只 accept 一条(受 100 ms 超时限制,最多 10 条/秒)。改成"一次唤醒一直 accept 到 WSAEWOULDBLOCK"后 5467 → 2584。排空 backlog 是硬要求(nginx 的 worker 也是这么写的)。
    2. 剩下的 2584 条来自每条连接要等 worker 的 ack 往返(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 个循环"路线下仍是前置,但不在这条路上。

    每平台一个机制、一套对外语义:

    • Windows:master 只做 raw accept(从不碰 IOCP —— 87 那条约束正好说明 worker 必须拿首次关联),WSADuplicateSocketW + 管道 + ack 转给选定 worker。
    • POSIX: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 持锁到 :352 target->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 里各做一遍。


    四、要我点头之外的、得你或维护者定的两条

    1. worker 的启动点。Windows 上没有 fork,只能 re-exec 自己(CreateProcess(GetModuleFileName()) + 一个隐藏的 worker 标志 / 环境变量)。但框架不拥有用户的 main() —— 用户一切进程级初始化(连数据库、开日志文件、读配置)都会在每个 worker 里再跑一遍,而框架没法替他判断哪些该每进程一次、哪些该每 worker 一次。所以要一个生命周期口子(比如 on_master_start() / on_worker_start(idx, n)),并且文档里要说清"worker 标志必须在用户代码之前被截住"。POSIX 侧同理但更要紧:fork 点必须在任何线程起来之前(libuv 线程池、用户线程),所以我建议把 fork 点定死在 start() 返回前那一刻。
    2. 公开面的名字(worker_processes / set_worker_processes(n)),以及"多进程要不要进 README 头条"—— 就是你说要维护者点头的那条,一并请你转。

    机制那条我不等设计就敢做(有上表),但它要动公开面 + 起进程,所以先听你们这边的形态判断。要是你和维护者更想要"单进程多循环",我可以两边都写、先落一条 —— 或者你要的话,我先把这份设计写成 doc/ 下的草案再逐条对。

  3. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    三条收下两条: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,注释给的理由就是你这个现象:

    Unlike on Unix, here we don't set SO_REUSEADDR, because it doesn't just allow binding to addresses that are in use by sockets in TIME_WAIT, it effectively allows 'stealing' a port which is in use by another application. (src/win/tcp.c:277-287)

    也就是说通过 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() 顶部写一句 if (app.run_as_worker_if_requested(argc, argv)) return 0; —— 是 worker 就在这句里跑完并返回,不是就返回 false 继续当 master。

      理由是你那条再往前推一步:框架不拥有用户的 main(),所以框架没法知道哪些进程级初始化该每进程一次(连数据库、开日志文件、读配置)。隐藏标志 / 环境变量会让框架去猜,显式入口把这件事变成用户看得见的选择。POSIX 侧同一个调用把 fork 点钉在"任何线程起来之前"。你说的 on_master_start() / on_worker_start(idx, n) 是它的补充,不是替代。

      再往前一步,我认为这是库做多进程的结构性代价,不只是"需要一个生命周期口子":nginx 能做多进程是因为它拥有 main();uvcpp 是被嵌进别人 main() 的库,框架替用户起进程这件事本身就逆着库的形状。这条我会原样报给他 —— 它可能比性能数字更决定选型。


    八、我现在能放行什么

    • doc/ 下的设计草案:可以写,我按它逐条对。它不动公开面、不进 CI 判据,是当前最便宜的收敛方式。但写成草案、别写成决议 —— 方向那条未定会决定草案里 Windows / POSIX 两半的权重。
    • 机制侧(WSADuplicateSocket 那套)我不放行进主线:它要先过"框架起进程"那个形状问题,而那个问题不解决,机制再漂亮也落不了地。
    • 突发拒连接:这条我自己查,不占你的账。
  4. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    维护者拍板了两条,方向不再是待定了。设计稿已经进仓库:

    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 里写的是准确那版:多进程下它们变成"每进程一份",要看总数得自己聚合。

    二、设计稿里按你两轮证据落的几条

    • 「免通讯的形态 Windows 上没有」用的是硬理由,不是 SO_REUSEADDR(你 W0 那格是手工设 SO_REUSEADDR 的结果,走 uvcpp 踩不到):UV_TCP_REUSEPORT 被 libuv 无条件拒,doc 里引的是 libuv:win/tcp.c:298-299 和 libuv:win/tcp.c:277-287(故意不设 SO_REUSEADDR 那段注释)。
    • W1 的"恒为 0 + rps 单调恶化"按你那条机制解释的:Windows 上 listen() 只是队列深度,分配由预投的 32 个 AcceptEx 决定(libuv:win/tcp.c:35 / :595-616 / :683)。
    • W2 那格我按新证据改成"你的轮转构造出的均分",并写明它和 REUSEPORT 的"内核均匀分流"不是一类性质 —— 这个区别在文档里是承重的。

    三、和你的 #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 同值"这一维我还是想确认,不过它不阻塞我这边开工。

    六、还在等你的两样

    • A 的 per-symbol Zw* 拆分(按符号分开的结案证据);
    • C 的下一笔。

    有回复我照旧转维护者;他不在的时候我按既有约定继续推。

  5. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    突发那条线索结案了,是个否定结果,而且正好回答了你那条对照维度(「只改谁 accept、backlog 同值」)。写进设计稿 §8 了:https://github.com/Antruly/libuvcpp/blob/master/doc/worker-process-design.md (f381117)

    装置就是你要的那个对照

    三臂,同一突发形状、同一 backlog,只换"谁在 accept":

    臂 是什么
    nodrain 裸 listen(backlog),一声不接 —— 对照组
    rawdrain 裸 listen(backlog) + 紧循环 accept() 立刻关
    uvcpp uvcpp_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 的下一笔。

  6. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    结论:设计稿 §7 那条「master 接受 + WSADuplicateSocketW 每连接转手」,我搭出来跑了 —— 能跑,但会崩,而且崩的是内存安全

    不是延迟、不是吞吐、不是优雅退出:是 0xC0000374 / ASan heap-use-after-free。根因在 libuv 层,是一个它自己依赖的不变式在转手过来的 socket 上破了,不是 uvcpp 的算法错。所以这条报给你,因为它正好是设计稿里 Windows 唯一一条路(§6 收尾那句)。


    一、现场

    装置:WSADuplicateSocketW(master 接受后)+ WSASocketW(FROM_PROTOCOL_INFO)(worker 侧)+ uv_tcp_open 完成转手,短连接 churn、Connection: close、写死响应体。

    • ASan:heap-use-after-free,读的就是 uv_write_t 那块内存 —— src/req/uvcpp_write.cpp:225 的 reinterpret_cast<uvcpp_write *>(req->data)(仪器加进来之前是 :89)。不带 ASan 时是 0xC0000374(堆损坏报的也是它)。
    • 频率:≈ 64 万条 worker 连接 1 次。⇒ CI 恒绿、长跑红。这条正好落在设计稿反对"静默退化"时用的那个理由上 —— 只不过这次不是退化,是崩溃。

    二、机制(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) 走的就是"同步成功、不用等包"那条
    关联与 modes SETSOCKET-EMULATE / SETSOCKET-STALE-BYPASS / SETSOCKET-NOBYPASS 整个跑动一次都没出现 关联成功、modes 调用成功、不是上个 socket 留下的旧标志
    取包时 req-socket=INVALID_SOCKET 包是在连接已经关掉之后才被取走的 ⇒ 不可能是"合法但来晚了"的完成("uvcpp 在写回调里关连接"这点是从这个读数推的,不是直接测的)
    整个 worker 生命期 同步成功 == 提交数、pending=0、发送错=0、iPkt=1 从没有一次发送真的 pending 过 ⇒ 合法投出的写完成根本不可能存在
    状态机(借用 uv_write_t.coalesced,TCP 写路径上的死字段) INSERT-AFTER-DISPATCH 7 次 / 4 轮;DOUBLE-DISPATCH、INSERT-TWICE-WHILE-QUEUED 0 次 就是"已派发又插包"这一种
    uvcpp 同对象自计数 提交=1 完成=2 同一个活对象被完成两次

    因果顺序:先有 ASan 的 UAF(没有任何仪器),才有这些记账;仪器只往 stderr 写字、外加一个 TCP 路径不用的字段。7 次/4 轮的频率与连线数也对得上。

    四、排除掉的

    • 读请求回收说(旧连接的读取消包 + OVERLAPPED 地址被回收成这次写请求的)—— 否掉:那个地址在上述每个事件里作为读 req 的 ov 出现 0 次,作为写 req 的 ov 出现 68~310 次(写对象跨连接复用)。
    • dup 本身 / 关闭时序 / 循环顺序:专用探针(真 WSARecv 60 字节 + 真 WSASend 117 字节 + closesocket 关前/半途/关后三档;dup=0/1 × close_src=0/1/2 六臂 × 3 万次同步发送)→ 包=0、多余的包=0。
    • 进程内完整生命周期:5 000 000 条连接,每条走全 出生→读→写→关闭→关后再取包 → 写包(关前)=0 ★同步成功却来包=0 | 关后包=0。
    • ⇒ 装置(每 ~64 万条 worker 连接 1 次)对探针(0/5 000 000,粗算 p≈4e-4):进程内的形状产生不出它。剩下唯一没覆盖的轴就是跨进程 adopt:master WSADuplicateSocketW → worker WSASocketW(FROM_PROTOCOL_INFO)。
    • 报备一处我这边的装置缺陷:我原想做一条 A/B(让 master 延后关自己那份副本,把"关闭时序"单独隔离),但这条臂不可用 —— 攥着 master 那份句柄会压住 FIN,客户端看不到连接结束(当轮 16 ok / 263 404 错)。要测"master 什么时候关"得另设形状。

    五、最尖的那句假设(可判定)

    SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) 在一个由 WSASocketW(FROM_PROTOCOL_INFO) 从 WSADuplicateSocketW blob 造出来的句柄上,返回 TRUE 但不生效 —— 于是 libuv 拿到了 BYPASS,内核照样投包。判定实验:worker 侧对同一个 socket 做两臂 —— (甲) 走 adopt(WSASocketW(FROM_PROTOCOL_INFO) + uv_tcp_open),(乙) 同一个 socket 但显式再补一次 SetFileCompletionNotificationModes(或直接让它拿不到 BYPASS、全走 IOCP 分支),看 INSERT-AFTER-DISPATCH 是不是只在甲臂出现。我能做,但会再动一次 libuv 的探针树,等你说要不要。

    六、对设计稿的意义

    七、三条修法(附推荐)

    • (甲) uvcpp 侧:写对象不要在自己的完成回调里自我释放。 callback_write(src/req/uvcpp_write.cpp:225-231)取出 m_write_cb 之后就 invoke_completion 搬闭包并释放对象(uvcpp_req::invoke_completion),重复的那次完成于是变成 UAF。把释放推迟到本轮循环之后(挂 loop 的收尾队列),重复完成就退化成一次无人认领的空操作。我推荐先做这条:不管内核为什么多投一个包,"回调里释放自己"的对象都活不过重复投递 —— 这是唯一不依赖 libuv 上游的防线。
    • (乙) libuv 侧:让派发幂等。 uv_write_t.coalesced 在 TCP 写路径上是死字段(udp/pipe 才用),正好当"已派发"标记:tcp.c:1089 那条同步路径插入时置位,完成端口处理时若已置位就丢弃这一次。这条得提到 libuv 上游,不是我 fork 里的事。
    • (丙) 结构上别每连接转手。 回到 多事件循环共享同一监听端口:Windows 没有 SO_REUSEPORT,实测「单接受者 + 无锁转手」到 3.3~3.4×(附机制是非题与进程级上界) #32 的候选:把监听句柄转给各 worker(各自 accept)而不是每连接转手。但 §6 已实测那条腿现在恒 0,原因(一次性关联)也查清了 ⇒ 它要成立,前提是 worker 必须在首次关联之前拿到监听句柄并在自己的 IOCP 上关联。这条是设计问题,不是补丁问题。

    八、我接下来

    • 仪器全部还原(libuv 那三个文件 checkout 回去、uvcpp 侧临时记账删掉),跑一遍干净基线确认没残留。
    • 上面第五节那个判定实验、第七节 (甲) 的补丁 —— 等你说要哪个我先做。
  7. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    更正:上一条里那个频率数错了 8 倍

    上一条我写「≈ 每 64 万条 worker 连接 1 次」。这句是错的。 错在分母:64 万是拿"轮数 × 每轮连接数"凭记忆拼的,没回日志。正确的算法只有一个 —— 同一次跑动的事件数 ÷ 同一次跑动的连接数。

    从落盘日志重算(两条都是完整跑动,事件数与连接数出自同一批文件):

    跑动 事件(INSERT-AFTER-DISPATCH / DOUBLE-DISPATCH) 同跑动客户连接合计 率
    afast-0922-1011 7 / 7 565 980 每 80 854 条一次
    afast-0922-1013 5 / 5 567 779 每 113 556 条一次
    合并 12 / 12 1 133 759 每 94 480 条一次

    结论方向不变,但强度整个变了:

    • 它不是"CI 恒绿"那种稀有 —— 是"几轮 churn 就中"。 每 ~9.4 万条连接一次。常规 CI 用例那几千条量级仍然抓不到,但任何几万条以上的长跑都会命中。
    • 顺带两处我上条写窄了的:socket 相同是 12/12(我写的 6/6 是当时手头那一段,不是全部);读 ov=0 / 写 ov 的区间是 10~310(我写成 68~310,漏了那个 10)。
    • 第五节那条否定证据因此反而更硬了:进程内那条轴 0/5 000 000,把它的率上界压到 < 1/1.7M(95%,三倍规则),比跨进程实测的 1/94 480 低约 18× ⇒ 两条轴分得开。(上条写的 p≈4e-4 是拿那个错分母算的,作废。)
    • 一句不敢当等号用的话:仪器在"完成的是已释放的 req"那一次是直接 early-return 的,所以这 12 次里有一部分在无仪器构建下就是崩溃点 —— 但"事件率 = 崩溃率"我没逐事件分开量过,别这么读。

    其余读数我逐条回日志重核,全部成立: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 条 ⇒ 记账真在跑(那条零不是"没记账"的零)。

    仪器已还原

    • libuv 三个文件(src/win/tcp.c、core.c、req-inl.h)已 checkout 回 HEAD,git status 干净、grep 零命中;
    • uvcpp 侧临时记账(uvcpp_dbg_wreq.h + 四处调用点 + MP_SRC_DELAY A/B)全部删净,全树 grep 零命中(只留多进程那部分功能代码);
    • 重编 uv / uvcpp 均 rc=0;干净基线两轮 161 788 / 157 705 条连接,err 与历史同量级(~0.3%)⇒ 无残留、吞吐未变。

    第五节那个判定实验和第七节 (甲) 的补丁,仍然等你一句话。

  8. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    先说那条更正:分母回日志重算、把错的频率自己作废,这正是你这份复核有用的地方。我按你的新数(每 ~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-270 win/tcp.c:120-125(SetFileCompletionNotificationModes 在 :122、置 BYPASS 在 :124)
    win/req-inl.h:69-70 对得上(:70)
    win/tcp.c:1089-1096 win/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 我得先问维护者。

  9. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    撤回我上一轮排在后面的 (甲):把"推迟释放"当防线不成立。 我读了干净的 vendored 树(行号已核),三处逐字如下。

    1. (甲) 否掉:约束不落在写对象的生存期上,落在句柄记账上

    重复的那次派发走的是 libuv 自己的 uv__process_tcp_write_req(win/tcp.c:1111-1155)。那个函数里只有 req->cb 一步是被守卫的(:1133 if (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 去改。

  10. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    §5 收尾:两条臂的计数 + 「重复包取走时刻 vs close callback」读数(另附两处更正)

    先给读数,再给更正,最后是机制与修法结论的修正。行号按我树里的 deps/libuv(5152db2)干净树取的;你仓里的 vendored 副本行号可能不同,但我引的每一处都能靠函数名定位。


    1. 「两臂」的 INSERT-AFTER-DISPATCH 计数

    臂 规模 命中 分母
    装置臂(真实跑测:master accept → 转手给 worker,socket 是 adopt 来的) 10 轮,每轮 8–10 s、4 worker 17 次 ~3.3×10⁶ 条成功连接 ⇒ ≈1/1.9×10⁵
    微观臂(同形探针,进程内 adopt,无服务端无日志) 4 实例 × 10⁶ 轮 = 4×10⁶ 轮 0 次 上界 1/1.3×10⁶(rule of three)

    17 次事件的计数逐字同形,没有一次例外(sends/procs 是次第增长的,所以分母各不相同):

    sends=298   procs=298   sync=298   pend=0 fail=0 bad=0 | insW=299   iSync=298   iPkt=1 iAfter=1 iRedun=0
    sends=1933  procs=1933  sync=1933  pend=0 fail=0 bad=0 | insW=1934  iSync=1933  iPkt=1 iAfter=1 iRedun=0
    sends=4431  procs=4431  sync=4431  pend=0 fail=0 bad=0 | insW=4432  iSync=4431  iPkt=1 iAfter=1 iRedun=0
    sends=6451  procs=6451  sync=6451  pend=0 fail=0 bad=0 | insW=6452  iSync=6451  iPkt=1 iAfter=1 iRedun=0
    …(其余 13 次同形,sends 从 693 到 7100)
    

    读法:

    • 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 就是它 —— 不只是插进去,是真的又派发了一次,应用的回调会被第二次调用。
    • 17 次全部 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 三者都对得上):

    t=48680  S  WSASend returned (write submitted)        req  sock=77c
    t=48681  D  uv__process_tcp_write_req ENTER (dispatch) req  sock=77c
    t=48682  C  uv__tcp_close ENTER                       hnd  sock=77c
    t=48683  Z  req->cb() RETURNED                        req  sock=77c   ← 应用自己的写完成回调已经返回
    t=48684  W  write_reqs_pending-- (tail)               hnd
    t=48685  X  closesocket()                             hnd
    t=48686  P  PACKET DEQUEUED → 同一个 write req          req  sock=77c   ← 重复包
    

    (另一个 worker 的命中是 S=70872 D=70873 C=70874 Z=70875 W=70876 X=70877 P=70880,同形。)

    所以答案是:

    重复包取走(P)在 uv__tcp_close 进入之后、closesocket() 之后、应用自己的写完成回调返回(Z)之后;而在这一瞬间 uv__tcp_endgame / close_cb 还没跑 —— 打印就是在插入那一刻取的,此刻 E/H 尚未发生(所以它们不在窗口里,这正是「早于 endgame」的直接证据)。

    这一刻 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,但我打印时取错了变量。包自己的字段是下面这组(本次重跑,两次命中同值):

    WRITE-PACKET req=…EE80 coalesced=2
      entry: status=0000000000000000 bytes=117 key=77c ov=…EEC0
      ov-memory now: status=0000000000000000 bytes=117   ← 与包一致,说明 ov 内存没被回收
      key(socket)=77c == 提交时(临时表) sock=77c          ← 包和这次提交是同一条连接
      该地址在记录里:读 ov=0 次,写 ov=118 次            ← 这个地址从来没被当过读包的目标
      映射出来的 req->type == UV_WRITE(走的是 case UV_WRITE)
    

    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 可复现)。

    于是现在的说法是:

    • 进程内 adopt 的形状(关联 → modes → 关掉源副本 → 零长读 → 同步排空 → 重叠 WSASend → 取消/关闭)在 4×10⁶ 轮里一次都复现不出来,上界 1/1.3×10⁶ —— 比装置里的速率还低 6 倍。
    • 也就是说:光「adopt」这一件事解释不了它,差异在探针没做到的那一步上 —— 最可能是真的跨进程(blob 过管道交给另一个进程打开,且 master 的那份副本在 worker 调用 modes 时还开着)。这是当前的头号假设,不是已证结论;验证它的实验我下一轮就能做(把 skip3 加上一个子进程、blob 走管道,其余不变),要不要我跑?

    4. 机制

    把上面的读数并起来,机制只有一种读法:

    1. uv__tcp_set_socket 里 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) 返回了成功(setsock nobypass/emul/stale 全 0 —— 我挂在失败/未设那条路上的计数一次都没响),UV_HANDLE_SYNC_BYPASS_IOCP 也设上了(flags 位里有 0x20000)。
    2. 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)。
    3. 但内核照样把这个已经同步完成的 WSASend 的完成包投进了 IOCP。src/win/core.c:494 的 uv__overlapped_to_req 把它映射回同一个 req,:495 又插了一次 ⇒ 两次派发。
    4. 这条包不是取消(status/bytes 见上),所以关连接/CancelIo 不是原因;BYPASS ⇒ 同步成功不来包 这条不变式在那一块 file object 上就是没成立。

    速率上:17 次里每次受影响的 worker 都只有 1 次(从来没出现过 2 次),而且都发生在该 worker 很早期(sends@event 从 298 到 7100,而一个 worker 一轮要写 4 万次上下)。像是每进程一次左右的一次性条件,不像均匀随机竞态 —— 这条我没有解释,只能先报出来。


    5. 修法结论的修正(我上次推荐错了)

    • (甲)「写对象别在自己的完成回调里自我释放」——只把窗口变窄,关不上。 实测次序里应用回调(Z)在重复包(P)之前 3–8 个 tick 返回,方向是对的;但「重复包什么时候来」没有任何上界(它由 IOCP 出队时机决定),所以甲只能降低命中率,不能消除。
    • (乙) 派发侧幂等才是对的方向,但要注意一个前提:要「丢弃重复包」,libuv 得知道它是重复的,而判断依据只能是 req 的内存 —— 而那份内存在应用看来已经交还了。所以乙不能只加一个 dispatched 位了事,得连内存生命周期一起设计。
    • 就 libuv 而言,我看到的最省、可验证的一条是:对 adopt 来的 socket 不要设 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)。跨进程那一版我可以接着做,说一声就行。

  11. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    跨进程探针:跑吧。 那是你机器上的事,不用等谁批准;下面有两条要你顺手带上的东西,第二条比跨进程那件事更要紧。

    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. 第三臂(自建接受句柄)现在同时判两件事,第二件更重要

    worker 侧:WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED)
               → AcceptEx(监听句柄不关联任何 IOCP,完成包投给接受句柄的端口)
               → SO_UPDATE_ACCEPT_CONTEXT → uv_tcp_open → 照常写
    

    它判的:

    1. 本设计在 Windows 上还留不留一条免转手的路;
    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。

    (乙) 提上游还是那句:要提我得先问维护者,我上一轮已经问了、还没回 —— 它不挡你跑探针。

  12. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    拿到第一现场了:ASan 直出 UAF 栈 · 且「转手是不是必要条件」有对照了(另附我上一条的两处更正)

    按你上一条的意思,我把力气从「数 IAD」换成了「抓第一现场」。结论变了,所以我先说结论,再把读数、更正、以及我没有回答的部分分开放。

    结论一句话:worker 那批 0xC0000374 不是噪声、也不是「每进程一次的一次性条件」—— 它是一条稳定速率、可复现的内存安全缺陷,第一现场是「完成包映射回来的那个 uv_write_t 已经被释放」;而把转手关掉(同一个二进制、只改 MP_WORKERS=4→1)跑 24 万条连接,一次都不出。我上一条给你的速率(1/1.9×10⁵)和「每进程一次」的读法都是错的,错因在 §1。


    1. 先更正我上一条:速率与规模

    错在哪:worker 的日志是 freopen(p, "w", stderr) —— 重启即截断。我 grep INSERT-AFTER-DISPATCH 得到「每个 worker 只有 1 次」,那只是活到最后那个进程的日志里有 1 次;前面死掉的那些生命,日志已经被后一个进程覆盖了。真正的分母在 master 侧:worker N 退出(code=…),重启第 k 次。

    四个 afast 批次合计:

    4 122 946 条成功连接 / 656 次 worker 退出  ⇒ 1 / 6 285
    

    今天新跑的两条(同窗口、同一台机器):

    构建 连接 worker 退出 速率
    我的 libuv 仪器版 166 088 17 1 / 9 769
    干净 Release(run-mp-0922) 168 375 26 1 / 6 476
    干净 Release 167 017 17 1 / 9 824

    两点必须说清:

    • 干净构建一样死 ⇒ 这不是我那套仪器的副作用(这条我自己先排掉了,仪器会改堆布局,不排掉不能往下说)。
    • 退出码两种:3221226356(0xC0000374 堆损坏,约 85%)与 3221225477(0xC0000005 访问越界)。master 会自动重启 worker,所以客户端 ok 从来不塌,只有 err 会涨(同一批跑动里 399 / 597 / 1 026 / 3 240)—— 这就是为什么它一直藏在「一切正常」的读数底下。
    • 于是「很早期、像一次性条件」也作废:那是我按活到最后那个进程的日志数出来的假象。真实形状是均匀速率,而「每次都在很早期」只是因为每个进程都死在它遇到的第一个事件上(§3 的 ASan 臂直接印证:每条约 8 500 条连接就死一次,死了就重启,重启后再跑约 8 500 条)。

    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 记账与截断日志的粒度差)。

    ==1144==ERROR: AddressSanitizer: heap-use-after-free ... 
    WRITE of size 8 at 0x12299c244ce8 thread T1
        #0 uv__poll                       D:\build\deps\libuv\src\win\core.c:495
        #1 uv_run                         core.c:559
        #2 uvcpp::uvcpp_loop::run         uvcpp_loop.cpp:109
    freed by thread T1 here:
        #1 uvcpp_req::free_req            uvcpp_req.cpp:98
        #2 uvcpp_req::~uvcpp_req          uvcpp_req.cpp:16
        #3 uvcpp_write::~uvcpp_write      uvcpp_write.cpp:151
        #5 uvcpp_req::invoke_completion   uvcpp_req.h:172
        #6 uvcpp_write::callback_write    uvcpp_write.cpp:230
        #7 uv__process_tcp_write_req      src/win/tcp.c:1194
    previously allocated by thread T1 here:
        #1 uvcpp_write::uvcpp_write       uvcpp_write.cpp:136
        #2 uvcpp_tcp_client::write        uvcpp_tcp_client.cpp:1252
        #3 uvcpp_http_server::start_write uvcpp_http_server.cpp:1330
    

    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 已经死了。
    • 这块 176 字节(= 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 进程):

    臂 装置 连接 ASan 报告 err
    转手 MP_WORKERS=4 272 523 32 3 240
    无转手 MP_WORKERS=1 243 382 0 0

    转手臂 ≈ 1/8 500;无转手臂 0/243 382(rule of three 上界 1/8.1×10⁴)⇒ 实测 0 : 32,差 ≥9.5×。Release 侧的同一对照(qpsrv-log.exe 单进程 vs mpsrv.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 的栈重排,并纠正我自己上一条的取舍)

    1. (甲)「写对象别在自己的完成回调里自删」—— 我上一条把它说轻了。 ASan 的释放栈就是它(invoke_completion 回调返回后 delete self)。只要把释放移出回调栈(推迟到本轮 uv__process_reqs 走完、或下一个 loop 迭代),uv__insert_pending_req 那 8 字节就落在活内存上:重复包退化成「逻辑上多派发一次」,不再是 UAF。你上一条驳回它的理由(「换个症状,不是防线」)对逻辑那一半成立 —— 但内存安全那一半只有它关得上,而这一半才是 0xC0000374/0xC0000005 的来源。两条不是替代关系。
    2. (乙) 派发侧幂等:uv__insert_pending_req 里那条重复插入断言在 #ifdef _DEBUG 里,release 下没有;第一现场正落在这里。真正要的是「这个 req 已经派发过 ⇒ 完成包直接丢掉」。但判据只能来自 req 的内存 ⇒ 必须先有 (甲),(乙) 才有意义(顺序不能反,否则判据本身是 UAF 读)。
    3. (丙) 不再让转手来的 socket 走同步快路径(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> 就是一次事件的第一现场。
    • Release 侧死亡计数: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\)。
  13. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    你要的两个读数 + 探针血统的答复 + 一条归因更正 —— 以及 (乙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)就是:

    WSADuplicateSocketW(orig, GetCurrentProcessId(), &info);              /* 同一个进程 */
    s = WSASocketW(FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO,
                   &info, 0, WSA_FLAG_OVERLAPPED);

    ⇒ 是第二个分支: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 倍的分辨力):

    order 次序 同步成功却收到完成包
    0 assoc → modes → close(探针原样) 0 / 1 000 000
    1 close → assoc → modes(转手的真实次序) 0 / 1 000 000
    2 assoc → close → modes 0 / 1 000 000

    (同一次跑动里的正对照: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 快照留住了。

    DBL-ENTRY  reqs_pending=1 write_reqs_pending=0 flags=72089
    DBL-EXIT   reqs_pending=0 write_reqs_pending=-1 flags=720a9 --> endgame 会被排队
    

    (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(服务端连接恒挂零长读)不成立。
    • ② endgame 被正常排队了: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"在这条路径上没有发生。
    • 但 −1 是真的,它会静默跳过三个下游分支(行号我在干净树核过):win/tcp.c:1521 的 if (write_reqs_pending == 0) 整块(块内 CLOSING 时的 closesocket、以及 uv__process_tcp_shutdown_req 的唯一调用点 :1529)不会再走;win/tcp.c:1788 writing = 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 次):

    臂 连接 worker 退出 err conns/s
    A-1 / A-2 / A-3 201 496 / 194 232 / 186 227 20 / 17 / 18 449 / 343 / 400 16 513 / 15 918 / 15 262
    B-1 / B-2 / B-3 200 169 / 193 655 / 184 472 0 / 0 / 0 0 / 0 / 0 16 403 / 15 869 / 15 118

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

    臂 连接 worker 退出 ASan 报告 err
    A-1 210 633 27 28 3 472
    A-2 218 474 25 26 3 410
    B-1 289 432 0 0 0
    B-2 292 804 0 0 0

    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 核 ⇒ 读数是服务端真数,不是压测端封顶):

    臂 rps 延迟 mean 服务端核
    A-1 / A-2 351 814 / 342 707 283 / 291 µs 3.97 / 4.00
    B-1 / B-2 341 820 / 335 431 291 / 297 µs 3.98 / 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. 我下一步

    1. 我倾向先不搭 AcceptEx 那条第三臂:它原本的两个用途里,"设计上还留不留免转手路径"现在有了更便宜、且已实测有效的答案((乙1));而"uvcpp_tcp::open 对自建句柄安不安全"这条进程内答不了(§1)⇒ 要答它必须把自建接受句柄搬进真 rig(worker 自己 WSASocket + AcceptEx),那是架构级改动。你要我搭就说一声。
    2. 更便宜的下一步我可以马上做:把 (乙1) 整理成最小改动 + 一条库内用例(uv_tcp_open 一个句柄 → 同步写 → 断言不二次派发;或者在 uv__tcp_set_socket 里对 imported 走 UV_HANDLE_EMULATE_IOCP 那条不用 BYPASS 的路),供你去跟维护者确认要不要进上游。
    3. 我这边不往上游推任何东西(身份只有你仓的只读权限),所有读数都出自我的 fork + 本地装置;要动上游由你或维护者决定。
  14. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    三件都收到,两处我认错、一处我认他的判断。 按你问的顺序回。

    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"这个条件写进去 —— 我推的时候默认了"这条句柄上只有这一个写请求"。所以:

    • 我那句"句柄永远停在 CLOSING / 没有 endgame"作废(0x720a9 里那一位就是 ENDGAME_QUEUED ⇒ 照常关,没有泄漏);
    • 但 −1 我按你给的行号核了我们自己那棵 vendored 树(你的是 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) 被上游拒了才做。设计稿里我这么写的。
    • 你第 2 条(最小改动 + 一条库内用例)去做,但把它定位成给上游的可复现证据包。两个要求:
      1. 用例必须有对照组 —— "没观察到二次派发"在没触发的情形下也绿;对照可以是"关掉开关的臂"或"不 adopt 的句柄"。
      2. 证据包必须写明边界:进程内 1×10⁶ 轮 0 命中只是上界、不是"adopt 安全",能复现的形状是跨进程。上游一定会问"那我怎么信" ⇒ 包里要带你的装置读数(跨进程 1/8 500 + (乙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),你不用改。

  15. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    机制查实 + 两个反证 + 一个坏消息:(乙1) 只能落在 libuv 里,uvcpp 侧"事后撤"已被实测判死

    先给结论:① 那个包是真包、而且是这一次写的(不是复用地址上的旧包)—— 内核侧那条不变式确实破了;② 触发条件不是"跨了进程"—— 我做了一个两进程的最小探针,1 000 000 轮 0 命中;③ 我原想让你的仓自己修,实测不可能:FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 撤不掉,唯一可行的形状是"当初别调那一次"。


    1. 那个包是真的(我仪器里本来就带判别器,这次把它读出来了)

    现场:afast-0922-1143/run1/worker-3.log,那一次异常的三行原文:

    WRITE-PACKET req=…22EEA0 coalesced=2 | entry: status=0 bytes=117 key=5bc ov=…22EEE0 | ov-memory now: status=0 bytes=117
    PACKET: … PACKET-STATUS-VS-OVMEM: 同值 -- 包与 ov 内存一致
    INSERT-AFTER-DISPATCH req=…22EEA0 a=2 b=0 | sends=2568 procs=2568 sync=2568 pend=0 fail=0 |
      ins=10260 insW=2569 iSync=2568 iPkt=1 iRedun=0 iAfter=1 | pkts=7671 pktW=1 pktR=5114
    
    • a=2:第二次插入来自 IOCP 取包路径(src/win/core.c 里 uv__poll 把包转成 req 那次),不是 libuv 自己插的(那条路径是 a=1)。b=0 是多余的。
    • procs 从 2568 变成 2569(同一行里 sync=2568 不变)⇒ 多出来的那次包真的派发了一次。这就是"同一个写 req 被派发两次"的现场。
    • 完成键 = 5bc = 这条 socket,字节数 117 = 本次响应长度,ov = 这个写 req 的 ov(req+0x40)。

    然后是按 req 认人的事件环(不是按 socket —— 按 socket 会被号段复用骗到,我踩过一次):

    t=28207  S  WSASend returned (write submitted)         p=…22EEA0 sock=5bc <req>
    t=28208  D  uv__process_tcp_write_req ENTER (dispatch) p=…22EEA0
    t=28210  Z  req->cb() RETURNED                         p=…22EEA0
    t=28213  P  PACKET DEQUEUED -- the event under study   p=…22EEA0     ← 提交后 6 个刻度
    

    同一个写 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 双进程模式),次序用管道消息钉死,没有竞态:

    父:connect+accept → WSADuplicateSocketW(s, 子进程 pid) ──msg1──► 子:WSASocketW(FROM_PROTOCOL_INFO)
                                                               ◄─msg2─ 子:ack「拿到了」
    父:closesocket(自己那份)        ← "master 收到 ack 随即关副本",就是现场的次序
    父:──msg3(go)──►                子:CreateIoCompletionPort(自己的 iocp) → SetFileCompletionNotificationModes
                                     子:挂零长读 ──msg3b──► 父:send(117 字节请求)
                                     子:(读包) → 同步排水读 → WSASend(117) 必须返回 0 → 0 超时轮询这个写 req 的 ov
    
    臂 内容 同步成功却收到完成包
    nomodes 正对照:不调 modes 5000 / 5000(检出率 100%,不是"看不见")
    modes 与现场同:blob 血统 + 关联 + modes + 关副本在关联之前 0 / 1 000 000
    modes pz 同上,且送之前挂一个待完成的零长读(= 现场 send 时刻的句柄状态,flags 里含 `READ_PENDING ZERO_READ`)

    加上进程内的 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 只翻一个环境变量):

    臂 连接 worker 退出 ASan 报告 客户端
    A(=今天的行为) 226 570 / 226 275 22 / 23 23 / 23 11 215 / 11 201 conn/s
    B(撤捷径) 0 / 0 0 / 0 0 / 0 conns=0 err=0

    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 收到的包
    none 从不调 modes 2000 / 2000(正对照)
    skip 两个位都设(= libuv 做的) 0 / 2000
    clearev 之后再调一次,只带 FILE_SKIP_SET_EVENT_ON_HANDLE(返回 TRUE) 0 / 2000
    clear0 之后再调一次,flags = 0(返回 TRUE) 0 / 2000

    ⇒ FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 是单向的、撤不掉(文档里没写;两次"清除"调用都成功返回,行为不变)。所以"事后撤"这条路是死的,唯一可行的形状是当初别调那一次 —— 就是你记的 (乙1),而它必须在 libuv 的 uv__tcp_set_socket 里(干净树 src/win/tcp.c:119-125,v1.51.0)。

    对你的仓的直接含义:uvcpp 修不了这个缺陷,能选的只有三条:

    • (a) 上游 libuv 提补丁(形状:imported 的句柄不设 skip)。代价我已经量过:≈ −2.5% rps / +8 µs,而且那是"100% 连接都转手"的上界,混合部署按占比摊。
    • (b) 把 libuv pin 到带补丁的 fork/tag —— 你 CMake 里已经有 UVCPP_BUILD_LIBUV_FROM_SOURCE + LIBUV_VERSION,这条路对你零新增机制。
    • (c) 改设计让 worker 自己 accept —— 这条我劝退:Windows 上多个 worker 共享监听是不公平的(我量到过 0/0/25/75 那种分布),那正是当初走转手的原因。

    另有一条我没测的逃生口,先记着别当结论:事先用一个一次性端口把 socket 关联掉,让 uv_tcp_open 里的关联失败 ⇒ libuv 自己置 UV_HANDLE_EMULATE_IOCP 并跳过 modes 调用(于是不设 skip)。代价是每个 I/O 多一个 event handle + 一次 RegisterWaitForSingleObject,而且它骑在"重复关联会被拒"这条没文档的语义上。要走这条我可以先量。


    4. 我接下来

    1. 把 (乙1) 整理成最小 libuv 补丁 + 一条库内用例给你。先说清这条用例的能力边界:它只能断言状态("uv_tcp_open 之后句柄上不该带 UV_HANDLE_SYNC_BYPASS_IOCP"),不能断言那个缺陷 —— 要让它变红就得让那个包稳定出现,而那个包我用 8×10⁶ 轮都造不出来。缺陷本身的复现装置是这个 rig,不是单测。你如果去跟维护者谈,这条边界最好先摆在桌面上(对方一定会问"测试呢")。
    2. 口径建议:这条不变式在 libuv 里没有任何文档承诺,uv_tcp_open 的文档也没写"这个句柄会走同步快路径" ⇒ 按"未文档化的假设 + 实测破坏"说,别按"回归"说。
    3. 我这边不往上游推任何东西;装置和读数都在我的 fork + 本地构建树里,要动上游由你或维护者决定。

    5. 装置与命令(都可复现)

    # 跨进程 1e6 轮(父进程自己起子进程;正对照 / 现场状态臂)
    cd /d/build/allocprof && ./skip5.exe 1000000 modes        # 0/1e6
                             ./skip5.exe 5000    nomodes      # 5000/5000(正对照)
                             ./skip5.exe 1000000 modes pz    # 0/1e6(送之前挂待完成零长读)
    # 构建:cmd //c D:/build/allocprof/b-skip5.bat     源码:skipon-success-probe5.cpp
    
    # skip 位能不能撤(四臂)
    ./clrmodes.exe {none|skip|clearev|clear0} 2000     源码:clrmodes-probe.cpp
    
    # (乙1) 的 ASan A/B(libuv 侧,改的是我本地 libuv 树里的一个 getenv 分支)
    bash asan-eb1.sh 20 64 4 2        # RUN=/d/build/qps/run-asan-eb1
    
    # uvcpp 侧"事后撤"的 A/B(已撤;patch 留档 archive-uvcpp-undo-bypass-attempt.patch)
    bash wa-asan-ab.sh 20 64 4 2      # RUN=/d/build/qps/run-asan-wa

    判别器那三行日志在 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。

  16. 53 remaining items

  17. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    §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 出口::299 UV_EINVAL(n<1 或 >64)、:300 UV_EBUSY(已经在听)、:306 UV_EBUSY(装过一遍了)、:333-339(某条 worker 起不来,已经 stop_workers() + 置 TCP_SERVER_ERROR)。吞掉它 ⇒ 净层 1 条循环、webapp 按 n 定尺寸=静默单循环:请求全在 0 号上跑,按循环切的表永远只用 0 号那一格 —— 而这恰好让 §2 那条 h2 abort 不响(全在 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。

  18. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    六条我对着源码逐条核过,全部成立。 行号口径: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::299 UV_EINVAL、
    :300 UV_EBUSY(已在听)、:306 UV_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_ 进格。

  19. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    §4 我撤回 —— 而且这个裁决你自己的代码里早就写着,我没读到。 begin_h2_goaway() 的函数内注释(src/web/uvcpp_http_server.cpp:1570-1572,9cb2794 与 3f48f2b 同)写着:

    只翻本循环那张表(多循环):表是按循环切的,从一条循环上去翻另一条循环的表就是数据竞争。所以调用方要在每条循环上各道一次别(webapp 的停机正是按循环推进的)

    ⇒ 每循环一份 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)本循环就地关、其余各投一条,返回值语义写在头文件里("本循环发起关闭的条数",不是"关掉多少")。
    • 共享状态字靠 atomic RMW:set_status()(:657-661)注释:"n>1 时两边各写回自己那份旧快照就会互相抹掉"。

    判据我同意你写的方向,但把它说成"这份状态要被谁读"更省事:只在本循环读 ⇒ 切份(contexts_);要跨线程按 id 读 ⇒ 单表 + 锁(clients_ 的答案)。

    ★所以 registry_ 切份时有一个破例点:connection(id)(src/webapp/uvcpp_web_app.cpp:1931-1932 registry_.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) 只在 0 号(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。

  20. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    行号口径先立好:我这帖一律按 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 :3068 全进程一次
    stop_loop() :3074(http_->get_tcp_server()->stop_loop()) 只在 0 号
    http_ / TLS 那一层的收尾 —— 全进程一次
    delete slot.idle_timer / delete slot.shutdown_timer :3035 / :3041 每循环本格
    slot_here() 那格的 async + 丢队列 :3048-3056 每循环本格

    finish_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),而第一次进来就在 :616 clear_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 C 0、warning C 0。
    • ctest -C Release:98/98 通过(143.6 s)。
    • 页面堆门禁 run_pageheap_gate.py --tree build-h2:98 个用例裸跑与完整页堆下都绿、抓到 0 个、其他异常 0 个、过期 DLL 0 个、rc=0。

    还有一条我这轮撞上的、跟你的 §4 是同一族的,而且它有两个面:

    1. 新写的引用会静默过锁。 本批往设计稿里新加的引用(这一笔的锁 diff 里是 28 条新键)里有 2 条(我自己写错的号)六条判据一条都没响 —— 因为锁是拿引用自己的区间算出来的(判据 3 对新键是空转的),而 --update 的报告只覆盖已经在锁里的键(want is None 的新键直接静默写入)。我是靠"拿锁的键集和 HEAD 做差、再逐条人眼对"才把这两条翻出来的。
    2. 区间内部变更会被静默重哈希,并且把区间截断。 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。

  21. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    口径先对齐:你说的 :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 形":最快那一格把最慢那一格吃掉了。

    两件一起改就能同时满足"每格都跑完"和"全进程只做一次":

    1. begin_shutdown() 的门换成本格的"已进入停机"(slot.shutdown_phase 或一个 per-slot 标志),别再读 loop_started_ —— 那个量是"停机是否已完成"的全局结果,不是"本格要不要开始"的判据,拿它当门就是上面那个拆台;
    2. 全进程那三件改计数:每格在 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、不变宽度,同宽候选永远不存在。
      这类"内部插入"有两条补法(都不用动判据语义,只加"人看得见的东西"):

      1. 锁里除区间哈希再存"首行文本哈希 + 末行文本哈希"(或行数)。内部插入会让末行变(167 处现在是旧 165)⇒ 重刷时"末行哈希变了"就能判红,并直接告诉人"区间边界要重定";
      2. 便宜一半的版本:[刷] 那一行把当前区间的首行/末行原文打出来(现在只有两个哈希,人眼对不了)。

      你那条"先造一个真会级联的输入让它红"的规矩,这两条也适用:造一个"+2 行插在区间内部"的样例,今天它会安静地重刷;补完之后应该印出"[刷](末行变了)"或那个 [新] 计数。

    5. 我这边

    等你 step 3。你把"每格原子计数"和"跑着的时候读聚合量"那条用例放同一笔落地是对的 —— 那正是"n==1 等价 ⇒ 变异恒绿"的反面。

    没推任何东西、没开 PR。

  22. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    补一条 —— 是上帖 §4 那半的实测,不是新话题。

    上帖 §4 我是读着工具说的;按你我那条规矩(判据得能说出它在什么情况下会是错的),我给它搭了个最小台子:一个 md+一个源码+一份锁,跑完再改。台子是 2 行插在区间内部 这个输入 —— 就是你那条"先造一个真会级联的输入让它红"。两条臂:

    臂 1(对照,区间上方插 2 行):插入后直接判 rc=1 红;--update 印

    [移] src/m/a.cpp:10-20 -> src/m/a.cpp:12-22(内容逐字节没变,整体挪了 +2 行)
         **doc/x.md 第 3 行写的 `src/m/a.cpp:10-20` 也要一起改成 src/m/a.cpp:12-22**
    

    再判仍是 rc=1 红 —— 因为文档里那条号没改,锁里就没有 10-20 这条键。⇒ [移] 的尾巴是自封的:人不动手,它不会自己变绿。

    臂 2(区间内部插 2 行):插入后直接判也是 rc=1 红(判据 3 报出来),--update 印

    [刷] src/m/a.cpp:10-20 的内容真的改了(锁 d470ede2be3b0aff,实际 afa7c4f2f018583e)
         人工核一遍引述还对不对
    

    再判 rc=0 绿,锁里那行仍是 src/m/a.cpp:10-20 afa7c4f2f018583e。

    ⇒ 精确的说法是:两条臂在插入那一刻都会红(判据 3 都抓得住),分岔在"处方跑完之后" —— [移] 留在红上直到人改文档号,[刷] 直接绿到底、而引用永久短两句。所以这不是"门禁看不见",是"门禁给的那条处方把证据吃掉了",也就是上帖说的那个缺口;我上帖说"[刷] 说不出比原来短两行"是同一件事的另一种说法,现在有数了。

    一条改法,且是接进现有 [移] 机器,不是新判据

    锁里除整区间哈希再存区间首行、末行的哈希。整区间对不上时,去新文件里找这两行的哈希落在哪:

    旧首行推了 旧末行推了 判
    d d 整体平移,交既有 find_shift_candidates(已自封)
    0 d>0 区间内部插了 d 行 ⇒ 新键取 s-(e+d),走 [移] 那条路 ⇒ 文档号不改就一直红
    其它 退回 [刷]

    同一台子上实测这两行输出的就是上表:

    • 上方插 2 行:旧首行推 [2] / 旧末行推 [2] ⇒ 整体平移 2
    • 内部插 2 行:旧首行推 [0] / 旧末行推 [2] ⇒ 内部插入 2 行,新键 10-22

    代价只是平移扫描之外再一次 k 扫描(有界),判据语义不动;[刷] 仍然留着给"真改了内容"用,只是它不再是内部插入的收容所。顺带这也修了上帖提的另一半:[刷] 那行把首尾原文打出来,人眼就能判"这段是不是短了"。

    我这边台子是 D:\build\allocprof\docshift*(三个树:原始、上方插、内部插),随时可以按你的锁格式重跑一遍。

    没推任何东西、没开 PR。

  23. Antruly commented on Sep 22, 2026

    @Antruly
    Owner

    口径:这一笔是 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 对照组 + 逐个守卫变异)。定稿那版实测:

    变异 结果
    M0 对照组(未变异) 绿 —— 归因成立
    M1 拆掉停机扇出 抓 7 条
    M2 WS 停机只让 0 号发 Close 抓 3 条
    M3 WS 分片键钉成 0 抓 2 条
    M4 connection_count_at 不看下标 抓 3 条
    M5 缓存重插不扣旧字节 抓 1 条
    M6 缓存 put() 不拿锁 抓不住(无 sanitizer,如实记)

    循环泄漏探针(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 C 0;ctest 99 个 100% 通过(新用例跑在第 49 位,输出到 全绿,不是 skip);页面堆门禁裸跑 + 完整页堆都绿、抓到 0;文档三条门禁 + 片段门禁全过(61 条片段编过,77 条标 fragment)。

  24. sercebr commented on Sep 22, 2026

    @sercebr
    CollaboratorAuthor

    口径:我手上是 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()。
    • 你说的"§2 以代码为准"我理解,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 那条同形(缺的就是一致性):

    1. 在 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;);
    2. 或者,如果"重试"这条路对 n>1 本来就不打算支持,那就只做 1 的一半(标 orphaned + 停 workers)并把 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)不接返回值:

    tcp->set_loop_start_hook([this](int index, uvcpp_loop* loop) {
      init_loop_state(index, loop);          // 返回值丢了
    });

    ⇒ 某条工作循环建 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 多循环写成"不在本用例里")。更要紧的是它没有 #else SKIP 分支(#if UVCPP_WEBAPP_ENABLE … #endif 直落 :1318)⇒ 宏缺了是链接错误,不是"注册、通过、一个断言都没跑"。这正是那道过滤器注释里那次事故的反面。
    • 对照组那两条(M0 红就宣布自己的归因不成立,multiloop_mutation.py:309-310;M4 的采样点挪进处理函数 + 前提断言 at1 >= 1)—— 尤其"时红时绿的判据等于没判据"这句,比那条变异本身值钱。M6 如实写"抓不住"也对;那条诚实行(:3183-3186)一样。
    • 泄漏探针这次的修法比"修好锚点"更值钱:run_loop_leak_probe.py:264 的 assert cur.count(o) == 1 —— 探针自己的锚点也得有判据。一个打不响的探针和全绿长得一样,这句话你已经在 :172-176 里写下来了。
    • §3 你说的"原来那句理由不成立"我核了,方向对,而且能说得更准:~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。

  25. Antruly commented on Sep 23, 2026

    @Antruly
    Owner

    口径:这一批已经落到 master 的 a73c267(17 个文件);行号按这一版核过,你手上是 5dc9f21,所以数会对不上。第 2、3、4 条按你说的一起落。

    1. 形状:保留 loops_running_,两处加固都做了

    • 没有加第二个显式标志,理由同意(同一件事两个来源正是这一批在消的东西)。
    • note_loop_started(int index) 那个 (void)index; 现在真用起来了:loops_running_ 的两个增减点各由按格 CAS 把门 —— 自增侧 loop_slot::started"记过就不再自增",自减侧 finished CAS 之后再加一句 if (!slot.started.load()) return;"没记过就不许减"。
    • 你那段"偶然安全"的推演我逐句核了源码,完全成立(唯一失败点 slot.async->init() 就在 note_loop_started(index) 前一句;async == nullptr 的格子 post() 是丢弃 + WARN,进不了停机状态机)。这段写进 loop_slot::started 的注释了,含"以后谁在 init_loop_state() 里加一句可能失败的调用会发生什么"。
    • 选了 CAS 而不是 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() 成功那条路上置位:

    不是拿 TCP_SERVER_LISTENING 凑的。那个状态位在 bind() 成功时就置了,而 bind() 与 listen() 之间正是这个回滚唯一该被放行的窗口——拿它当门会反过来挡住本该放行的回滚。

    webapp 侧:teardown_failed_start(int rc),四条失败路径共用(set_loops() 返非 0 / 钩子里 init_rc != 0 / bind() / listen()),做两件事:

    1. tcp->rollback_loops();
    2. 逐格 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() 对称的公开钩子

    void uvcpp_tcp_server::set_loop_exit_hook(std::function<void(int, uvcpp_loop*)> fn);

    在 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())共用同一份、幂等:

    void uvcpp_web_app::release_loop_handles(int index);   // idle_timer → shutdown_timer
                                                           // → ws 分片 → async/投递队列

    于是两种结束方式收在同一个时间点:循环内存被释放之前,且在那条循环自己的线程上。既不会 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;ctest 99/99 全绿。
    • 变异 M7/M8 —— 判据本身被我改了,得说清楚。 原来写的是"M7 应绿、M8 应红"。实测这条判据是坏的:注入的故障是"1 号格初始化失败",而 1 号格是同 exe 里每一条 n=2 用例都要用的 ⇒ 修好的代码在 M7 上让每一条 n=2 用例的 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 条在同两个文件里,一次复核到位了。

  26. sercebr commented on Sep 23, 2026

    @sercebr
    CollaboratorAuthor

    口径: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 那个分支做的是:

    if (hrc != 0) {
      delete async_; async_ = nullptr;
      if (loop_ != nullptr) { loop_->loop_close(); delete loop_; loop_ = nullptr; }
      ready->set_value(hrc); return;
    }

    它上面那句注释(.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:其余四条核过,成立

    • §1 那对 CAS:note_loop_started()(:1976-1980)与 note_loop_stopped()(:1990-1998)两侧都幂等,"没自增过就不许减"在 finished CAS 之后、fetch_sub 之前,次序对。你那段"偶然安全"我复核仍然成立(async->init 恰在 note_loop_started 前一句)。CAS 而不是断言这个取舍我同意,理由你写得比我想的准。
    • §4:uvcpp_ws_server.h:126-128 / :139-143 两条 @warning 与 client_count_at(slot.index)(:3500-3505)都对;不加 close_all_sessions_everywhere() 的理由("全关"会把"关闭是循环亲和的"这条掩盖掉)我同意。
    • 判据 7:把真正的红面定成 !loop_started()(:1312-1315)、并写明"再 start() 返 UV_EBUSY 单独不足以证明回滚做了事"(:1322-1325)—— 这两句都对,第二句尤其。
    • 判据 8 自陈"未变异树上恒真"、M7/M8 判据换成判别式并把"原来那条红绿判据为什么是坏的"写进脚本头部 —— 这是这一笔里我最想学的部分。
    • 文档 §4.5 两条新公开 API 写全了(含"钩子里只关句柄:别停循环、别 post()、别阻塞")。

    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 条顺手就好。

  27. Antruly commented on Sep 26, 2026

    @Antruly
    Owner

    先说结论:第 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,这个我实测过。有两道互相独立的防线:

    1. uvcpp_loop::loop_close() 只在 uv_loop_close() 返回 0 时才置 closed_。属主的
      async 还开着 ⇒ 返回 UV_EBUSY ⇒ ~uvcpp_loop 走
      if (!closed_ && uv_loop_close(loop) != 0) { detach_handle(); return; } 那一支 ——
      它故意把那整块循环内存记成泄漏(注释里就写着"泄漏一块仍然有效的内存,换掉一个悬垂")。
    2. 就算没有第 1 条,~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() 之前,所以记账那圈是抵得平的)。

    臂 形状 D1 循环内存泄漏 D2 属主未被通知
    A 真·修前 on_exit_ 不跑、泵不跑 1 1
    B 半修 on_exit_ 跑、泵不跑 1 0
    C 修好 on_exit_ 跑、泵跑 0 0
    • D2 = 回滚之后属主那几格里还有非空 async。A 与 C 必须不同,否则判据没牙 ⇒ 它把
      A 和 C 分开了。
    • D1 = ~uvcpp_loop 那条泄漏支被打到。B 与 C 必须不同 ⇒ 它把 B 和 C 分开了,
      也就是说你写的**"泵这一步不能省"是实测出来的**,不是推断。这一条我照抄进了代码注释。

    补一句装置上的教训:我第一版的"对照组"只替换了能锚到的两行,锚点上方那句
    if (on_exit_) { on_exit_(); } 活了下来 —— 于是那一轮跑的其实是第三种形状(钩子跑、泵没跑),
    给出的读数与源码直接矛盾。那个矛盾才是我回头去读 ~uvcpp_loop 的原因。


    2. rollback_loops() 的返回码与次序 —— 属实,下一笔

    你说得对,而且是两件事叠在一起:

    1. uvcpp_tcp_server.cpp:473 的 if (listen_called_) return UV_EBUSY; 让
      rollback_loops() 可以拒绝回滚,而唯一调用点 uvcpp_web_app.cpp:2098
      tcp->rollback_loops(); 把返回值丢了 —— 拒绝了也没人知道。
    2. 紧接着 :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-log 113/113、build-h2 109/109。

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