sing-box 透明网关冻结从 SIGQUIT Goroutine Dump 定位三重自锁 Bug摘要本文详细分析了 sing-box 透明网关在特定配置下tproxy inbound urltest outbound出现的周期性完全冻结问题。通过自动触发的诊断脚本和 SIGQUIT Goroutine Dump我们定位到一个三重自锁循环 Bug1) bufio.CopyConn Read() 无 idle 超时导致连接永久阻塞2) URLTest batch.Wait() 无超时导致选路冻结3) checking 原子标志压制所有恢复尝试。这三层缺陷共同形成了不可自愈的死锁状态。文章提供了完整的诊断过程、根因分析、修复方案PR #4256以及热修复部署指南并指出该问题自 1.8.x 版本以来在多个 Issue 中持续存在但从未被彻底修复。2026-06-29 | sing-box 1.10.7 | iStoreOS N100 | tproxy urltest背景家用 N100 路由器运行 sing-box 作为透明代理tproxy inbound urltest outbound6个出口节点通过 relay 中继。从某天下午开始网关每隔几分钟就会完全冻结进程存活TCP accept() 在内核层正常但所有连接都不通。外部看门狗通过 Probe 7tproxy 路径探测检测到后重连恢复但几分钟后再次冻结。2小时内观测到8次冻结间隔从13分钟缩短到57秒。诊断工具链为了在冻结瞬间捕获进程内部状态我在看门狗的 PROXY_BROKEN 检测点加了一个自动触发的诊断脚本diag-proxy-broken.sh在重连杀死 sing-box 之前采集11维度的系统快照。后来又加了第12步利用 Go 运行时的 SIGQUIT 机制GOTRACEBACKall在冻结时刻打印所有 goroutine 的完整栈追踪到 stderr即 proxy log 文件然后通过 log 文件偏移量差提取 dump。这个 goroutine dump588KB7256行205个 goroutine是定位根因的决定性证据。七个诊断快照的一致模式在不同出口节点LA、Chicago、NY、Atlanta、Miami捕获了7个快照模式完全一致指标 值 含义 proxy ESTAB 112-136 连接已建立内核 accept 正常 Send-Q 全部 0 sing-box 没有写入任何数据 CLOSE-WAIT 1-5 远端发了 FINsing-box 没调用 close() conntrack 76-105/全部 TCP 层全部 ASSURED健康 tproxy rules 2 2(QUIC) 未被 tombstone路由正常 proxy log 100/100 最近100行全是错误 direct probe 000, 0.88s 快速失败非超时TCP 层健康但应用层完全不响应。不是网络问题不是路由问题不是 nftables 问题。Goroutine Dump 揭示的真相205个 goroutine 的状态分布数量 状态 含义 66 IO wait (1 min) 最近一分钟涌入的阻塞连接 26 IO wait, 1 min 中期积累 18 IO wait, 2 min 早期积累 10 IO wait, 4 min 冻结开始前就存在的连接 67 select (各时长) task.Group.Run 等 copy 完成 1 semacquire, 3 min batch.Wait() -- 关键阻塞点 16 runtime/infra GC, finalizer, signal 等 168 CopyConn 相关总计 每连接消耗4个 goroutine根因三重自锁循环同一个缺失的Read()超时在三个层面同时生效形成无法自愈的死循环第一层bufio.CopyConn Read() 无 idle 超时relay 在 TCP 层保持连接活跃keepalive ACK 正常conntrack 显示 ASSURED但在应用层停止转发数据。bufio.CopyConn没有在 relay 侧 socket 上设置 read deadlinegoroutine 在Read()上永久阻塞连接永远不释放。goroutine 1151 [IO wait, 1 minutes]: bufio.splice - rawConn.Read -- 永远阻塞随着时间推移被阻塞的 goroutine 持续堆积10个/4分钟前 - 26个/1分钟前 - 66个/最近1分钟加速度达6倍。第二层URLTest batch.Wait() 无超时URLTestGroup.urlTest()为每个 outbound 生成一个 batch worker 进行健康探测。batch.Wait()调用WaitGroup.Wait()要求所有 worker 完成。当其中一个 worker 的 relay 接受了 TCP 但不返回 HTTP 响应时该 worker 永久阻塞整个 batch 也永久阻塞goroutine 103 [semacquire, 3 minutes]: sync.(*WaitGroup).Wait() sync/waitgroup.go:118 batch.(*Batch).Wait() singv0.5.1/common/batch/batch.go:77 URLTestGroup.urlTest() outbound/urltest.go:407batch 阻塞期间selectedOutbound永远不更新所有新连接继续路由到死掉的 relay。第三层checking 原子标志压制所有恢复尝试func (g *URLTestGroup) CheckOutbounds(...) { if g.checking.Swap(true) { return result, nil // 已有检查在进行静默跳过 } }goroutine 103 卡在batch.Wait()后checking永远为true。后续所有定时触发的健康检查全部静默返回没有错误日志没有任何外部信号。urltest 的120秒定时器在正常触发但每次都被checking挡回。完整因果链relay 停止转发数据TCP keepalive 保持连接存活 | --[第一层] bufio.CopyConn Read() 无 idle 超时 | goroutine 堆积加速10/min - 66/min (6x) | 每连接 4 goroutine连接不释放 | --[第二层] urltest probe Read() 同样无超时 | goroutine 381 [IO wait, 1 min] | batch.Wait() 永久阻塞 | goroutine 103 [semacquire, 3 min] | selectedOutbound 锁定在死节点 | --[第三层] checking true 永久 所有后续健康检查静默跳过 无错误日志无外部信号 | v 三重自锁 - 连接堆积第一层 - 选路冻结第二层 - 健康检查被压制第三层 - 进程无法自愈只能 kill | v 网关完全无响应100% 错误日志 看门狗检测 - 重连 - 临时恢复 relay 再次降级 - 循环重复三层缺一不可。没有第一层连接会超时释放损害有限没有第二层URLTest 会检测到死 relay 并切换没有第三层定时器会重新触发检查。三层同时存在才形成不可逆的死锁。排除的假设interrupt.Group mutex 死锁静态代码分析曾怀疑interrupt.Group.access互斥锁在Interrupt()持锁期间调用conn.Close()可能导致死锁。但 goroutine dump 明确显示0个 goroutine 阻塞在sync.Mutex.Lock。这个假设被运行时证据彻底排除。出口节点存活时间节点 存活时间 relay 数量 备注 LA 789s (13min) 5 个 IP 正常冻结 Chicago 1162s (19min) 10 个 IP relay 最多 NY 393s (6.5min) ? - Atlanta 2942s (49min) / 57s ? 57s 是级联退化 Miami 268s (4.5min) ? -Atlanta 的57秒发生在连续5次 PROXY_BROKEN 的级联退化中正常存活时间是2942秒。relay 数量越多的出口节点存活越久所有 relay 停止转发需要更长时间。context.WithTimeout 为什么不够sing-box 已经在 URL test 请求上设置了C.TCPTimeout15秒的 context 超时。但 context 取消不会中断net.Conn.Read()。当连接通过自定义DialContext获取时http.Transport不管理连接生命周期。context deadline 触发了但底层 TCPRead()继续阻塞。goroutine dump 证实goroutine 381 在TCPConn.Read上阻塞超过1分钟远超15秒 context 超时。修复必须使用conn.SetReadDeadline()直接在net.Conn上设置内核级超时。修复 (PR #4256)两个文件30行改动common/urltest/urltest.go在DialContext返回的连接上调用SetReadDeadline(time.Now().Add(C.TCPTimeout))。使用相对超时而非ctx.Deadline()因为 context deadline 是绝对时间包含 dial 阶段已消耗的时间极端情况已过期会立即超时。protocol/group/urltest.go从batchCtx派生testCtx原来用g.ctx使 batch 取消能传播到各 probebatch.Wait()外包time.NewTimer(2*TCPTimeout)硬超时30秒超时后以已有结果继续超时后清理未完成 probe 的 stale history防止performUpdateCheck选中死节点N100 Hotfix 部署surflare-proxy是独立的 sing-box 二进制文件非嵌入闭源 surflaresurflare 通过--config stdin启动它。从 v1.10.7 源码同 revision、同 build tags构建 patched 版本直接替换/usr/bin/surflare-proxy。原版备份为surflare-proxy.orig。历史 Issues全部未修复Issue 日期 版本 平台 状态 #1620 2024-03 1.8.x N100 NUC tproxy closed-stale #1607 2024-03 1.8.9 OpenWrt tun closed-stale #1738 2024-05 1.9.0-rc Ubuntu open #2027 2024 1.9.3-10 - open #4144 2026-05 1.13.11 Ubuntu tun OpenWrt open #4255 2026-06 1.10.7 iStoreOS tproxy open (本文) #4256 2026-06 testing PR fix open (本文)共同基底urltest 透明代理tun/tproxy 持续真实流量。跨版本 1.8 至 1.13从未修复。证据置信度结论 来源 置信度 batch.Wait() 永久阻塞上层 goroutine 103 semacquire 3min 已确认 bufio.CopyConn Read() 永久阻塞下层 168 IO wait goroutines 已确认 三重自锁循环 两层同在一个 dump 中 已确认 interrupt.Group mutex 未参与 dump 中 0 个 mutex 阻塞 已排除 relay 不响应是触发条件 proxy log socket conntrack 已确认 出口存活时间与 relay 容量相关 dmesg 时间戳 推断(中) 升级 sing-box 能修复 changelog 1.11-1.13 未知链接Issue: sing-box #4255PR: sing-box #4256Goroutine dump: gist关联 Issue: #4144, #1620