状态:设计稿,未实现。 本文定的是形状与判据,代码还没有。 文里的
src/...:行号引用由check_doc_lines.py锁着 —— 改代码时门禁会把该一起改的地方顶红。
这一篇讲打算怎么做。做成之后怎么用,会进 webapp 应用框架开发者指南。
接入成本是 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 做对比的口径,扩进来会把"单核比单核" 这个前提弄糊。实现之后单开一节写扩展性,单核那行不动。
这一条决定了整套机制的形态,而且它是从"路由注册写在哪"推出来的,不是选出来的:
路由是在 worker_process::split() 那一句之后注册的。 所以 worker 进程必须把用户的
main() 走完才拿得到路由表 —— run() 那一刻它还不知道有哪些路由,没法替用户把服务跑起来。
于是只有两种做法:
| 做法 | 结论 | |
|---|---|---|
| (a) | worker 重跑 main:POSIX fork 从那一句继续往下跑 |
采用 |
| (b) | 把路由注册提到那一句之前(全局构造期注册) | 否 —— 依赖静态初始化顺序,脆弱 |
由此推出两条对用户可见的语义:
worker_process::split()在 worker 里必须返回(否则后面那段注册代码不跑)。- 「worker 跑完就退进程」落在
join()上:worker 的join()永不正常返回,直接退进程。
注意是
join()而不是start()——start()按今天的语义是"起后台线程、阻塞到 bind 有结果为止",它会返回。真正阻塞的是join(),所以语义分叉点在那里。
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 也是同一条规矩。
master 不接请求、不进请求路径。它的 join() 是一个监督循环:起 worker、等 worker
(waitpid)、死了拉起、收到 SIGTERM 时先停 worker 再退。
这条性质是运维侧的前提 —— master 不持有连接状态,才有资格在 worker 之外独立地做重启与 换班。它与 nginx 一致。
不 fork、不 exec、不碰 fd、不看环境变量。这条要能变异证明:把 n=1 那条分支改坏, 现有用例里必须有一个红。没有这条,"默认没开"就只是一句承诺。
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 的直接好处。)
worker 的 join() 跑完循环后直接 _exit(0):跳过用户的 atexit 与静态析构 ——
这正是"不跑后面的代码"要的语义。代价是框架必须在退出前自己 flush(日志、统计),
不能指望用户的清理代码。
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,等两件事查清再上。
- 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/。
§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 那条仍是正确写法,
但它保的是延迟不是可用性。
§7 那条「master 接受 + WSADuplicateSocketW 每连接转手」有人搭出来跑了:能跑,但会崩,崩的是
内存安全 —— 0xC0000374 / ASan heap-use-after-free。不是延迟、不是吞吐、不是优雅退出。
频率按落盘日志重算是 每 ~94 480 条 worker 连接一次(12 次事件 / 1 133 759 条连接,两次完整
跑动)⇒ 不是"CI 恒绿"那种稀有,几轮 churn 就中。
读数来自外部贡献者的装置(本机没有 adopt 路径,复现不了,按他的读数引用);下面引的源码 位置是我在 vendored 树里逐条核过的。注意他最初给的行号取自他加了仪器的树,与干净树有偏移, 这里一律用干净树的号。
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 踩在
这一步上",说的是多进程那条血统。
callback_write(src/req/uvcpp_write.cpp:223-223)取出闭包后走 invoke_completion
(src/req/uvcpp_req.h:175-175)——回调返回之后 delete self。所以重复的那次完成打在已释放对象上。
⇒ 「在自己的完成回调里释放自己」这个形状,活不过一次重复投递,与内核为什么多投无关。
-
(甲) uvcpp 侧:写对象别在自己的完成回调里释放(推迟释放)—— 读完源码后否掉了,见 §9.6。 它不是"窗口不够宽",是约束根本不落在写对象的生存期上:重复的那次派发走的是 libuv 自己的
uv__process_tcp_write_req(libuv:win/tcp.c:1111-1155),而那个函数里只有req->cb一步 是被守卫的(:1133if (req->cb));它前面是handle->write_queue_size -= req->u.io.queued_bytes(:1117-1118),后面是handle->stream.conn.write_reqs_pending--(:1142),收尾是DECREASE_PENDING_REQ_COUNT(handle)(:1154)—— 而那个宏第一步就是assert(handle->reqs_pending > 0)(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 侧自建接受句柄(丙臂),看"已派发又插包"是否只在甲臂出现。
我上一版据此推过"重复包是在连接关掉之后才被取走的,所以释放得推迟到 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 的句柄记账决定,与"取走时刻"无关。
- §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 第三条)。
三件都是读出来的(vendored 树,行号为干净树),不是猜的。
-
"标志在 ⇒ 那次调用返回过 TRUE"是代码保证的。
uv__tcp_set_socket里if (!SetFileCompletionNotificationModes(...)) return GetLastError();(libuv:win/tcp.c:122-123), 置标志在:124—— 调用失败就直接返回错误、根本走不到置标志那一步。他读到的flags=6f08c(BYPASS=1)与"同步成功"并存,"返回真但不生效"是唯一幸存的解释。 -
取包循环零过滤。
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(连接泄漏)"。那是换个症状,不是防线。
-
一条还没被排除的结构性出路(线索,不是结论)。 把"关联"从监听句柄挪到接受句柄: 监听句柄不关联任何 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 上还剩不剩一条既免转手、也免得改上游的路。
装置臂: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) 走不通时才做。
它能不能成立,取决于那个洞是
FROM_PROTOCOL_INFO血统特有、还是所有imported=1句柄 都这样(§9.6 第三条)。这条推论原来的那半句理由("那条句柄从来没跨过进程",与"需要跨进程" 同方向)随着那个候选被证伪一起作废 ⇒ 现在它没有方向性依据,纯粹是要量的问题。
按 §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 通篇讲的是一条缺陷,但"哪条腿会走到它"直到这一天才有实测答案。判据是 源 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 应用框架开发者指南、压测靶场。