Skip to content

Latest commit

 

History

History
671 lines (534 loc) · 50.4 KB

worker-process-design.md

File metadata and controls

671 lines (534 loc) · 50.4 KB

多进程横向扩展设计(set_worker_processes)

状态:设计稿,未实现。 本文定的是形状与判据,代码还没有。 文里的 src/...:行号 引用由 check_doc_lines.py 锁着 —— 改代码时门禁会把该一起改的地方顶红。

这一篇讲打算怎么做。做成之后怎么用,会进 webapp 应用框架开发者指南。

1. 形状

接入成本是 main() 里的一条语句,外加一个 setter(它的返回码要接,见下):

// doc-snippet: fragment — 目标形状,不是可用代码:uvcpp_worker_process 与
// set_worker_processes() 都还不存在(本页状态是"设计稿,未实现"),handler 也是占位。
#include <webapp/uvcpp_web_app.h>
using namespace uvcpp;

int main(int argc, char** argv) {
  uvcpp_worker_process::split(argc, argv);  // ← 必须是 main 的第一条语句

  uvcpp_web_app app;
  app.set_host("0.0.0.0").set_port(8080);
  // **不能链式**:`set_worker_processes()` 返的是错误码,与 `set_loops(n)` 同形状
  // (理由见 §1 末)。Windows 上 n > 1 就在这里拿到非 0,服务根本不会起(§6)。
  int rc = app.set_worker_processes(4);     // 不调用 = 1 = 今天的行为
  if (rc != 0) return rc;

  app.get("/hello", handler);

  app.start();                              // worker:接住继承来的监听 fd
  app.join();                               // worker:跑完就退进程,不返回
                                            // master:监督循环
  return 0;                                 // 只有 master 走到这一行
}

角色的自动判定发生在两处,用户都不用管:

worker_process::split() start() / join()
master 认出"我不是 worker",返回 bind+listen 持住端口;join() 起 worker 并监督
worker 认出"我是第 k 个",记下身份,返回 接住继承来的 fd;join() 跑完就 _exit(0)
不启用(默认) 一个分支都不走 今天的行为,逐字节相同

uvcpp_worker_process 由库提供全局实例。它是全局的,有两个硬理由:身份必须在 app.start() 处可见;分裂点必须早于任何用户线程。

三个名字已定:uvcpp_worker_process / split() / index(),setter 是 set_worker_processes(n)。 两处取舍写在这里,省得以后有人问"为什么不是别的":

  • 不叫 run() —— uvcpp_web_app::run(uv_run_mode) 已经有别的意思了,同一个库里两个 run 含义不同会混。split 说的是它真正干的事。
  • class 不叫 guard —— 它同时管 master 与 worker 两个角色,不只是"守卫"。 叫"工作进程类"是按使用者的说法来的。
  • 不需要用户自己声明全局变量:库内部持有那份全局实例。用户声明会多一处可以写错的地方, 而"必须在 main() 第一条语句"这条纪律已经够容易违反了。
  • set_worker_processes() 返 int(0 成功,非 0 是错误码),所以不能链式。 与 set_loops(n) 同形状(doc/multiloop-design.md:58-59 已为同名 setter 定过这条)。 §6 那条"Windows 上 n>1 返回错误"要有地方报 —— 链起来写就没有地方接错误码, 两处自相矛盾时以 §6 为准。

还有一条没定:多进程横向扩展进不进 README 的性能口径那一行。我的判断是先不进 —— 它是尚未实现的设计,而 README 顶上那个数是和 hical 做对比的口径,扩进来会把"单核比单核" 这个前提弄糊。实现之后单开一节写扩展性,单核那行不动。

2. 为什么是重跑 main(),不是"在那个方法里把服务跑完"

这一条决定了整套机制的形态,而且它是从"路由注册写在哪"推出来的,不是选出来的:

路由是在 worker_process::split() 那一句之后注册的。 所以 worker 进程必须把用户的 main() 走完才拿得到路由表 —— run() 那一刻它还不知道有哪些路由,没法替用户把服务跑起来。

于是只有两种做法:

做法 结论
(a) worker 重跑 main:POSIX fork 从那一句继续往下跑 采用
(b) 把路由注册提到那一句之前(全局构造期注册) 否 —— 依赖静态初始化顺序,脆弱

由此推出两条对用户可见的语义:

  1. worker_process::split() 在 worker 里必须返回(否则后面那段注册代码不跑)。
  2. 「worker 跑完就退进程」落在 join() 上:worker 的 join() 永不正常返回,直接退进程。

注意是 join() 而不是 start() —— start() 按今天的语义是"起后台线程、阻塞到 bind 有结果为止",它会返回。真正阻塞的是 join(),所以语义分叉点在那里。

3. POSIX(Linux / macOS):零通讯

master 只做一件事:把端口占住。 它走今天已有的 bind 链 (src/webapp/uvcpp_web_app.cpp:2245-2245 → src/web/uvcpp_http_server.cpp:640-641 → src/net/uvcpp_tcp_server.cpp:387-392 → src/handle/uvcpp_tcp.h:59-60)建出监听 socket, 然后不跑循环 —— uv_listen 会在 listen(fd, backlog) 那一步就把端口占住, 而 master 的 loop 从不 uv_run,所以它永远不会 accept。于是"master 不接请求"是天然的, 不需要额外机制。

worker 继承那个 fd,各自 uv_tcp_open + uv_listen。 所有 worker 阻塞在同一个 accept 队列上,内核叫醒一个 —— worker 之间零通讯、零共享内存、零路由复制。

这条路在 libuv 上走得通,我核过三处:

  • uv_tcp_open 只要求该 fd 不被同一个 loop 重复持有(libuv:unix/tcp.c:352-365);
  • maybe_new_socket 在句柄已有 fd 时直接返回、不重建 socket(libuv:unix/tcp.c:85-110);
  • listen() 对已经在监听的 socket 是合法的,只更新 backlog(libuv:unix/tcp.c:440)。

本库这边已经有现成的口子:src/handle/uvcpp_tcp.cpp:53 的 uvcpp_tcp::open 就是 uv_tcp_open。

分裂用 fork()。 不用 re-exec,三条理由:fd 自动继承(不需要传 fd 号、不需要清 FD_CLOEXEC);写时复制便宜;用户的全局构造只跑一遍(子进程直接继承那份已经构造好的 全局状态),而 re-exec 会让全局构造也跑 n+1 遍。

fork() 的代价是一条必须写进文档的约束:fork 点必须在任何用户线程起来之前。 worker_process::split() 已经在 main 的第一条语句,处在最安全的位置;但如果用户的全局 构造器起了线程或连了库,这条就破了(fork 之后子进程里只有调用线程活着,别的线程持有的 锁在子进程里永远不释放)。这是用 fork 必须付的账,nginx 也是同一条规矩。

4. master 只监督

master 不接请求、不进请求路径。它的 join() 是一个监督循环:起 worker、等 worker (waitpid)、死了拉起、收到 SIGTERM 时先停 worker 再退。

这条性质是运维侧的前提 —— master 不持有连接状态,才有资格在 worker 之外独立地做重启与 换班。它与 nginx 一致。

5. 三条对外约束

5.1 不调用 set_worker_processes() = 今天逐字节相同

不 fork、不 exec、不碰 fd、不看环境变量。这条要能变异证明:把 n=1 那条分支改坏, 现有用例里必须有一个红。没有这条,"默认没开"就只是一句承诺。

5.2 用户的 main() 会跑 n 遍

worker 从 worker_process::split() 那一句继续往下跑(§2),所以用户写在 main() 里的一切 每进程一次的初始化 —— 连数据库、开日志文件、读配置、建线程池 —— 会在每个 worker 里各跑一遍。逃生口是

// doc-snippet: fragment — 同样是目标形状:这个静态方法还没实现。
uvcpp_worker_process::index();   // -1 = master,0..n-1 = worker

这是这个形状唯一要用户操心的点,必须写进使用文档。它也是这条路线相对"单进程多循环" 的结构性代价:nginx 能做多进程是因为它拥有 main(),而本库是被嵌进别人的 main() 的库 —— 框架替用户起进程这件事本身逆着库的形状。

(fork 让全局构造只跑一遍,所以"每进程一次"的重灾区是 main() 里那段,不是全局 初始化。这是 §3 选 fork 而不是 re-exec 的直接好处。)

5.3 worker 退出用 _exit()

worker 的 join() 跑完循环后直接 _exit(0):跳过用户的 atexit 与静态析构 —— 这正是"不跑后面的代码"要的语义。代价是框架必须在退出前自己 flush(日志、统计), 不能指望用户的清理代码。

6. Windows:这一版 n>1 直接报错

set_worker_processes(n>1) 在 Windows 上返回错误、拒绝启动,而不是退化成"只有一个 worker 真干活"。

这条是有意的。Windows 上 fork 与免通讯的共享端口都不存在:UV_TCP_REUSEPORT 被 libuv 无条件拒绝(libuv:win/tcp.c:298-299 直接 return ERROR_NOT_SUPPORTED;), 而 libuv 在 Windows 故意不设 SO_REUSEADDR(libuv:win/tcp.c:277-287,注释写的是 它"effectively allows 'stealing' a port which is in use by another application")。

于是静默退化是这条路唯一危险的失败方式,而它长得和成功一模一样 —— nginx 在 Windows 上就是这个现状(官方已知问题:几个 worker 都能起,但只有一个真的干活)。报错是响的,比 静默好。旁边那条路(把 listener 复制给各 worker、各自 uv_listen)也验证过了:两个 worker 恒定为 0 条连接、rps 单调恶化,而每个 worker 都打 rc=0。

Windows 要真正能用,得走 master 自己 accept() + 每连接 WSADuplicateSocketW 转手。 那套的账写在 §7,等两件事查清再上。

7. 不做 / 没解决

  • Windows 的转手机制(master 接受 + WSADuplicateSocketW + 管道 + ack)。它的代价是 master 变成单接受者(它死了整个服务就停)、每条连接多一次 IPC 与 ack 往返, 并且 master 必须排空 backlog(一次唤醒 accept 到 WSAEWOULDBLOCK)并把转手 pipelined 化。上它之前要先查清的两件事已经查完了,结论在 §8;但这条路上后来又冒出 第三件事,而且是拦路的 —— 见 §9。
  • 不解决 contexts_ 那一族。 多进程形态下它们天然正确:每个 worker 一份,就是今天的 n=1 语义。uvcpp_http_server 的 contexts_(src/web/uvcpp_http_server.h:1844-1844)、 uvcpp_web_app 的 upgraded / inflight(src/webapp/uvcpp_web_app.h:2281-2281 / src/webapp/uvcpp_web_app.h:2222-2222;这两个容器后来在多循环那条路上被搬进了 loop_slot,见 doc/multiloop-design.md §4.1)、 uvcpp_tcp_server 的 clients_(src/net/uvcpp_tcp_server.h:967-967)都不需要切成 per-loop。 这是选这条路白拿的最大一块。
  • 日志那条闸门在进程内照样存在。 1.4.0 之后 src/webapp/uvcpp_log.cpp:416-436 的 write() 已经不持锁调 sink 了(锁内只快照一次 sink(),:342-351),但默认 console sink 自己那把锁与锁内的 std::string 拼接 / fwrite 仍在( src/webapp/uvcpp_log_console.cpp:193-256)⇒ 同一条进程内,打日志的线程们仍然在 这一处排队。多进程不改变这一点,多进程改的是别的东西(见上面的闸门表)。
  • work_limit_ 仍按进程推。 src/webapp/uvcpp_web_work_limit.cpp:222-226 是 UV_THREADPOOL_SIZE × 4(下限 16),与进程数无关 ⇒ n 个 worker 下它仍然是对的数。 要写进文档的是另一件事:n 个 worker × 每进程 4 条池线程 = 4n 条,调 UV_THREADPOOL_SIZE 时按每进程算。
  • 统计要跨进程聚合。 uvcpp_tcp_client 的 reclaim_stats() / try_write_stats() (src/net/uvcpp_tcp_client.h:816-816 / src/net/uvcpp_tcp_client.h:844-844)本来就是 static 的进程级聚合, 多进程下变成"每进程一份",要看总数得自己聚合。
  • 那个突发探针不进仓库、不进 CI。 它要断言的失败模式("队列满了会拒连接")在这台机器上 基本不出现(§8),而且"满了之后是被拒还是挂住"本身随平台变(POSIX 给 RST、 Windows 挂住)⇒ 写成用例只会得到一条在 CI 上恒绿、换个平台恒红或恒绿、断言的东西还不是 被测物的用例。结论留在 §8 的文字里,装置留在仓库外的 _probe/。

8. 突发拒连接:实测结论

§7 说要先查清两件事:外部贡献者读到的突发拒连接,以及本库自己的 accept 路径在突发下的 表现。都量完了。

装置:_probe/burst/burst_probe.cpp(仓库外,链 build-h2 的库)。三臂,同一突发形状、 同一 backlog,只换"谁在 accept":

臂 是什么
nodrain 裸 listen(backlog),一声不接 —— 对照组
rawdrain 裸 listen(backlog) + 紧循环 accept() 立刻关 —— 排空上限
uvcpp uvcpp_web_app + set_backlog(backlog),走真实接入路径

下面的每个数都来自一次落盘的运行(_probe/burst/log.txt,仓库外),不是回忆。

读数一(对照组成立):队列填满之后,客户端拿到的是"挂住",不是"被拒"。 nodrain、backlog 128、4 线程一批压 512 条:ok=128 / refused=0 / timeout=1920。 128 恰好等于 backlog —— 队列确实被填满了,而被填满之后多出来的那些永远停在 SYN_SENT。⇒ 判据必须把超时和被拒分开记:只数"拒连接条数"在这台机器上 几乎什么都测不出来。

读数二:本库的接入路径在突发下不失败。 10 轮、合计 35 328 条连接, refused=0、timeout=0,服务端 accepted 与客户端 ok 逐轮相等:

形状 结果
峰值并发 512(2×3×256),backlog 128 1536/1536
峰值并发 2048(4×3×512),backlog 128 6144/6144
峰值并发 4096(4×4×1024),backlog 128 16384/16384
峰值并发 512,backlog 8(队列 8 槽)×3 轮 1536/1536 三轮
峰值并发 512,backlog 1(队列 2 槽)×3 轮 1536/1536 三轮

backlog=1 那一格是这条结论的承重证据,因为它是机制不是空读数:队列只有 2 槽、 同时压 512 条而全部完成 ⇒ 排空速率远超到达速率,连最尖的形状都不需要"排空到 WSAEWOULDBLOCK"来救。反过来,"峰值并发 4096 也全过"这一格的说服力弱于它看起来的样子 (见读数三的尾巴:溢出在这台机器上本来就多数表现为挂住而不是拒绝)。

读数三(平台事实):Windows 上 listen(n) 的队列深度是 n,n>200 之后封顶在 200。 nodrain 逐档量,ok 数即实际队列深度:

backlog 8 32 64 100 128 200 201 256 512 1024 4096
实际 8 32 64 100 128 200 200 200 200 200 200

⇒ set_backlog(>200) 在 Windows 上是静默无效的(默认 128 在限内,所以今天没问题)。 "backlog 给足"这条建议在 Windows 上有硬顶,写进文档要带这个数。

这条的尾巴:「这台机器不给 RST」不是平台不变量 —— 拒连接出现过,只是不可复现。 11 档逐档扫的那一轮里,backlog=4096(单批深度 4128)那一格是 ok=200 / refused=534 / timeout=3394。两臂归因(把"backlog 值"和"单批深度"分开)都归不到:

归因臂 refused
backlog 128,深度 4128(同深度、小 backlog) 0
backlog 4096,深度 1056(同 backlog、小深度) 0
backlog 200,深度 4128 0
backlog 4096,深度 4128 —— 原配置复现两次 0 / 0

⇒ 两个候选变量各自中和之后都没复现,原配置重跑两次也都没复现。能说的只有: 深突发下偶发,1/15 轮,与 backlog 值和批深度都不单独对应(剩下的嫌疑是连跑累积的机器状态, 没验)。所以 §7 那条设计不能假设"拒连接不会发生",只是不必把它当常态失败模式。

这批没有证明的事(别读成结论)

  • 吞吐对比不作数。 同一形状(4×1×512,backlog 128)下 rawdrain 是 705.5 ms、 uvcpp 是 203.9 ms;而更早的批次里排序是反的。机制是清楚的:rawdrain 那条臂是 自己空转的 accept() 紧循环,烧掉一个核,而客户端线程与它在同一个进程里抢核 ⇒ 烧 CPU 多的那条臂看起来更慢。再加上回环上那笔随负载伸缩的税 ⇒ 这个装置的 WALL_MS 分辨不了接入路径的开销。要真比,得把服务端与客户端拆成两个进程、逐轮配对同号。
  • 没测每连接的应用层工作量。 客户端只 connect 就关,不发请求 ⇒ 量的是接入与关闭, enable_tls(src/net/uvcpp_tcp_server.cpp:782-782)那次 arm 之后的请求处理没进这条路径。
  • 没测"master 自己 accept + IPC 转手"那条路(§7 的机制本身还没实现)。 外部贡献者的拒连接读数在他的装置上,本探针没有复现它、也没有否证它 —— 只否证了"本库单进程的接入路径在突发下会拒"这个具体担忧。

由此对 §7 的修正:「master 必须排空 backlog,否则突发下会拒连接」里因与果都换了—— 在这台机器上突发几乎不拒连接,而排空仍然值得做,理由从"防拒连接"变成降接入延迟 (队列深了,客户端的 connect 要等排空才有归宿)。WSAEWOULDBLOCK 那条仍是正确写法, 但它保的是延迟不是可用性。

9. Windows 转手路径挂着一条 libuv 层的内存安全缺陷

§7 那条「master 接受 + WSADuplicateSocketW 每连接转手」有人搭出来跑了:能跑,但会崩,崩的是 内存安全 —— 0xC0000374 / ASan heap-use-after-free。不是延迟、不是吞吐、不是优雅退出。 频率按落盘日志重算是 每 ~94 480 条 worker 连接一次(12 次事件 / 1 133 759 条连接,两次完整 跑动)⇒ 不是"CI 恒绿"那种稀有,几轮 churn 就中。

读数来自外部贡献者的装置(本机没有 adopt 路径,复现不了,按他的读数引用);下面引的源码 位置是我在 vendored 树里逐条核过的。注意他最初给的行号取自他加了仪器的树,与干净树有偏移, 这里一律用干净树的号。

9.1 机制:libuv 在 Windows 写路径上依赖的一条不变式,在转手 socket 上破了

  • libuv:win/tcp.c:120-125 —— 只有不是 UV_HANDLE_EMULATE_IOCP 且没有非 IFS LSP 时, libuv 才调 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) (:122)并置 UV_HANDLE_SYNC_BYPASS_IOCP(:124);
  • libuv:win/req-inl.h:70 —— UV_SUCCEEDED_WITHOUT_IOCP(r) = r && (flags & SYNC_BYPASS_IOCP);
  • libuv:win/tcp.c:921-929 —— WSASend 同步成功 + 这个标志 ⇒ libuv 判定"内核不会投包",于是 自己把 req 插进 pending 队列(注释原话 Request completed immediately.,:924)。

不变式 =「标志在 ⇒ skip-on-success 生效 ⇒ 同步成功不会来包」。它在转手过来的 socket 上破了 ⇒ 同一个写请求被派发两次:一次来自 libuv 自己的插入,一次来自内核真的投来的完成包。

洞的形状(这一处是拼上他读数之后才清楚的):libuv:win/tcp.c:102-111 里, CreateIoCompletionPort 失败且句柄是 imported 才置 UV_HANDLE_EMULATE_IOCP —— 而那个标志 会正确关掉上面那个 BYPASS 分支。关联成功的 imported 句柄不置它 ⇒ :120 的 if (!EMULATE_IOCP && !non_ifs_lsp) 照进 ⇒ BYPASS 照设。而 uv_tcp_open 恰恰走 imported=1 (libuv:win/tcp.c:1489),转手那一步在 libuv:win/tcp.c:1506-1510 ⇒ 本库的 uvcpp_tcp::open(src/handle/uvcpp_tcp.cpp:53)正是踩在这一步上的那个入口。

但这条链有个前提(2026-09-22 才量清楚,见 §9.9):worker 侧那次 CreateIoCompletionPort 得成功 —— 也就是源 socket 从没被关联过。源是 uv_accept 出来的那种、且走了转手的 (本库 set_loops(n>1) 那条路)在这一步就失败 87、落进 EMULATE 分支,BYPASS 永远不置 ⇒ 进不了本条说的那一族。set_loops(1)(= 不调用,也就是默认)根本没有"这一步" —— 不转手就没有 worker 侧那次关联,所以也没有那次 87。上面那句"本库的 uvcpp_tcp::open 踩在 这一步上",说的是多进程那条血统。

9.2 uvcpp 侧为什么变成 UAF,而不是"多跑一次回调"

callback_write(src/req/uvcpp_write.cpp:223-223)取出闭包后走 invoke_completion (src/req/uvcpp_req.h:175-175)——回调返回之后 delete self。所以重复的那次完成打在已释放对象上。 ⇒ 「在自己的完成回调里释放自己」这个形状,活不过一次重复投递,与内核为什么多投无关。

9.3 三条候选修法,与先做哪一条

  • (甲) uvcpp 侧:写对象别在自己的完成回调里释放(推迟释放)—— 读完源码后否掉了,见 §9.6。 它不是"窗口不够宽",是约束根本不落在写对象的生存期上:重复的那次派发走的是 libuv 自己的 uv__process_tcp_write_req(libuv: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)(libuv:win/handle-inl.h:51-60)。⇒ 重复派发把句柄级的两个 计数器打成负数:Debug 构建下当场断言,Release 下 reqs_pending 再也回不到 0 ⇒ uv__tcp_endgame(libuv:win/tcp.c:233-239,四条 assert 之一是 reqs_pending == 0)永远不会 为这个句柄跑。推迟释放救不了,把 cb 清空也救不了 —— 两次派发打的是同一份句柄记账。

    这一句已被实测修正(§9.7 有完整读数):装置里这个句柄上恒挂着一个未完成的零长读 (取包时 flags 含 UV_HANDLE_READ_PENDING / ZERO_READ),它给 reqs_pending 留了余量 ⇒ 第二次派发的减数落到流级的 write_reqs_pending 上(0 → −1),而 reqs_pending 只是 1 → 0、endgame 照常入队、句柄照常关(没有泄漏)。但那个 −1 是静默的: :1143 那一整块(含 CLOSING 时的 closesocket、以及 uv__process_tcp_shutdown_req 的 唯一调用点 :1148-1151)不再走;uv__tcp_try_cancel_reqs 的 writing(:1399)为假 ⇒ CancelIo(:1406)被跳过(在途写不取消);uv__process_tcp_shutdown_req 里那条 assert(write_reqs_pending == 0)(:210)永远到不了。 ⇒ 结论不变,理由更精确:推迟释放挡掉的是"打在已释放对象上"这一次,挡不掉第二次回调 与这份记账损伤 —— 换症状,不是防线。

  • (乙) libuv 侧:别让派发重复。 两个候选形状(读数见 §9.7;都是上游的事,不是本仓的): (乙1) 对 imported 句柄不拿同步快路径 —— 不调 SetFileCompletionNotificationModes (libuv:win/tcp.c:79 / :122-124),代价是这类连接每次写多一个 IOCP 往返。这条已 A/B 实测 (§9.7:死亡 55 → 0、ASan 报告 54 → 0,代价 ≈2.5% rps,且它是修复不是护栏 —— 关掉 BYPASS 后 libuv 不再自己插队、只等内核的包,"给一个消费一个"两种情况下账都对);(乙2) 真做幂等派发 —— 但"这条包是重复的"只能从 req 内存判断, 而那份内存应用已经交还(§9.7 的次序读数)⇒ 它不能只加一个 dispatched 位, 得连内存生命周期一起设计。

  • (丙) 结构上别用 master 已经关联过的句柄:让 worker 走"自己建、自己关联"的接受路径。 两个形状 —— (a) 监听句柄在首次关联之前就复制给各 worker、各自 uv_listen; (b) 监听只归 master,但 worker 用自建的接受句柄 AcceptEx 接进来(§9.6 第三条)。 §6 实测那条腿恒 0,原因(完成端口关联是一次性的,第二次报 87)已查清,所以 (a) 今天做不到。 两个形状都要求 worker 手里那条句柄还没被谁关联过 ⇒ 它们由同一个判据决定成败: 那个洞是 FROM_PROTOCOL_INFO 造的句柄特有的,还是所有走 uv_tcp_open(imported=1)的句柄都这样。 这是设计问题,不是补丁问题 —— 而且它正好就是判定实验第三臂要回答的那件事。

(甲) 一否掉,候选就只剩 (乙) 与 (丙),两条都不在本仓手里。 §9.3 初稿把 (甲) 排在最前, 理由是"① 缺口在 CI 里复现不出来(库今天没有任何用例走 adopt)⇒ 会是一行没人能证伪的防御代码, 还挂在每次响应写热路径上;② 判定实验决定走 (乙) 还是 (丙)、(甲) 不决定;③ 实验在对方装置上"。 ②③ 仍然成立,① 也仍然成立 —— 但它已经不重要了:(甲) 不是"没证据就先别做",是做了也没用(§9.6)。

新的顺序:判定实验(在对方装置上)→ 假设成立则 (乙) 提上游 / 不成立则回到 (丙)。

判定实验(假设,可证伪):SetFileCompletionNotificationModes 在一个由 WSASocketW(FROM_PROTOCOL_INFO) 从 WSADuplicateSocketW blob 造出来的句柄上返回 TRUE 但不生效。 三臂(第三臂见 §9.6):走 adopt(甲臂)vs 同一 socket 显式补一次该调用(乙臂) vs worker 侧自建接受句柄(丙臂),看"已派发又插包"是否只在甲臂出现。

9.4 一处我先读错的读数:req-socket=INVALID_SOCKET

我上一版据此推过"重复包是在连接关掉之后才被取走的,所以释放得推迟到 close callback 之后"。 这个推论不成立 —— 源码里有更简单的一种解释。

那个 INVALID_SOCKET 是第一次派发自己置上的:uv__process_tcp_write_req 里 write_reqs_pending-- 归零 + UV_HANDLE_CLOSING ⇒ closesocket(handle->socket); handle->socket = INVALID_SOCKET;(libuv:win/tcp.c:1143-1147)。第二次派发随后才读到它,于是打包成完成包 的 req-socket 字段送出。⇒ 这条读数只能说明包"迟到"(连接已经关了才被取走), 说明不了它迟到到 uv__tcp_endgame(:233-239)之后 —— 次序恰好相反:endgame 要等 reqs_pending == 0,而重复派发把那个数打负了(§9.6),所以 endgame 根本不会跑。

读数仍然值得留(上游报告里"包迟到"是要给的一份证据),但它不再是判据 —— (甲) 的存废 由 §9.6 的句柄记账决定,与"取走时刻"无关。

9.5 这件事对本设计的意义

  • §7 那条转手是这条路上 Windows 唯一的一条,而它现在挂着一条内存安全缺陷 ⇒ §6 那条 「Windows n>1 直接报错」的决定不变,而且更站得住了:既然唯一那条真能用的路有拦路的缺陷, "报错"就不是保守,是当前唯一诚实的默认。
  • 别和 §6 那条恒 0 的机制混起来:那条是"完成端口关联只能做一次",这条是"BYPASS 标志在 adopted 句柄上不生效"(假设,待判定;§9.6 第一条把它的竞争解释挤到只剩它一个)。 这条假设只适用于「源 socket 未被关联过」那条血统(§9.9):源已关联时 worker 侧那次 关联根本不成功,BYPASS 压根没置,谈不上"置了不生效"。
  • 顺带把 §6 那条恒 0 的根因补上:两个 worker 各对同一个继承 socket 发 uv_listen,第二次 CreateIoCompletionPort 报 87 ⇒ 第二个 worker 预投的包 永远回不来,uv_listen 照样返回 0。但根因不是"还有在途 I/O" —— 这一句在 2026-09-22 被实测推翻(与 §2 那四条情形是同一条事实,归口见 §9.9):关联是一次性的、绑在端点上, 一个 socket 对象只能属于一个完成端口,与有没有在途 I/O 无关。所以「一条刚接受、还没有 在途 I/O 的连接 socket 可以被重新关联」是反的:uv_accept 交出来的连接 socket 返回时就已经关联过了(libuv:win/tcp.c:662 那次 imported=0 的调用),再关联一律 87。 "静默"(§6)与"崩溃"(§9)是同一个约束的两面:一条 socket 的完成端口,一关联就定终身。
  • 顺带记一条:"把 master 那条监听句柄直接转给 worker"在 Windows 上不是"还没设计",是今天做不到 —— 它已经被 master 关联过,而关联是一次性的(上一条;不是"因为有在途 I/O 才被拒") ⇒ 一律被拒(这就是 §6 那条恒 0)。要让它成立,worker 得在首次关联之前拿到它,或者改用自建的 接受句柄(§9.6 第三条)。

9.6 源码级定位:读干净树得到的三件

三件都是读出来的(vendored 树,行号为干净树),不是猜的。

  1. "标志在 ⇒ 那次调用返回过 TRUE"是代码保证的。 uv__tcp_set_socket 里 if (!SetFileCompletionNotificationModes(...)) return GetLastError();(libuv:win/tcp.c:122-123), 置标志在 :124 —— 调用失败就直接返回错误、根本走不到置标志那一步。他读到的 flags=6f08c(BYPASS=1)与"同步成功"并存,"返回真但不生效"是唯一幸存的解释。

  2. 取包循环零过滤。 libuv:win/core.c:489-495:每个出队条目只做两件事 —— req = uv__overlapped_to_req(overlappeds[i].lpOverlapped); 然后 uv__insert_pending_req(loop, req);。不看 lpCompletionKey、不看句柄状态、不看这个 req 是不是已经被派发过。 ⇒ libuv 在 Windows 上没有任何一层能吸收一个多余的完成包; 唯一的不变式就是"内核不会投"。这同时说明 (甲) 为什么救不了(§9.3):包会先被写进 req->next_req —— 而本库的 uv_req_t 是独立分配的(src/req/uvcpp_req.h:203-203 那个成员), delete self 经 ~uvcpp_req()(src/req/uvcpp_req.cpp:25-25)走 free_req()(:105-105)把它一起还掉 ⇒ 那一写就已经落在已释放内存上,比派发更早。

    顺带把 (甲) 的成色说准(这是我第一版说糊的地方):推迟释放能挡掉"打在已释放对象上" 这一次,挡不掉随后那次派发对句柄记账的破坏 —— 于是崩溃被换成了"Debug 断言 + Release 下 这个句柄永远走不到 endgame(连接泄漏)"。那是换个症状,不是防线。

  3. 一条还没被排除的结构性出路(线索,不是结论)。 把"关联"从监听句柄挪到接受句柄: 监听句柄不关联任何 IOCP,各 worker 自建接受句柄、各自关联到自己的端口。按文档,AcceptEx 的完成包投给接受句柄所属的端口(监听句柄没有端口时)—— 这一条我是从文档读的,没在 本机量过。若成立,worker 走的就是 libuv 在 Windows 上天天走的标准路径(自己 WSASocket 建的句柄、自己关联),那个"skip-on-success 不生效"的洞可能压根不适用。 它成不成立只取决于一件事:那个洞是 WSASocketW(FROM_PROTOCOL_INFO) 造出来的句柄特有的, 还是所有走 uv_tcp_open(imported=1)的句柄都这样。 ⇒ 判定实验要加第三臂:worker 侧 WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED) 自建接受句柄,AcceptEx + SO_UPDATE_ACCEPT_CONTEXT 接进来,再 uv_tcp_open。只有这一臂决定 Windows 上还剩不剩一条既免转手、也免得改上游的路。

9.7 §5 判定实验的收尾读数(外部装置,2026-09-22)

装置臂:17 次命中 / ~3.3×10⁶ 条成功连接 ⇒ ≈1/1.9×10⁵,而 17 次的计数逐字同形 (sync == sends == procs、pend=0 fail=0 bad=0、insW = sends+1、iSync = sends、 iPkt=1、iAfter=1、iRedun=0)。读法:

  • pend = 0 ⇒ 那条包不是"它自己 pending 写的合法完成"(否则 pend ≥ 1);
  • insW = sends+1 且 iAfter = 1 ⇒ 同一条 req 被插了两次,第二次发生在它已经被派发过之后;
  • 派发侧独立计数 procs = sends + 1(uv__process_tcp_write_req 入口)⇒ 不只是插进队列, 应用的回调真的被调了第二次。
  • 包的字段这次取对了:status=0 + bytes=117 ⇒ 一次成功的发送完成,不是取消 (取消会是 0xC0000120、bytes=0)。(他上一版那条"包字段"取自 req 自己的 overlapped, 已自行更正。)

微观臂(进程内 adopt 同形探针,4×10⁶ 轮):0 命中,上界 1/1.3×10⁶(rule of three) —— 比装置里的速率还低 6 倍 ⇒ 光"adopt"这一件事解释不了它。阳性对照是有牙的:去掉 modes 的 2000 轮 = 2000/2000,且包在 WSASend 返回时已经躺在队列里(所以"返回后立刻零超时取一次" 是完备探测,不是抽样)。

他同时撤回了自己上一轮那句"假设被证伪":当时那 1600 轮阴性只排除快于 1/533 的速率, 而装置里的速率是 1/2×10⁵ —— 没有功率的阴性结果不是反证。这条单记,它正是 "判据要有牙"的一个实例(本仓的规矩:判据要对着对照组跑一遍)。

次序读数(§9.4 缺的那一半,他重做了):他原来的事件环按 socket 号认人,而 socket 号会回收 (64 K 事件窗口里约 10 代)⇒ 上一版那个"取走时刻"取错了人。改成按 req/handle 认人后两次命中同形:

S 提交 → D 派发进入 → C uv__tcp_close 进入 → Z 应用写回调返回
      → W write_reqs_pending-- → X closesocket() → P 重复包出队

⇒ P 在 Z 之后(应用已经把写对象交还),P 在 X 之后而 endgame 还没跑。 这与 §9.4 的更正一致(INVALID_SOCKET 是第一次派发自己置的),并且补上了那一半: 包确实迟到,迟到到"对象已释放"之后。

我那条预测,实测下来是"机制对、计数器错"(他按上面的判据读了):reqs_pending 是 1 → 0, 没有变负;变负的是流级的 write_reqs_pending(0 → −1)。原因写在 flags 里:那一刻 句柄上还挂着一个未完成的零长读(0x10000 / 0x40000)⇒ 它给 reqs_pending 提供了余量, 而 write_reqs_pending 只被写 req 加减、没有余量。所以:

  • "句柄永远停在 CLOSING"不成立:flags 从 0x72089 变成 0x720a9,多出来的正是 UV_HANDLE_ENDGAME_QUEUED(0x20,libuv:src/uv-common.h:87)⇒ endgame 被正常排队、 句柄照常关,没有泄漏。
  • 但 −1 是真的,而且是静默的:它跳过 libuv:win/tcp.c:1143 那一整块 (块内含 CLOSING 时的 closesocket,以及 uv__process_tcp_shutdown_req 的唯一调用点 :1148-1151)、让 uv__tcp_try_cancel_reqs 的 writing(:1399)为假从而跳过 CancelIo(:1406)、并使 uv__process_tcp_shutdown_req 里那条 assert(write_reqs_pending == 0)(:210)永远到不了 —— 也就是说 "Debug 下当场断言"不会以预期的形式出现。
  • 对"句柄上当时没有别的 req"的情形,我原来那版才成立(第一派发就把 reqs_pending 减到 0 ⇒ DECREASE_PENDING_REQ_COUNT 的 assert(reqs_pending > 0) 在 Debug 下会断)。本 rig 不满足这个前提 —— 服务端连接恒挂零长读。(这一条我写在这里,是因为它是"把条件写全"的实例:我漏了那个并存的读请求。)
  • 同一批还有一条形状读数:大多数进程生命是"两次打印之间就没了"(ENTRY=1, EXIT=0), 而那两次之间只有 req->cb() ⇒ Release 下每次事件几乎等于一次死亡;ASan 臂死得更早 —— 死在我预测的那一处,uv__insert_pending_req 里那 8 字节的写。

次序那个怀疑也降级了(他补了我没列的一个维度):他那个进程内探针的句柄是 FROM_PROTOCOL_INFO 血统(WSADuplicateSocketW 同进程 + WSASocketW(FROM_PROTOCOL_INFO,…)),不是我猜的自建句柄; 而源副本什么时候关这个维度他用三臂扫了(关在 modes 后 / 关在关联前 / 关在两者之间, 每臂 1 000 000 轮:0 / 0 / 0;同一次跑动的正对照关掉 modes = 3000/3000 全中)⇒ 次序不是触发条件(进程内)。把它和"进程内怎么摆都是 0"拼起来,剩下的差别只有真的跨了进程 —— 这一句已被他下一轮证伪(§9.8 第 3 条)。

⇒ (乙1) 已有 A/B 实测,而且它是修复不是护栏(同一份 uv.dll,只翻一个开关):

臂 连接 worker 退出 ASan 报告 口径
A(现状)Release 581 955 55(1/10 582) — 单轮
B(imported 不设 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) Release 578 296 0 — 单轮
A ASan 429 107 27 / 25(合计 52) 28 / 26(合计 54) 两轮之和
B ASan 582 236 0 / 0 0 / 0 两轮之和

口径必须跟着数一起读(他这一轮点出来的;这一类我们俩都栽过):Release 两行是单轮, ASan 两行的连接数与事件数都是两轮之和(27 / 25 是两轮各自的死亡数,合计 52)。B 两档 都是 0 ⇒ 用 rule of three 取上界 3 / n:

  • Release:3 / 578 296 = 1/192 765,除以 A 的 1/10 582 ⇒ 18.2×;
  • ASan:3 / 582 236 = 1/194 078,除以 A 的 52 / 429 107 = 1/8 252 ⇒ 23.5×。 若把 A 那行的死亡数按单轮读(27 而不是合计 52)配两轮之和的连接数,就会算成 12.2× (27 / 429 107 = 1/15 893 ⇒ 194 078 ÷ 15 893)—— 正是"印出来的数和算出来的数对不上" 的实例,所以口径写进表里而不是留给读者推。

为什么是修复:BYPASS 在时,同步成功走 libuv:win/tcp.c:923 那条分支 —— libuv 自己把 req 插进队列 (:929),它根本不期待完成包;内核多给的那一个只能撞到同一个 req 上。BYPASS 关掉后走 :930 那条(UV_SUCCEEDED_WITH_IOCP)—— libuv 不插、等内核的包 ⇒ 内核给一个消费一个, 两种情况下账都对:B 不再依赖那条不变式。

代价(真栈 QPS,4 worker / 100 并发 / 10 s,四档服务端 CPU 都 ≈4.00 核): A 351 814 / 342 707 rps,B 341 820 / 335 431 rps ⇒ ≈2.5% rps、延迟 +8 µs。 那条装置里每条连接都是转手来的 ⇒ 这是"adopt 用户付全额"的上界,混合部署按 imported 句柄占比摊。(单次对比看不出来:A 自己两次就差 2.6%。)

一条对 §7 那套转手有约束的装置侧发现:他想用"推迟 master 关副本"来验上面那条嫌疑, 结果 MP_CLOSE_DELAY_MS=1000 直接把吞吐打到 63 连接/s —— 原因是重复句柄的 FIN 只在 "最后一个句柄关闭"时才发,而 churn 客户端是 FIN 定界(Connection: close + 读到 EOF 才重连) ⇒ 在这个设计里,连接的收尾其实是由 master 关掉自己那份副本促成的。 任何推迟这个关闭的实验都会改掉连接寿命形状(同一份重建 DLL 在 delay=0 下复现基线 1/8 800 ⇒ 重建无污染,塌的是形状)。⇒ 写进 §7 那条:master 必须及时关掉自己那份副本, 否则连接不会收尾 —— 这不是优化,是这条机制的工作条件之一。

他上一版那条头号假设(未证 ⇒ 本轮已做、已证伪,见 §9.8):差异在"真的跨进程"那一步 —— blob 过管道交给另一个进程打开、 且 master 那份副本在 worker 调 modes 时还开着。那个验证实验(子进程 + blob 走管道,其余不变) 他这一轮就做了,连同它自己的对照臂(nomodes keep 2 000/2 000) ⇒ 证伪: 最小跨进程形状是 0/1×10⁶,我点名的那一格也是 0/1×10⁶(§9.8 第 3、4 条)。

⇒ 第三臂(§9.6 第三条)的次序,因此也定了:先走 (乙1),第三臂只在 (乙1) 被上游拒了才做。

理由(已改写:原来写的是"0/10⁶ 说明这个洞需要真的跨进程的形状",那个候选已被证伪):它只剩 "uvcpp_tcp::open 对同进程 adopt 用户安不安全"这一个用途,而这个用途在进程内装置上答不了 —— 不是因为它需要跨进程,而是因为进程内试过的所有摆法(两种血统 × 三种次序,各 1×10⁶ 轮) 一次都复现不出来 ⇒ 要答它必须把自建接受句柄搬进真 rig。注意 0/10⁶ 是上界,不是"证明干净"。

于是两条路的定位是:

  1. (乙1) 便宜、已实测有效、但需要上游:(乙1) 落地后,"设计上还留不留一条免转手的路"这个问题 就不必再答;
  2. 第三臂(自建接受句柄)是唯一不需要改上游的路,代价是架构级改动 ⇒ 只在 (乙1) 走不通时才做。 它能不能成立,取决于那个洞是 FROM_PROTOCOL_INFO 血统特有、还是所有 imported=1 句柄 都这样(§9.6 第三条)。这条推论原来的那半句理由("那条句柄从来没跨过进程",与"需要跨进程" 同方向)随着那个候选被证伪一起作废 ⇒ 现在它没有方向性依据,纯粹是要量的问题。

9.8 上游证据包(外部装置,2026-09-22 晚)

按 §5 提的两条要求(必须有对照组、必须写明边界)摆好的包,以及三处对 §9.7 的更正。 方案不变((乙1) 先走上游、第三臂延后),改的是理由与几处口径。

1. 用例自带对照组,而且是"先红后绿"的。 他做的库内用例断言的不是"没观察到二次派发", 而是那位开关的效果本身("一个同步完成的 WSASend 必须投递一个完成包")⇒ 库的现状就是 它的对照臂:

同一份用例(test-tcp-open-sync-bypass) 干净 libuv 1.51.0 同一棵树 + 补丁
uv_tcp_open 一个已连接 socket → 同步完成的 WSASend(117) → 在 loop 的 iocp 上等那个包 5/5 红(ovp == &ov:包根本不出现 ⇒ 这个句柄确实走了捷径) 5/5 绿

⇒ 红 = 缺陷的前提成立,绿 = 前提被去掉;探针层另有对照 nomodes 5 000/5 000(检出率 100%)。 但它抓不到那个概率性事件(内核偶尔仍投包)—— 那一层只有 rig 能复现,所以 rig 读数必须随包走。 ⇒ 包里不能声称有最小复现,能声称的是"只有 rig 能复现"。

2. 更大规模的一批 rig 读数(同一装置)。 干净 1.51.0:2 170 885 条连接 / 87 次 worker 退出 / 93 份 ASan 报告(1/24 950);把那个开关关掉:3 462 684 / 0 / 0 ⇒ 上界 8.7×10⁻⁷, 比现场速率低 40 倍以上。ASan 报告数(93)≥ 退出数(87) ⇒ 每次事件基本都被抓到, 所以那个 0 才算数(否则"没报"可能只是"没看见")。

3. "真的跨进程"这个候选已证伪(§9.7 那两句据此作废):他把跨进程也做成了探针 (skipon-success-probe5:一个 exe 双进程、blob 过管道、管道消息把次序钉死、子进程用自己的 IOCP),最小跨进程形状是 0/1×10⁶ ⇒ "跨进程"跟"血统"、"(进程内)次序"一样,是被证伪的 候选,不是剩下的解释。仍然为真的是弱版本:唯一已知能复现的装置是他的 rig(而 rig 恰好跨进程 —— 但跨进程不是充分条件)。

4. 我点名的那一格(源副本"还开着")连同它自己的对照臂一起做了:

跨进程臂(blob 过管道,子进程自己的 IOCP) 子进程调 modes 时源副本 送的时候有未完成零长读 轮数 同步成功却收到包
nomodes(正对照) 已关 否 5 000 5 000/5 000
modes(与现场同) 已关 否 1 000 000 0
modes pz 已关 是 1 000 000 0
modes keep(= 我假设的那一格) 开着 否 1 000 000 0
nomodes keep(新增臂自己的对照) 开着 否 2 000 2 000/2 000

keep 那轮的健康度:rounds=1 000 000 no_verdict=0 ready_fail=0 读到包=1 000 000 other=0 ⇒ 管子是活的(每轮都有读完成),不是"装置哑了所以 0"。

5. 包的状态。 补丁 + 用例(4 文件 / +178−1)在私有 gist 里,git apply --check 过了全新的 5152db2 检出,在干净检出上 apply → 编译 → 跑用例 = 绿。不往上推:(乙) 提上游仍是我先问 维护者那件事,还没有回音 ⇒ 包停在"知道在哪"的状态。

9.9 归口:哪条血统在 #5282 上(外部装置 + 本机探针,2026-09-22)

§9 通篇讲的是一条缺陷,但"哪条腿会走到它"直到这一天才有实测答案。判据是 源 socket 有没有被关联过 —— 不是"谁造的句柄",也不是"那笔 I/O 在不在途"。

血统(socket 从哪来) 源 socket worker 侧 CreateIoCompletionPort EMULATE BYPASS 在 #5282 一族上
本库 set_loops(n>1)(被收养句柄) 已关联 失败 87 1 0 否:没有入口
本库 set_loops(1)(= 不调用,默认;同循环 uv_accept) 已关联 没有这一步 0 1 否:不是入口
master 裸 accept()(多进程那条形状,也是 rig 的形状) 未关联 成功 0 1 是
  • imported 的两个调用点各管一件事,别互相顶替(2026-09-22 逐参复核):87 与"失败被 静默吞成 EMULATE"这两个后果都发生在转手腿自己那次 uv__tcp_set_socket(..., 1) (uv_tcp_open 那个调用点);而 imported=0 那次(uv__tcp_accept)只负责让源 socket 被关联上,它不是 87 发生的地点。⇒ EMULATE 这个后果的作者是 imported=1。 行号请按函数名 + 实参核:imported 实参在 uv_tcp_init_ex / uv__tcp_try_bind / uv__tcp_accept 是 0,在 uv__tcp_xfer_import / uv_tcp_open 是 1(外部贡献者那份 libuv-clean 与本仓引用的修订行号不等差,所以本文只说函数与实参)。

  • 闸只有 BYPASS 一个位。 UV_SUCCEEDED_WITHOUT_IOCP(libuv:win/req-inl.h:69-70) = (result) && (handle->flags & UV_HANDLE_SYNC_BYPASS_IOCP);BYPASS 只在 libuv:win/tcp.c:117-124 那段里置,而那段被 :119 的 !(handle->flags & UV_HANDLE_EMULATE_IOCP) 挡着 ⇒ EMULATE 与 BYPASS 互斥, EMULATE 一旦置上就永远进不了那一支。

  • 实测 worker 句柄的 flags 恰好差在这一对上(外部装置,仪器打在 uv__tcp_set_socket 出口;位值见 libuv:src/uv-common.h:102 与 :104):set_loops(n>1) 那条腿 0x8e088 (EMULATE_IOCP = 1、SYNC_BYPASS_IOCP = 0),裸 accept() 那条腿 0x6f08c(反之)。 ⇒ §9.6 第 1 条那段"返回真但不生效"的推理,只属于最后一行(源未关联那一支), 它不是本库这条转手路的解释。

  • set_loops(1) 的读数把它放在"同档、不同入口"上(2026-09-22 加:一个二进制、一次跑动、 只翻 set_loops 的实参):arm 了读时 0x0007f08c(SYNC_BYPASS_IOCP = 1、 EMULATE_IOCP = 0)—— 实测;未 arm 时 0x6f08c —— 推得的(0x0007f08c 减掉 READ_PENDING 那一位,不是测的),与上面最后一行同值。所以第 2 行与 第 3 行同一档(都走同步腿),但不是同一个入口:第 2 行那个 socket 是 libuv 自己 accept 出来的(imported=0)。两栏要分开写,只写 BYPASS 会把这两行读成一行。 顺带:(乙1) 那笔候选修复在 set_loops(1) 上按构造是空操作(&& !imported 碰不到 imported=0 的句柄),上面那个 0x0007f08c 就是没打补丁的树。

  • 旁证:仪器计数三条全 0(SETSOCKET-EMULATE / SETSOCKET-STALE-BYPASS / SETSOCKET-NOBYPASS)⇒ 整个跑动里"关联失败 ⇒ EMULATE"那一支一次都没进过, 因为 rig 那条腿上是关联成功的。

  • 本机侧的独立证据(纯 Win32 2×2 探针,两个正对照):对一个已关联的 socket 做 WSADuplicateSocketW + WSASocketW(FROM_PROTOCOL_INFO),造出的新对象再关联报 87; 对未关联的源做同样的事,关联成功。两头都翻 ⇒ 关联跟着端点走,而 WSADuplicateSocketW 复制的是同一个端点。⇒ 这也正是 §9.5 第一条那句"关联是一次性的" 的依据(原写法"只在还有在途 I/O 时被拒"已改)。

归口结论:#5282 的暴露面在「master 裸 accept() + 转手」那条血统上 —— 也就是本设计(多进程)的那条路,所以 §6 的「Windows n>1 报错」不受影响、更站得住。 进程内的 set_loops(n>1) 落在另一条腿(EMULATE),它付的是那一档折扣(§9.7 三臂表里的 accept 臂),不是这条内存安全缺陷;而默认那一档(set_loops(1) / 不调用)连转手 这一步都没有,与 master 裸 accept() 同档、但不是那一族的入口(上表第 2 行)。这一条同时 改掉了 doc/multiloop-design.md §2 与 src/net/uvcpp_socket_handoff.h、 src/net/uvcpp_tcp_server.h 里"默认开 = 走进 #5282"的写法。

边界。"没有入口"是结构判断(那一族唯一的闸恒假),不是"set_loops(n>1) 这条转手 已经验过没有别的问题"。§9.1 的机制本身仍未定 —— §9.8 那批实验是把候选一个个排除, 没有一个是"已证实的原因"。把它写成"多循环那条路是安全的"就是又一次拿"没观察到"当"不存在"。

相关文档:webapp 应用框架开发者指南、压测靶场。