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 静默死透(进程活着、端口监听、日志停写、请求无响应)。

学习路径建议(按本文结构,每段可独立跳到):

  1. 会看症状 → 现象 + 第一步/第二步:TCP 能连 ≠ 服务能用;ss/lsof 谁在听;日志停止点=第一现场
  2. 学会"判定进程是否活着"(活死判定) → 第三步 wchan 全分布(futex_wait_queue vs do_epoll_wait vs park_condvar 的意义)+ 第五步 epoll fdinfo 注册表
  3. 学会取证 → 第四步 gcore → 手工解栈(无 DWARF 时 x/8gx $rsp + nm 基址)→ 带 DWARF 的 debug 版(本项目一键 build 出 .debug,gdb 免参数全栈)
  4. 会复现(可跑通)→ 复现成功 章 + 脚本下载(repro7/8 一键起)→ 三次独立命中;最小独立样本见 最小独立复现样本(按生产锁序的 tokio 版,3/3 命中 lock_exclusive_slow)
  5. 懂根因 → 根因锁定(dashmap 状态机 + 读锁跨 await 红线)→ 连锁反应(driver 令牌断供)
  6. 懂怎么修 → QA 的 A/B 修复 + 修复落地与验证

背景

message-queue-enhance 是我们一个 Rust 组合服务(单端口 8080):

部署后不久服务"异常":/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)

要点:

第二步:日志时间线——两轮运行完全同构的结尾

日志文件 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 分钟无任何日志)

冻结模式(这是本次最重要的发现):

  1. 旧隧道对(A)仍存活时,新客户端对 B 注册 /forward/ws
  2. B 对的其中一条升级"卡死":TraceLayer 已打印 request method,但永远没有 response status: 101、没有 registered、没有 listening
  3. 恰好 11.003 s 后客户端重试同一端口——同样卡死
  4. 随后整个 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)

配套系统调用证据(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 ?? ()
...

原因(这次学到的重要知识):

  1. 生产二进制是 static-pie、not stripped(有 .symtab 符号表)但 没有 DWARF(无 .debug_* 节)→ 没有行号/内联信息。
  2. glibc 的 syscall() 是裸汇编函数(无 .eh_frame)——即使有 .eh_frame,unwinder 到 frame0 也找不到"返回地址在哪儿",直接放弃展开。
  3. 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 - 基址>

每一根线成什么样子?

结合 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 驱动没人在跑。

排除项

结论(第一阶段:定性)

tokio multi-threaded runtime 的 liveness 丢失(调度级睡死):

⚠️ 尚未钉死的部分:挂起的 WebSocketUpgrade 与 runtime 睡死之间的精确因果链(是 hyper 连接任务持有某事导致驱动饿死?还是升级路径上某处丢唤醒?)。已知链路:真实 agent 客户端经 caddy 10200 反代到达 8080,但升级请求挂起点与代理层(buffering/协议/超时)是否相关仍待查——本地复现未包含 caddy 一层,是主要缺口。

复现尝试:版本迭代链(v1 → v8)

核心方法论:每一版只解决上一版暴露的一个最大缺口;"下一版改什么"完全来自"上一版为什么没冻结"的逐条排查。中间的无价值分支(脚本写法错误、命名跳号等)不纳入本文——只保留"每一步逼近真相"的部分。

版本脚本新增/修正(相对上一版)解决上版局限结果
v1repro.pyPython socket 手写 WS(半关 SHUT_WR / RST / 悬挂)+ 单隧道 abort 风暴最小验证"半关升级是否卡死"未冻结
v2repro2.py+ MQ /ws 会话(web/client)· + 隧道注册并发 · + tunnel_id 重连(CAS 路径)· 状态页监控 10minv1 无 MQ/重连路径、无真实协议语义未冻结
v3repro3.sh换真实 reverse-forwarder-client(协议/重试节奏与生产一致)+ /dev/tcp 数据面 churn + 周期 kill/restart(8min/354 流)v2 的 python 客户端"太理想"未冻结 → 暴露缺口:无重叠态、无 caddy 层嫌疑
v4repro4.shA 对存活时 B 对加入(双对并存、新端口)+ 脚本内"请求已读入但无 101"卡死检测v3 的"kill 再起"非生产形态未冻结(B 只加入一次→无高频注册)
v5repro5.sh全要素长跑 15min(状态页轮询 + MQ 会话 + 多对交叠 + churn)v4 单次加入、无混合负载未冻结(周期 60-90s 太慢→重叠窗口窄)
v6repro6.sh7.1 实验机 + caddy:10200 同构反代 + 双探针 + 30 轮 kill 后重启v3-v5 直连 8080、机器条件不齐未冻结 → 否定实验:caddy 层非触发因素
v7repro7.sh不 kill 旧对:每轮直接叠加新对(8250+i/9250+i),7-12s/轮,30 轮v3-v6 都是"杀旧再起",无真重叠冻结命中(第 ~5 轮,8254)
v8repro8.sh终版:彻底无 caddy(环境无 caddy、探针只 8080、trap cleanup 清理残留客户端)排除 caddy 影响、脚本纪律,收尾闭环冻结命中(第 6 轮,8253)(v7 的插曲还自动冻过一次,三次独立命中)

三条最终原则(1-6 缺一不可,v7 起全部具备):

  1. caddy 反代层 —— 排除:v6 带 caddy 30 轮不冻结;v7/v8 有无 caddy 均命中(v8 期间残留客户端还在无 caddy 环境自动冻过一次)。caddy 只是"生产链路事实",与触发无关
  2. "旧对存活 + 新对注册"的真重叠(kill 后再起不算;v7 起"不杀旧对"即命中)
  3. DashMap 默认 4 分片下,两端口 hash 同分片的并发写者碰撞(同轮 port 对 ≈1/4 概率命中同分片;30 轮内命中即源于此)

脚本下载(全部最新命名;v7/v8 全文见下"复现成功"节,verify-fix 用法见文末"修复落地与验证"):

复现成功(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 全文)

核心设计(两个要素缺一不可):

#!/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):

v8(repro8.sh:彻底无 caddy)—— 第 6 轮命中,端口 8253

两次正式命中 + 一次自动重放,断点签名完全一致:都是"inserting <port> 之后全静默";命中端口 8254/8253(同区间、同 DashMap 分片——默认 4 分片,相邻端口 hash 落同分片);三份 core 的 gdb 签名一致(dashmap lock_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 自动判定是必须的。

取证细节(第一次实战全栈回溯)

根因锁定(DWARF 全栈 + 源码双证)

  1. 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 协议可丢失唤醒)——不是用错版本,是这个版本该锁在重度并发下的真实弱点
  2. 我们代码独立红线:status.rs::collect_tunnel_rows 的 DashMap Ref guard 跨 .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_sessionsession::handle_session(&t, ...)Ref 在整个会话期持有(跨 await,永不释放)
server::spawn_status_pagestatus::collect_tunnel_rowsiter() + get(&p).state.read().await(临时 Ref 跨 await)
client::spawn_registration_burstws_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 的饿死"。现在回看:那版缺的正是上面 ①②③:

顺带收获: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 停住

三原则:

  1. 看全线程分布,不看单值;
  2. 单值 futex_wait_queue 无法区分空闲与死锁 → 用 core 读 rdi(futex 地址):每线程栈区内各自独立的地址 = park 令牌;多个线程挤同一共享锁地址 = 死锁;
  3. 一次 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 的边界

带 DWARF 的 debug 版用于分析生产 core(构建链路与优化)

生产二进制 not stripped 但无 DWARF,nm/addr2line 可用但退化(只有函数名,无行号)。解法:源码重编 debug 版。但"源码一样"不是充分条件:

debug=1 vs debug=2:常用哪个

debug=1(默认推荐)debug=2(局部深挖)
内容行号表 + 函数边界(栈回溯足够)+ 局部变量/类型/内联树(可看变量值)
编译耗时少量增加(约 5-15%)明显增加(约 20-40%+);且改 profile 值会令整个 release 构建缓存失效全量重编
产物 .debug41MB(实测 musl+debug=1)≥100MB 量级
适用任何 core 分析:bt 全栈 + 行号想 dump 局部变量值(如冻结时 Tunnel 字段)

限制(两种级别都救不回的部分):release opt-level=3 下被完全优化/寄存器化的局部变量,gdb 会显示 <optimized out>——是优化问题不是 debug 级别问题;要看全变量需本地 --debug 复现。

构建链路已优化(本仓库 build.sh / docker-build.sh)

# 交叉构建注意:与本项目一致,构建目标是 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。

另外两个小坑:

排查工具建议清单(本次缺失、下轮优先补齐):

附录:概念速查表(深度学习用,各条目展开即本章相关小节)

术语是什么在本案中的角色展开看
driver 令牌(单 owner)tokio multi-thread runtime 中,同一时刻只有一个 worker 有资格 poll io/timer driver(防惊群)被卡死在锁里的 worker 占走 → 无人轮询 epoll/timer → 全 runtime 断供唤醒源QA Q2/Q3、连锁反应
park_condvar / wait_untilworker 空闲时的"条件变量等待";timeout 来自最近 timer(无 timer 时无限等)其余 worker 的常态;"醒了也没资格干活"的所在地QA Q2
parking_lot_core park/unpark用户态 futex park + 令牌唤醒原语(parking_lot 生态的底层)dashmap 自研锁依赖它实现等待队列根因锁定、dashmap lock.rs 源码片段
DashMap 自研 RawRwLockdashmap 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 会死锁?是版本不对还是代码问题?

都不是"版本用错",但两者都有贡献——双因叠加:

  1. 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 在重度读写并发下的已知类问题(后续版本/换实现多次修过)。
  2. 我们代码踩了 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 修复落地与验证)

索引
排查/学习路径
背景
现象
排查过程
结论(第一阶段:定性)
复现尝试:版本迭代链(v1 → v8)
复现成功(7.1 实验环境 + 重叠对模式)
最小独立复现样本(dashmap-hang:对齐生产真实锁序)
工具与方法论(本文可复用的部分)
附录:概念速查表(深度学习用,各条目展开即本章相关小节)
附录:QA 记录(排查中的高频疑问)
修复落地与验证(2026-08-26)