tokio 运行时「睡死」诊断记录:message-queue-enhance 服务异常排查
八月 25, 2026 [Rust] #tokio #调试 #Rust #gdb #proc排查/学习路径
一句话结论:一个 tokio+axum 服务在"双隧道并发注册 + 旧对存活"的重叠场景下,registry.insert() 撞上 dashmap 6.2.1 自研写锁的多 writer 丢失唤醒(lock_exclusive_slow),两个以上 worker 永久卡在锁上;恰逢其中一位正持有 tokio io/timer driver 的单 owner 令牌 → epoll 无人轮询 → 其余 worker 无限空闲等唤醒永不满足 → 全 runtime 静默死透(进程活着、端口监听、日志停写、请求无响应)。
学习路径建议(按本文结构,每段可独立跳到):
- 会看症状 →
现象+第一步/第二步:TCP 能连 ≠ 服务能用;ss/lsof谁在听;日志停止点=第一现场 - 学会"判定进程是否活着"(活死判定) →
第三步wchan 全分布(futex_wait_queuevsdo_epoll_waitvspark_condvar的意义)+第五步epoll fdinfo 注册表 - 学会取证 →
第四步gcore → 手工解栈(无 DWARF 时x/8gx $rsp+ nm 基址)→带 DWARF 的 debug 版(本项目一键 build 出.debug,gdb 免参数全栈) - 会复现(可跑通)→
复现成功章 +脚本下载(repro7/8 一键起)→ 三次独立命中;最小独立样本见最小独立复现样本(按生产锁序的 tokio 版,3/3 命中lock_exclusive_slow) - 懂根因 →
根因锁定(dashmap 状态机 + 读锁跨 await 红线)→连锁反应(driver 令牌断供) - 懂怎么修 → QA 的
A/B 修复+修复落地与验证
背景
message-queue-enhance 是我们一个 Rust 组合服务(单端口 8080):
/forward/ws—— 反向隧道控制面(Reverse Forwarder 协议:公网端口 → Nginx 客户端 → 内网服务)/ws、/mq/ws—— 消息中继(WebSocket 发布/订阅)/mq/status—— 组合仪表盘(SSR HTML,5 秒自动刷新)
部署后不久服务"异常":/mq/status 无法访问,TCP 显示还在监听 8080 端口,但 curl 或 websocat 连接都没有任何反映。
这篇文章是"调试工具与方法论"视角的完整记录:没有一次定位,没有灵光一闪,只有一步步用
/proc、日志、core dump 把"还活着"证明成"其实死透了"。文章会随深度排查持续更新。
现象
$ curl http://<server>:8080/mq/status # 超时,0 字节应答
$ curl http://<server>:10200/mq/status # 超时(同服务经 caddy 代理)
$ curl -k https://<server>:9000/mq/status # HTTP 200 但 body 空!
端口探针/ss 显示 8080 仍在 LISTEN,进程还存在(容器 docker ps Up 状态),但一切请求都像被黑洞吞掉。
第一课:TCP 能连通 ≠ 服务可用。 内核层监听(backlog 队列)和用户态 accept/处理是完全两回事——ss 显示 LISTEN 只证明了内核 socket 还在。
排查过程
第一步:确认"谁在听、谁在等"
先搞清楚 8080 上到底有什么 socket 坐着:
# 容器 netns 内查看(宿主侧 lsof)
sudo nsenter -t <host_pid> -n lsof -nP -iTCP:8080
结果(关键几行):
caddy <pid> TCP 127.0.0.1:32768->127.0.0.1:8080 (ESTABLISHED) # 反向代理一条连接挂死
message-q <pid> TCP *:8080 (LISTEN) # 主服务在听
message-q <pid> TCP 127.0.0.1:8080->127.0.0.1:xxx (CLOSE_WAIT) # 积压的半关闭连接(对端已 FIN,服务端从未 close)
要点:
CLOSE_WAIT:对端已 FIN,服务端应用从未 close 这个 socket —— 应用早就"不干活"了,只是进程没退出。- 真实客户端链路:agent 侧经 caddy 的 10200 端口反代(Caddyfile:
handle /forward/ws、handle_path /mq*→reverse_proxy {$MQ_WS},MQ_WS=http://127.0.0.1:8080)到达 message-queue-enhance 8080 ——caddy到 8080 的那条 ESTABLISHED 挂死正是代理转发的请求等不到上游响应。
第二步:日志时间线——两轮运行完全同构的结尾
日志文件 message-queue-enhance.log 最后的写入时间是冻结时刻,之后 40 分钟没再增长(哪怕每 5s 自动刷新的 /mq/status 也没触发任何日志写入)。提取最后部分:
06:52:13.647 message-queue-enhance listening on 0.0.0.0:8080 # 服务启动
06:52:40.924 request method: GET, uri: /forward/ws?remote_port=9171... # 客户端对 A
06:52:40.924 response status: 101 Switching Protocols # A 对成功注册
06:58:37.406 request method: GET, uri: /forward/ws?remote_port=8172... # 客户端对 B 来
06:58:37.406 response status: 101 Switching Protocols # B 对第 1 条成功
06:58:37.406 request method: GET, uri: /forward/ws?remote_port=9172... # B 对第 2 条:请求已记录
06:58:39.6 tunnel ...: listening on 0.0.0.0:8172 ... # 但 9172 永无后续
06:58:48.409 request method: GET, uri: /forward/ws?remote_port=9172... # 恰 11.003s 后客户端重试
06:58:48.409 <没有 response 行> # 第二次也卡死
(之后 40 分钟无任何日志)
冻结模式(这是本次最重要的发现):
- 旧隧道对(A)仍存活时,新客户端对 B 注册
/forward/ws - B 对的其中一条升级"卡死":TraceLayer 已打印
request method,但永远没有response status: 101、没有registered、没有listening - 恰好 11.003 s 后客户端重试同一端口——同样卡死
- 随后整个 runtime 冻结:无日志、无响应
再回看上一轮容器运行(前一天启动的那次),结尾完全同构:
03:42:26.515 request method /forward/ws?remote_port=8171 # 第 1 条卡死
03:42:26.515 request method /forward/ws?remote_port=9171 # 第 2 条成功 101
03:42:37.518 request method /forward/ws?remote_port=8171 # 11.003s 后重试也卡死
(之后 3+ 小时无日志,直到人工重启)
这不是偶发,是可复发的 bug 模式。 两轮运行、不同的挂起端口(这次 9172、上回 8171),模式一模一样的"11 秒重试→全灭"。
第三步:进程是否真的活着?——/proc 线程状态
ps 显示进程在、CPU 0、无 panic。到底每根线程在等什么?
for t in /proc/<pid>/task/*/; do
echo "$(basename $t): $(cat $t/wchan)"
done
结果:
16 × tokio-rt-worker : futex_wait_queue # 全部 worker 处于 idle park
1 × message-queue-e : futex_wait_queue # 主线程(block_on)
对照健康实例(起一个同类服务对比):
1 × tokio-rt-worker : do_epoll_wait # ← 健康实例有且只有一个"轮询者"
N × tokio-rt-worker : futex_wait_queue # 其余 worker park
冻结态:0 个线程处于 do_epoll_wait。 也就是说——tokio 的 io driver 没有任何线程在轮询。所有 fd(含 8080 LISTEN)都在 epoll 里注册着,事件来就绪时却无人消费。
这里我用了一个对照实验:直接在本机跑一个同样骨架的 tokio 服务,看它空闲时 wchan 的"分布样貌"。单看 wchan 值没有意义,看分布才有意义:
futex_wait_queue既可能是健康空闲、也可能是死锁——区别就看有没有人do_epoll_wait。
第四步:是不是"锁死锁"?——core dump 裁决
futex_wait_queue 也可能是两个任务互相等一把共享锁。必须区分"锁死锁"还是"调度睡死"。手段:gcore 固化解冻态,再读每个线程的 futex 参数。
生成 core
# 服务器宿主侧(先确认 TracerPid=0,见下文的"插曲")
sudo gcore -o /tmp/mq-core 1552056
# Saved corefile /tmp/mq-core.1552056 (36MB)
- core 是手动生成一次的取证产物(与后文的 watchdog 自动 gcore 不同,watchdog 至今未触发)
- 本地副本(排查用):
scp smyl@192.168.7.165:/tmp/mq-core.1552056与生产二进制副本存入~/.tmp/opencode/mq-core/ - 注意:core 内文件映射路径是容器内
/message-queue-enhance——gdb 会报"路径映射"警告(warning only,可完全忽略:gdb 以命令行提供的 exec 文件为准,分析照常)。真正的前置只有 exec 文件的.text布局与 core 匹配(同源构建=同布局;这就是"带 DWARF 的 debug 版"章节的意义)。"建同名路径/在容器内分析"只是消除该警告的手段,非必需。
配套系统调用证据(strace 曾成功捕获一次,attach 后主线程进入等待的参数):
futex(0x792cd09b81d8, FUTEX_WAIT_PRIVATE, 1, NULL) # 无超时私有锁等待;该地址=主线程 park token
⚠️ 插曲:排查自己也被"卡住"——TracerPid 与残留 strace
排查途中想用 strace -p 观察,先 attach 成功(后台化时命令挂掉导致 strace 变孤儿进程还挂在目标上);随后所有 gdb/strace 都报 ptrace 不允许,最后在 /proc/<pid>/status 发现:
State: S (sleeping)
TracerPid: 208 # ← 进程已被别的 tracer 占用!
kill 掉残留 strace 后(内核自动 detach),TracerPid 回到 0,gcore 才可附加。教训:一个进程同时只能有一个 tracer;strace 不做后台化就死掉,会留下"附身"痕迹——排查工具本身也污染现场。
解栈:gdb 的 bt 为什么断掉?如何手工补?
对 core 用 gdb -batch -ex "thread apply all bt",结果几乎全是问号,栈在一个方向就断了:
#0 0x0000792ccfd468ee in syscall () # 断在 libc 裸汇编 syscall()
#1 0x0000792c00000008 in ?? () # 之后全废
#2 0x0000792ccdba2240 in ?? ()
...
原因(这次学到的重要知识):
- 生产二进制是
static-pie、not stripped(有.symtab符号表)但 没有 DWARF(无.debug_*节)→ 没有行号/内联信息。 - glibc 的
syscall()是裸汇编函数(无 .eh_frame)——即使有.eh_frame,unwinder 到 frame0 也找不到"返回地址在哪儿",直接放弃展开。 - Rust release 默认无 frame pointer,camera 依赖 CFI;CFI 断了就没法自动跳。
手工解栈方案(绕开 unwinder,一层层"读栈"):
# 方式 A(推荐,一条 batch 命令批量完成):
# -ex 是顺序执行的子命令;thread apply all 对每条子命令都逐线程执行,
# 因此 $rsp 自然跟随当前线程,无需手动抄地址
gdb -batch \
-ex "thread apply all p/x \$rsp" \
-ex "thread apply all x/16gx \$rsp" \
./message-queue-enhance ./frozen-core.1552056
# 方式 B(真正"交互式",gdb 交互 shell 内手动分步):
# gdb ./message-queue-enhance ./frozen-core.1552056
# (gdb) thread apply all p/x $rsp ← 看输出抄下想关注的线程 rsp
# (gdb) thread 2
# (gdb) x/8gx 0x<上面抄来的地址>
# (gdb) quit
比如线程 2 的原始栈:
0x792ccf9ff1e0: 0x0000792ccf9ff1d0 0x0000792c00000008
0x792ccf9ff1f0: 0x0000792ccf9ff240 0x0000792ccf9ff200
0x792ccf9ff200: 0x0000792cd09876b0 0x0000792ccf9ffad0
0x792ccf9ff210: 0x0000000000000080 0x0000000000000001
其中 0x792cd09876b0 用 info symbol 解出是 tokio::runtime::task::waker::WAKER_VTABLE(tokio 内部静态全局对象,.data.rel.ro),而 0x792ccf9ffad0 是这个线程自己的 futex(park 令牌)。
WAKER_VTABLE 怎么用? —— 确定二进制"基址",从而把核心地址全部换算成偏移给 addr2line:
nm <binary> | grep waker_vtable # 偏移 0x00f876b0
# 基址 = 0x792cd09876b0 - 0xf876b0 = 0x792cc0000000
addr2line -f -e <binary> <pc_hdr - 基址>
每一根线成什么样子?
- 每个 worker 的 futex 地址都在它自己的栈区内(间距 2MB 规律)——tokio 每线程
CachedParkThread的 park 令牌,不是共享锁。 - 栈帧里能看到
WAKER_VTABLE指针 —— 线程正处于 waker 回调/park 路径。 - 最终判读:全部线程 = tokio 标准 idle park;共享锁死锁被排除。
结合 wchan + epoll 注册表,完整因果闭合:
fdinfo 显示 fd 仍在 epoll(结构完好)
+ wchan 统计显示 0 线程在 epoll_wait(无人驱动)
= "事件就绪但无人消费"的调度级睡死
第五步:epoll 注册表——fd 都没丢
ls -l /proc/<pid>/fd | grep eventpoll # fd 3、5 都是 anon_inode:[eventpoll]
cat /proc/<pid>/fdinfo/3 # 内核 dump 的注册表
输出样例:
tfd: 9 events: 8000201d data: 792ccdda9a00 # 8080 LISTEN 仍在 epoll
tfd: 4 events: 80002019 data: 0 # eventfd
tfd: 8 events: 80002019 data: 1 # socketpair
...
events: 0x8000201d 逐位含义:0x80000000 EPOLLET(边缘触发)| 0x2000 EPOLLRDHUP | 0x10 EPOLLHUP | 0x8 EPOLLERR | 0x2 EPOLLPRI | 0x1 EPOLLIN。
结论:注册表完好——不是 io 注册丢了,是 io 驱动没人在跑。
排除项
- ❌ OOM:
VmRSS ≈ 5MB,不可能因内存问题 - ❌ 崩溃/panic:日志无 panic 帧、线程数正常(17)
- ❌ 进程退出/僵尸:容器 Up、LISTEN 在、TCP 可连
- ❌ 端口冲突:
ss只有本进程监听 8080 - ❌ 句柄耗尽:fd 只有 20 余个
结论(第一阶段:定性)
tokio multi-threaded runtime 的 liveness 丢失(调度级睡死):
- 进程"活着"但有 0 个线程在轮询 io driver(wchan 分布)
- 所有任务/驱动都被丢进 idle park 且不再被唤醒(futex 在每线程 TLS park 槽、栈中有 waker 痕迹)
- 内核侧 LISTEN/注册表完好、事件就绪无人消费(fdinfo 与 TCP 行为交叉验证)
- 强相关的触发序列:旧隧道活着 → 新客户端对注册 → 其中一个 WebSocketUpgrade 请求"已读入、永不完成"(无 101、无 registered)→ 11s 后同一端口重试也"已读入、永不完成" → 全灭
- 两轮运行完全同构(分别是 03:42 轮与 06:52 轮),可复发
⚠️ 尚未钉死的部分:挂起的 WebSocketUpgrade 与 runtime 睡死之间的精确因果链(是 hyper 连接任务持有某事导致驱动饿死?还是升级路径上某处丢唤醒?)。已知链路:真实 agent 客户端经 caddy 10200 反代到达 8080,但升级请求挂起点与代理层(buffering/协议/超时)是否相关仍待查——本地复现未包含 caddy 一层,是主要缺口。
复现尝试:版本迭代链(v1 → v8)
核心方法论:每一版只解决上一版暴露的一个最大缺口;"下一版改什么"完全来自"上一版为什么没冻结"的逐条排查。中间的无价值分支(脚本写法错误、命名跳号等)不纳入本文——只保留"每一步逼近真相"的部分。
| 版本 | 脚本 | 新增/修正(相对上一版) | 解决上版局限 | 结果 |
|---|---|---|---|---|
| v1 | repro.py | Python socket 手写 WS(半关 SHUT_WR / RST / 悬挂)+ 单隧道 abort 风暴 | 最小验证"半关升级是否卡死" | 未冻结 |
| v2 | repro2.py | + MQ /ws 会话(web/client)· + 隧道注册并发 · + tunnel_id 重连(CAS 路径)· 状态页监控 10min | v1 无 MQ/重连路径、无真实协议语义 | 未冻结 |
| v3 | repro3.sh | 换真实 reverse-forwarder-client(协议/重试节奏与生产一致)+ /dev/tcp 数据面 churn + 周期 kill/restart(8min/354 流) | v2 的 python 客户端"太理想" | 未冻结 → 暴露缺口:无重叠态、无 caddy 层嫌疑 |
| v4 | repro4.sh | A 对存活时 B 对加入(双对并存、新端口)+ 脚本内"请求已读入但无 101"卡死检测 | v3 的"kill 再起"非生产形态 | 未冻结(B 只加入一次→无高频注册) |
| v5 | repro5.sh | 全要素长跑 15min(状态页轮询 + MQ 会话 + 多对交叠 + churn) | v4 单次加入、无混合负载 | 未冻结(周期 60-90s 太慢→重叠窗口窄) |
| v6 | repro6.sh | 7.1 实验机 + caddy:10200 同构反代 + 双探针 + 30 轮 kill 后重启 | v3-v5 直连 8080、机器条件不齐 | 未冻结 → 否定实验:caddy 层非触发因素 |
| v7 | repro7.sh | 不 kill 旧对:每轮直接叠加新对(8250+i/9250+i),7-12s/轮,30 轮 | v3-v6 都是"杀旧再起",无真重叠 | 冻结命中(第 ~5 轮,8254) |
| v8 | repro8.sh | 终版:彻底无 caddy(环境无 caddy、探针只 8080、trap cleanup 清理残留客户端) | 排除 caddy 影响、脚本纪律,收尾闭环 | 冻结命中(第 6 轮,8253)(v7 的插曲还自动冻过一次,三次独立命中) |
三条最终原则(1-6 缺一不可,v7 起全部具备):
caddy 反代层—— 排除:v6 带 caddy 30 轮不冻结;v7/v8 有无 caddy 均命中(v8 期间残留客户端还在无 caddy 环境自动冻过一次)。caddy 只是"生产链路事实",与触发无关- "旧对存活 + 新对注册"的真重叠(kill 后再起不算;v7 起"不杀旧对"即命中)
- DashMap 默认 4 分片下,两端口 hash 同分片的并发写者碰撞(同轮 port 对 ≈1/4 概率命中同分片;30 轮内命中即源于此)
脚本下载(全部最新命名;v7/v8 全文见下"复现成功"节,verify-fix 用法见文末"修复落地与验证"):
- v1
repro.py· v2repro2.py· v3repro3.sh· v4repro4.sh· v5repro5.sh· v6repro6.sh· v7repro7.sh· v8repro8.sh· 验证版verify-fix.sh· 最小样本dashmap-hang/
复现成功(7.1 实验环境 + 重叠对模式)
环境搭建(终版拓扑:只要 mq:8080,无需 caddy)
在一台干净机器(Ubuntu/Debian 系、免密 sudo)上搭建:
# 1) 上传插桩版二进制(本仓库构建产物 2.1.30-debug-hang 包中解出)
scp bin/message-queue-enhance smyl@<test-host>:/home/smyl/mq-repro/
# 2) 启动 mq(RUST_LOG=info!本二进制不设 RUST_LOG 完全静音)
ssh <test-host> 'RUST_LOG=info nohup /home/smyl/mq-repro/message-queue-enhance \
--listen 8080 >> /home/smyl/mq-repro/mq.log 2>&1 &'
说明:复现不需要 caddy(v6 否定实验 + v7/v8 有无 caddy 均命中已证明)。早期版本按生产拓扑额外搭了 caddy:10200 → 8080(
handle /forward/ws、handle_path /mq*与生产 Caddyfile 同构),那是"贴近生产链路"的第一版环境;终版直接连 8080 更干净。
复现脚本(v8 终版 repro8.sh 全文)
核心设计(两个要素缺一不可):
- 重叠注册:旧隧道对不杀,每轮直接叠加注册新对(8250+i / 9250+i)——命中生产"旧对存活时新对注册"
- 探针只打 8080 直连(无 caddy 干扰);
trap cleanup保证残留客户端进程被清理(v7 时代就吃过"残留客户端自动重放"的亏——虽然那次反而提供了自动命中证据)
#!/bin/bash
# repro8.sh (v8 终版):重叠对注册 + 直连 8080(无 caddy 环境)
set -u
CLIENT=/home/peter/project/agent-code/wfuzz-utils/target/release/reverse-forwarder-client
SRV=192.168.7.1 # 实验机
BASE1=8250 # agent 端口基线
BASE2=9250 # vscode 端口基线
D=/home/peter/.tmp/opencode/mq-core/repro8
mkdir -p "$D"
LOG=$D/driver.log; MON=$D/mon.log; ALERT=$D/alert.txt
: > "$LOG"; : > "$MON"; rm -f "$ALERT"; : > "$D/pids"; : > "$D/ports.txt"
log(){ echo "[$(date +%H:%M:%S)] $*" >> "$LOG"; }
freezebegin(){ # 冻结判定:8080 直连连续 3 次非 200
local f=0
while true; do
C1=$(curl -s -m 4 -o /dev/null -w "%{http_code}" http://$SRV:8080/mq/status 2>/dev/null)
echo "$(date +%H:%M:%S) probe8080=$C1" >> "$MON"
if [ "$C1" = "200" ]; then f=0; else f=$((f+1)); fi
if [ "$f" -ge 3 ]; then
echo "FROZEN $(date +%H:%M:%S) direct8080=$C1" > "$ALERT"
log "!!! FROZEN at $(date +%H:%M:%S)"
return 1
fi
sleep 2
done
}
pair(){ # 启动新对(不杀旧对)
"$CLIENT" --server "ws://$SRV:8080/forward/ws" --reverse-port "$1" --local-addr "127.0.0.1:1" >> "$D/cl-$3.log" 2>&1 &
echo $! >> "$D/pids"
"$CLIENT" --server "ws://$SRV:8080/forward/ws" --reverse-port "$2" --local-addr "127.0.0.1:1" >> "$D/cl-$3.log" 2>&1 &
echo $! >> "$D/pids"
echo "$1 $2" >> "$D/ports.txt"
}
cleanup(){ # 脚本纪律:结束必清理客户端残留
cat "$D/pids" 2>/dev/null | xargs -r kill 2>/dev/null
pkill -f "reverse-forwarder-client" 2>/dev/null
: > "$D/pids"
}
trap cleanup EXIT
log "=== repro8 start ($(date +%H:%M:%S)) NO-CADDY overlap-pair mode ==="
freezebegin & MONPID=$!
i=0
while [ $i -lt 30 ]; do
A=$((BASE1+i)); B=$((BASE2+i))
pair "$A" "$B" "pair$i"
sleep $((RANDOM%6+7)) # 每轮 7-12s(模仿生产双对注册节奏)
i=$((i+1))
[ -f "$ALERT" ] && { log "abort: frozen"; break; }
done
log "=== repro8 finished ($(date +%H:%M:%S)), pairs: $i ==="
kill $MONPID 2>/dev/null
exit 0
触发过程与冻结签名(两次正式命中 + 一次自动重放)
v7(repro7.sh:经 caddy 层)—— 第 ~5 轮命中,端口 8254
触发点日志最后三行(此后全日志静默):
14:19:43.639621 request method GET /forward/ws?remote_port=8254
14:19:43.639691 [DEBUG-a4f2] /forward/ws handler entry remote_port=8254 tunnel_id=None
14:19:43.639719 [DEBUG-a4f2] new tunnel 18cf123223537a3d: inserting 8254 ← 永无后续(无 101、无 registered)
冻结状态(gcore + wchan):
wchan全部futex_do_wait(9 线程);其中 2 个 worker 栈 =dashmap::lock::RawRwLock::lock_exclusive_slow→parking_lot_core::ThreadParker::futex_wait- 其余 worker
park_condvaridle;main 线程block_on(serve)空闲等——注意:没有任何线程在运行 - livetick 停止打印(14:19:42 后)——timer 驱动也停
v8(repro8.sh:彻底无 caddy)—— 第 6 轮命中,端口 8253
- 环境更严格:杀掉 caddy(10200 确认关闭)、探针只打 8080、客户端只连 8080
- 依然命中(23:40:56)→ caddy 层与触发彻底无关
- 附带证据:v7 轮次遗留下来未清理的客户端,在重启后的 mq 上自动重放注册/重连并再次触发冻结(8253 断点、caddy 已停)——侧面印证:只要"重叠注册"在跑,触发极容易(不需要任何人为干预)
两次正式命中 + 一次自动重放,断点签名完全一致:都是"
inserting <port>之后全静默";命中端口 8254/8253(同区间、同 DashMap 分片——默认 4 分片,相邻端口 hash 落同分片);三份 core 的 gdb 签名一致(dashmaplock_exclusive_slow)。
快速复现手册(从零到命中,约 15 分钟)
# 0) 准备:本仓库构建产物 + 一台可跑二进制的主机(记作 <H>)
# 1) 上传并启动 mq(RUST_LOG=info 是必须的——本二进制不设 RUST_LOG 则日志完全静音)
scp bin/message-queue-enhance smyl@<H>:/home/smyl/mq-repro/
ssh smyl@<H> 'RUST_LOG=info nohup /home/smyl/mq-repro/message-queue-enhance \
--listen 8080 >> /home/smyl/mq-repro/mq.log 2>&1 &'
# 2) 验证可用
ssh smyl@<H> 'curl -s -m 3 -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/mq/status' # → 200
# 3) 本地跑复现驱动(重叠对:旧对不杀、每轮叠加新对、7-12s/轮、最多 30 轮)
bash /tmp/repro8.sh # 终版:冻结自动判定(3 连失败→alert.txt) + trap 清理客户端
# 4) 等待命中(观察 <H>:~/mq-repro/mq.log 尾部出现 "inserting xxx" 后全静默 + 本地 alert.txt)
# 5) 取证(冻结瞬间,立刻做,进程必须还在):
ssh smyl@<H> 'MP=$(pgrep -f "mq-repro/message-queue-enhance" | head -1); \
sudo gcore -o /home/smyl/mq-repro/frozen-core $MP'
# 6) 分析(bin 与 .debug 同目录,gdb 免参数自动关联):
ssh smyl@<H> 'cd /home/smyl/mq-repro && gdb -batch -ex "set debuginfod enabled off" \
-ex "thread apply all bt 12" ./message-queue-enhance ./frozen-core.$MP'
# 期望:至少 2 个线程 bt 停在 dashmap::lock::RawRwLock::lock_exclusive_slow
# 7) 复制取证脚本;重启 mq 再刷命中率:重复 2-6
要点提醒:命中概率≈ 每轮两端口同分片(≈1/4) × 并发的时序窗口——30 轮一般会命中(我们是第 5/6 轮命中);不要用单一轮次判断"没复现";多轮循环 + alert 自动判定是必须的。
取证细节(第一次实战全栈回溯)
sudo gcore -o ... <pid>:把冻结态全线程内存+寄存器+映射固化为 ELF core(前提:/proc/<pid>/status的TracerPid=0——一个进程只能有一个 tracer,排查时自己的 strace 残留都会卡住它)- 用同目录
.debug+gdb <bin> <core>:.gnu_debuglink机制让 gdb 自动加载 DWARF →thread apply all bt一次出全栈+行号(对比无 DWARF 时代手工解栈——syscall()裸汇编无 CFI 时 bt 断 frame0)
根因锁定(DWARF 全栈 + 源码双证)
- dashmap 6.2.1 自研 RawRwLock 的多 writer 丢失唤醒(
dashmap/src/lock.rs手写状态机:WRITERS_PARKED/READERS_PARKED/ONE_WRITER;unlock_exclusive_slow在"读写者同时 parked"中间态 + 新读者/写者并发 CAS 的窗口下,parking_lot_core::park/unpark协议可丢失唤醒)——不是用错版本,是这个版本该锁在重度并发下的真实弱点 - 我们代码独立红线:
status.rs::collect_tunnel_rows的 DashMapRefguard 跨.await(let t = registry.get(...); t.state.read().await期间t未 drop——dashmap 文档明令禁止),读锁跨 await 与并发 insert 的写者交织会大幅放大上面的死锁窗口
连锁反应:为什么两个 worker 死锁 = 全 runtime 挂
(见文末 QA——这是排查期间最容易绕晕的部分。)
最小独立复现样本(dashmap-hang:对齐生产真实锁序)
重写本节之前先说清迭代史:第一版"纯 DashMap 泛化压测"(std 线程 + 任意读写风暴)8 组 × 60-120s 全部未冻结,一度得出"库层窗口概率太小钓不到"的结论。后来回到修复前源码把每个锁序细节核对一遍,发现那版压测缺了生产的三个结构事实(见下),补齐后同一台 22 核本机、最小样本 3/3 命中——每次都捕获与 v7/v8 gcore 完全一致的签名:2 个 tokio worker 永久 park 在 dashmap::lock::RawRwLock::lock_exclusive_slow。
先对齐:修复前的真实锁序(三个缺一不可的结构事实)
① 会话期永久持有 Ref ws_handler::handle_new 的 let t = registry.get(&p)
(Ref=分片读锁)在 session::handle_session(&t, ...)
整个隧道会话存活期内被跨 await 持有 —— 直到会话结束,
不会像状态页读那样 100µs 就释放。
② 同分片双写者并发 insert B 对(agent + vscode 两条隧道)同毫秒注册同一分片成对端口;
每个写者先 get(p)(读)再 insert(p)(写)。
③ 状态页 iter + get 锁序 collect_tunnel_rows 原样:registry.iter()(分片读锁遍历)
后逐个 *registry.get(&p).unwrap().state.read().await
—— 临时 Ref 跨 await 存活(原表达式如此)。
另一个事实修正(对前文"默认 4 分片"的补充):DashMap::new() 默认分片数是
(可用核数×4).next_power_of_two()(dashmap 6.2.1 lib.rs::default_shard_amount)——
本机 22 核 = 128 分片;只有 1 核环境才是 4。7.1 复现机实测命中≈1/4 与 4 分片吻合。
复现样本里显式 with_shard_amount(4) 把同分片概率顶到 100%。
样本结构(dashmap-hang 项目,4 个模块)
源码下载:dashmap-hang/(含 wchan/gdb 取证与 exit 42 判定,可直接入 CI):
dashmap-hang/
├── Cargo.toml
└── src/
├── main.rs # 入口:启动 server → client → watchdog
├── server.rs # server:Registry + Tunnel + 状态页 + session 任务
├── client.rs # client:A 对存活 + B 对并发注册
└── watchdog.rs # watchdog:进度监控 + wchan/gdb 取证 + exit 42
每个模块严格对应生产中的一个组件:
| 模块 | 生产对应 | 锁序 |
|---|---|---|
server::spawn_session | session::handle_session(&t, ...) | Ref 在整个会话期持有(跨 await,永不释放) |
server::spawn_status_page | status::collect_tunnel_rows | iter() + get(&p).state.read().await(临时 Ref 跨 await) |
client::spawn_registration_burst | ws_handler::handler_with_state | 两个并发任务 get → drop → insert(Barrier 同时性) |
watchdog::run | 排查过程的 wchan/gcore/gdb | 进度监控 + wchan 分布 + gdb 全栈 + exit 42 |
关键代码片段
server.rs — A 对会话(Ref 跨整个会话期持有)
/// A 对会话任务: handle_session(&t, ...) 原样锁序
/// registry.get(&port) (Ref = 分片读锁) 在整个会话期持有 (跨 await,永不释放)
/// 期间偶发 state.write().await (teardown 路径: shard-读 跨 inner-写)
pub fn spawn_session(state: &AppState, port: u16, tunnel_id: &str) {
let s = state.registry.clone();
let stop = state.stop.clone();
let ep = state.epoch_inner.clone();
let tid = tunnel_id.to_string();
tokio::spawn(async move {
// 注册隧道
s.insert(port, Arc::new(Tunnel {
state: tokio::sync::RwLock::new(TunnelState::Active),
tunnel_id: tid,
}));
// handle_new: let t = registry.get(&port)
let Some(t) = s.get(&port) else { return };
// handle_session(&t, ...): t (Ref) 持有至会话结束 — 永不 drop
while !stop.load(Ordering::Relaxed) {
// session.rs:87: *tunnel.state.write().await = Disconnected
// (teardown 路径: shard-读锁仍持有,跨 inner-写锁 await)
{
let mut st = t.state.write().await;
*st = if *st == TunnelState::Active {
TunnelState::Disconnected
} else {
TunnelState::Active
};
}
ep.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(2 + (port as u64) % 5)).await;
}
});
}
client.rs — B 对并发注册(Barrier 同时性)
/// B 对注册: 每轮两个并发任务 get → drop → insert (ws_handler 原样)
/// Barrier 保证两个写者同时开始 (模拟生产"两个 WS 连接同毫秒到达")
pub fn spawn_registration_burst(
registry: Arc<DashMap<u16, Arc<Tunnel>>>,
ports: Vec<u16>,
rounds: u64,
stop: Arc<AtomicBool>,
epoch_writer: Arc<AtomicU64>,
) {
tokio::spawn(async move {
for round_n in 0..rounds {
let p1 = ports[(round_n as usize + 2) % ports.len()];
let p2 = ports[(round_n as usize + 3) % ports.len()];
let barrier = Arc::new(tokio::sync::Barrier::new(2));
// 两个写者任务同时开始 (模拟 agent + vscode 两条隧道同毫秒注册)
let w1 = tokio::spawn({
let (s, ep, b) = (registry.clone(), epoch_writer.clone(), barrier.clone());
async move {
b.wait().await;
let _e = s.get(&p1);
drop(_e);
s.insert(p1, Arc::new(Tunnel { /* ... */ }));
ep.fetch_add(1, Ordering::Relaxed);
}
});
let w2 = tokio::spawn({ /* 同上 */ });
let _ = (w1.await, w2.await);
tokio::time::sleep(Duration::from_millis(rand_ms())).await; // 7-12s
}
});
}
watchdog.rs — 冻结检测 + wchan/gdb 取证
/// 监控 3 路进度, 写者 5s 无进展 → HANG (insert 永久卡死)
pub async fn run(&self, max_secs: u64) {
let mut last = (0, 0, 0);
for i in 0..max_secs {
tokio::time::sleep(Duration::from_secs(1)).await;
let cur = (
self.epoch_status.load(Ordering::Relaxed),
self.epoch_writer.load(Ordering::Relaxed),
self.epoch_inner.load(Ordering::Relaxed),
);
// 冻结判定: 全停 或 写者卡死 (writer=0 持续 5s)
if (i >= 2 && cur == last) || (i >= 5 && cur.1 == 0 && last.1 == 0) {
println!("HANG: insert() 永久卡死, writer=0 (status={} inner={} 仍活)", cur.0, cur.2);
dump_threads(); // /proc/self/task/*/wchan 分布
try_gdb(); // gdb -p <pid> -batch -ex 'thread apply all bt 10'
std::process::exit(42);
}
last = cur;
}
}
构建与运行(20s 内命中)
# 下载完整项目
curl -sL https://blog.example.com/tokio-runtime-hang-repro/dashmap-hang/ -o dashmap-hang.zip
# 或直接从 static 目录复制
cp -r static/tokio-runtime-hang-repro/dashmap-hang/ . && cd dashmap-hang/
# 构建
cargo build --release
# 运行 (默认 12 轮, 状态页 250ms, ~10s 内命中)
./target/release/dashmap-hang
# 自定义参数
./target/release/dashmap-hang 24 500 # 24 轮, 状态页 500ms
实测结果(22 核本机,3/3 命中)
[5s] status=24 writer=0 inner=1948
HANG: B 对注册 insert() 永久卡死, writer=0 (status=24 inner=1958 仍活)
--- wchan 分布 ---
1 × dashmap-hang2: 0
15 × tokio-rt-worker: futex_wait_queue ← 含 2 个卡在 dashmap 写锁的 worker
1 × tokio-rt-worker: do_epoll_wait
--- gdb ---
Thread 6 ...: #1 0x... in dashmap::lock::RawRwLock::lock_exclusive_slow ()
#2 in dashmap::DashMap<K,V,S>::insert ()
#3 in dashmap_hang2::main::{{closure}}::{{closure}} () ← B 对注册任务
Thread 3 ...: 同上 (第二个 B 对注册任务)
与 v7/v8 冻结核心逐字对齐("2 个 worker 栈 = dashmap::lock::RawRwLock::lock_exclusive_slow → futex 等待")。差异只在扩散层:样本里 status/inner 任务仍被其余 14 个 worker 驱动(锁层死,runtime 未全冻);生产里这 2 个 worker 之一恰好持有 io/timer driver 令牌 → 连锁反应(见 QA Q3)→ 全 runtime 冻结。
为什么先前"纯库压测"钓不出来(被饿死 vs 真的死锁)
第一版(std 线程、任意读写风暴、8 组 × 60-120s)未冻结,只复现"写者 1 op/120s 的饿死"。现在回看:那版缺的正是上面 ①②③:
- ① 缺会话期永久 Ref:读者最长持锁 2-5ms(sleep 版)→ 锁总是快速在读者/写者间流转,坏状态 1ms 内被下一个读者修复;生产里 A 对会话的 Ref 跨整个会话持有(秒~分钟级),把"读写者同时 parked"中间态的寿命拉到 B 对 insert 必然撞上的尺度;
- ② 缺同分片双写者突发:泛化压力里写者是"打地鼠",读多写少,两个写者同毫秒对称对垒的形态不出现;
- ③ 缺 tokio 任务调度:Barrier 同时性 + 16 worker 轮流被任务 park 阻塞的 runtime 语义(纯 std 线程是亲自动作,任务模型里插队/抢占全由 scheduler 决定)。
顺带收获:DashMap::new() 默认分片 (核数×4).next_power_of_two()(22 核=128),泛化压测用 with_capacity_and_shard_amount(4) 但读者 32-Ref 时分片内重入计数与 ONE_WRITER 同空间的风险也有别的坑——不多展开,直接以"固定 4 分片 + 生产锁序"为正确路径。
工具与方法论(本文可复用的部分)
wchan:看"线程在等什么",但一定看分布
| wchan | 含义 |
|---|---|
do_epoll_wait | 阻塞在 epoll_wait(事件循环驱动者在轮询) |
do_sys_poll | 阻塞在 poll(tmux 等普通事件循环) |
futex_wait_queue | 私有 futex 等待(tokio idle park 或 死锁等待者——不能单独定性!) |
0 | 内核未解析出函数名(勿与上面的函数名混入判断列表) |
ptrace_stop | 被 tracer 停住 |
三原则:
- 看全线程分布,不看单值;
- 单值
futex_wait_queue无法区分空闲与死锁 → 用 core 读rdi(futex 地址):每线程栈区内各自独立的地址 = park 令牌;多个线程挤同一共享锁地址 = 死锁; - 一次
cat /proc/X/task/*/wchan会把多个文件无分隔拼接——用for循环逐线程打印。
fd 表 + fdinfo:两个层次看 epoll
| 想知道 | 看哪 |
|---|---|
| 这个进程是不是 epoll 事件循环 | ls -l /proc/<pid>/fd 里有没有 anon_inode:[eventpoll](tokio/nginx/redis/libuv 有;tmux/poll 类没有) |
| epoll 里注册了哪些 fd / 什么事件 | cat /proc/<pid>/fdinfo/<epollfd>(events: 8000201d 逐位解码;注意 8000_0000 = EPOLLET) |
| 有没有线程在驱动它 | wchan 统计 do_epoll_wait 数量(0=无人轮询,1=健康) |
| socket 是谁 / 状态 | ss -tlnp 或 nsenter -n lsof -i(fd 表只看类型) |
strace / gcore / gdb 的边界
- 一个进程同时只能有一个 tracer:
/proc/<pid>/status的TracerPid非 0 时,任何新 attach 都会失败(ptrace 不允许)。排查中自己就跑出过孤儿 strace 卡住进程——工具也要清理现场。 - 容器进程通常没有 CAP_SYS_PTRACE(docker 默认能力集),
strace -p/gdb -p直 attach 会 EPERM;sudo在具备 CAP_SYS_PTRACE 的 ns 下可行。 - core 的文件路径映射:core 记录的是生成时的绝对路径(容器内如
/message-queue-enhance)。在另一台机器分析时 gdb 会报路径警告——要么在相同路径环境分析,要么以命令行 exec 文件为准(警告可忽略)。 - 没 DWARF + 裸汇编 syscall() 无 CFI →
bt断 frame0:改用x/8gx $rsp读栈、info symbol/nm手动符号化;先找一个已知符号(如 WAKER_VTABLE)运行时地址反推 static-pie 基址,把一切地址转偏移。
带 DWARF 的 debug 版用于分析生产 core(构建链路与优化)
生产二进制 not stripped 但无 DWARF,nm/addr2line 可用但退化(只有函数名,无行号)。解法:源码重编 debug 版。但"源码一样"不是充分条件:
- 决定
.text布局的是:源码 +Cargo.lock+ rustc 版本 + profile(opt-level/codegen-units/lto/panic 等) CARGO_PROFILE_RELEASE_DEBUG=N只增删.debug_*节,不移动函数布局(同 rustc/同 profile 前提下);build-id 会变,但 gdb 只用命令行指定的 exec,警告不碍事
debug=1 vs debug=2:常用哪个
| debug=1(默认推荐) | debug=2(局部深挖) | |
|---|---|---|
| 内容 | 行号表 + 函数边界(栈回溯足够) | + 局部变量/类型/内联树(可看变量值) |
| 编译耗时 | 少量增加(约 5-15%) | 明显增加(约 20-40%+);且改 profile 值会令整个 release 构建缓存失效全量重编 |
产物 .debug | 41MB(实测 musl+debug=1) | ≥100MB 量级 |
| 适用 | 任何 core 分析:bt 全栈 + 行号 | 想 dump 局部变量值(如冻结时 Tunnel 字段) |
限制(两种级别都救不回的部分):release opt-level=3 下被完全优化/寄存器化的局部变量,gdb 会显示 <optimized out>——是优化问题不是 debug 级别问题;要看全变量需本地 --debug 复现。
构建链路已优化(本仓库 build.sh / docker-build.sh)
- release 构建默认
CARGO_PROFILE_RELEASE_DEBUG=1,产物为bin/<bin>独立.debug(objcopy --only-keep-debug后--strip-debug --add-gnu-debuglink关联);调试只需gdb bin/<bin> <core>(gdb 依.gnu_debuglink同目录自动关联.debug),免参数出全栈 - 建议策略:
bin与.debug分文件、随包同目录发布——线上动态加载的二进制不背 DWARF(体积/信息暴露与现状持平,实测 18MB vs 57MB 含 DWARF 版),core 分析时 gdb 就地关联 - 编 debug=2 全量版:
CARGO_PROFILE_RELEASE_DEBUG=2 bash docker-build.sh bash build.sh(docker-build.sh 已做环境变量透传);必须走容器/同工具链构建,保证 rustc 版本与生产一致 - 容器镜像已含 binutils(build-essential 依赖),
objcopy可直接用,无需改 Dockerfile
# 交叉构建注意:与本项目一致,构建目标是 x86_64-unknown-linux-musl
CARGO_PROFILE_RELEASE_DEBUG=2 bash docker-build.sh bash build.sh
# 布局校验(严格版——比对(地址,名称)对序列;只比名称集不是充分条件)
nm -C <debug2-bin> | awk 'NF==3{print $1, $3}' | sort | md5sum
nm -C <生产bin> | awk 'NF==3{print $1, $3}' | sort | md5sum
# 一致 → gdb <debug2-bin> <core>:bt 全栈 + frame N + info locals(变量值,尽优化保留量)
# 不一致 → 符号桥接:生产地址→(生产nm)→符号名→(debug版nm)→地址(帧级别可用,变量读取受限)
执行环境注意:本机缺 gdb/lldb 本体(rust-gdb 需系统 gdb);core 用 docker cp 进容器(如 /tmp/core)配合容器内 apt install gdb(Ubuntu 20.04 基线)分析;生产卷挂载的 /message-queue-enhance 不要覆盖,debug 版从 /tmp/mq-debug 路径给 gdb。
另外两个小坑:
pkill -f会匹配到执行它的命令行自己(模式串出现在 shell 命令里)→ 会话自杀;用精确 PID(ps -ef | awk取号)nsenter -t <pid> -n lsof只进网络命名空间时,PID 显示仍是宿主号(不能当成容器 pid 用)
排查工具建议清单(本次缺失、下轮优先补齐):
- tokio-console(tokio_unstable + console_subscriber):运行时任务/IO 驱动观测
- bpftrace/perf(futex 地址→锁归属):服务器
perf_event_paranoid=4、需 CAP_PERFMON,谨慎另议 - 容器 loopback tcpdump(
tcpdump -i lo port 8080)+ caddy 侧日志:比对"完成 101 / 卡死请求"在 caddy:10200→8080 链路的时序 - 排查纪律:procfs/debug 类结果与命令全量留存(wchan 分布、fdinfo、core 路径、TracerPid),避免二次查证
附录:概念速查表(深度学习用,各条目展开即本章相关小节)
| 术语 | 是什么 | 在本案中的角色 | 展开看 |
|---|---|---|---|
| driver 令牌(单 owner) | tokio multi-thread runtime 中,同一时刻只有一个 worker 有资格 poll io/timer driver(防惊群) | 被卡死在锁里的 worker 占走 → 无人轮询 epoll/timer → 全 runtime 断供唤醒源 | QA Q2/Q3、连锁反应 |
| park_condvar / wait_until | worker 空闲时的"条件变量等待";timeout 来自最近 timer(无 timer 时无限等) | 其余 worker 的常态;"醒了也没资格干活"的所在地 | QA Q2 |
| parking_lot_core park/unpark | 用户态 futex park + 令牌唤醒原语(parking_lot 生态的底层) | dashmap 自研锁依赖它实现等待队列 | 根因锁定、dashmap lock.rs 源码片段 |
| DashMap 自研 RawRwLock | dashmap 6.x 私有读写锁(状态位:ONE_WRITER/WRITERS_PARKED/READERS_PARKED;非 parking_lot 原生 RwLock) | 死锁发生地:多 writer 竞争下丢失唤醒/饥饿 | 根因锁定、QA Q1 |
| DashMap Ref 跨 await | 分片读锁 guard 在 .await 间未 drop(dashmap 文档红线),dashmap 6 的 Ref 为 Send 故编译能过 | 我们的独立代码红线,放大库死锁窗口(位置:status.rs::collect_tunnel_rows) | QA Q1、根因锁定 |
| wchan / futex_do_wait | 内核视角每个线程的等待点;futex_do_wait 对 tokio/dashmap 都是"等 futex" | 判别"活死"的快速路:全分布里是否有 do_epoll_wait | 第三步、附录 wchan 表 |
| gcore + .gnu_debuglink | 冻结态全线程 core dump;.debug 分离 DWARF 用 debuglink 关联,gdb 免参数加载 | 全栈回溯(本案例第一次闭环) | 带 DWARF 的 debug 版 |
| livetick 探针 | main.rs 的 5s interval 日志:冻结后是否还在打 = timer 驱动是否活着 | 冻结瞬间证明 timer 停摆(生产与复现一致记录) | 触发过程与冻结签名 |
| 重叠对注册 | 旧隧道对存活时新对并发注册(agent+ vscode 两条隧道同一毫秒级到达) | 触发冻结的最小用户操作序列(生产=唤醒会话;复现=repro7/8) | 复现成功 |
附录:QA 记录(排查中的高频疑问)
Q1:dashmap 为什么 insert 会死锁?是版本不对还是代码问题?
都不是"版本用错",但两者都有贡献——双因叠加:
- dashmap 6.2.1 的锁本身有真实薄弱点:它的
RawRwLock(dashmap/src/lock.rs)不是 parking_lot 原生锁,而是用parking_lot_core::park/unpark手写的状态机锁(WRITERS_PARKED/READERS_PARKED/ONE_WRITER位运算)。unlock_exclusive_slow()在"读+写都有人 parked"时先把状态store(WRITERS_PARKED)(此时 owner 位清零),再由两条路径分别unpark——这个中间态 + 并发新读者(只查 ONE_WRITER、不查 WRITERS_PARKED)或新写者(CAS(0))的窗口,可以造成写者唤醒丢失/饥饿(core 里"两个 writer 全部 park、无 owner"正是这个形态)。这是 dashmap 6.x 在重度读写并发下的已知类问题(后续版本/换实现多次修过)。 - 我们代码踩了 DashMap 文档红线:
status.rs的let t = registry.get(&port)返回的Ref(分片读锁 guard)跨过了.await才 drop(t.state.read().await期间t仍存活;dashmap 6 的 Ref 是 Send,所以编译能过)。读锁跨 await 与并发insert交织,会大幅放大 1 的死锁窗口。
结论:库的固有弱点 × 我们的跨 await 用法 = 平时不炸、只在"唤醒/状态页并发"场景炸。两个都要修(一个换锁、一个守纪律)。
Q2:park_condvar 不是带 timeout 的 condvar 等待吗?为什么 timeout 唤醒不救场?
要点:timeout 到点唤醒 ≠ 能干活的资格。tokio worker 空闲 park 的 timeout 只取自"是否存在待处理的 timer"(无 timer 时是无限等);即使醒来的 worker:
醒来 → ① 本地/全局队列没任务
→ ② 尝试 poll driver(epoll/timer)
→ ③ 发现 driver owner 令牌被死锁线程持有 → 没有资格轮询 → 继续睡回去
tokio 的 io/timer driver 是单 owner 防惊群设计:只有一个 worker 真正在 epoll_wait。驱动令牌被卡在 dashmap 锁里的 worker 占死 → 其他 worker 每醒一次都在 ③ 被挡回——timers(livetick 5s)与 socket 事件永远没人轮询,这就是冻结期间 livetick 停打印、连接无人 accept 的机制。
Q3:只是两个 worker 死锁在锁上,为什么整个 runtime 都 hang?
不只看"锁住 2 个"——真正的扩散机制:
1 个 handler 卡死(dashmap 写锁,无人持有者却无人归还该锁)
└─ 该 worker 恰正持有 io/timer driver 令牌
└─ epoll 无人轮询 → 新连接与 timer 永不就绪
└─ 其余 worker 无限 idle-park 等唤醒,永远等不到(唤醒信号源断供)
└─ 全 runtime 静默死透(不是全部锁死,是"唤醒源断供")
核心结论:tokio 里"一个 task 长时间阻塞"与"全 runtime 挂"之间,隔着"driver 令牌"这一环——修复重点不是多留几个 worker,而是绝不让 worker 在持驱动令牌状态下长时间阻塞(handler 路径消除共享锁长阻塞、加超时兜底)。
修复落地与验证(2026-08-26)
落地改动(源码仓库 wfuzz-utils)
(换锁,根因):隧道注册表从 Arc<dashmap::DashMap<u16, Tunnel>> 换成 Arc<tokio::sync::RwLock<HashMap<u16, Arc<Tunnel>>>>(reverse-forwarder-server/src/lib.rs 的 Registry/TunnelHandle 别名)。dashmap 自研 RawRwLock 的多 writer 丢失唤醒窗口整个消失。
对比修复前:同一驱动形态下第 5-6 轮即冻结(v7/v8 三次独立命中)——两轮 30 轮全程 probe=200、无 alert,证明「重叠注册 + 并发写者」场景不再出现唤醒丢失。
验证脚本 verify-fix.sh(可复用,本机/远程均可)
与 repro8.sh 同形态(旧对不杀、每轮叠加、7-12s/轮),差异:参数化(SRV/PORT/BASE1/BASE2/ROUNDS/CLIENT)、探针自动判冻结(3 连非 200 → alert.txt)、结束清理客户端残留:
RUST_LOG=info nohup ./target/debug/message-queue-enhance \
--host 127.0.0.1 --listen 18080 >> /tmp/verify-fix/server.log 2>&1 &
SRV=127.0.0.1 PORT=18080 CLIENT=./target/debug/reverse-forwarder-client \
bash verify-fix.sh # 通过标准:pairs=30、全程 probe=200、无 alert
本文首发记录排查结论;后续随根因钉死、修复落地持续更新。(2026-08-25 首发;2026-08-26 修复落地与验证)