What happened:
PR #107 为 session.EndPoint() 加上了读锁,用于消除它与 Reset() 中 s.endPoint = nil 之间的数据竞争。这个改动本身是必要的,但它没有检查调用方 —— UDP 路径的 Send 会回调 EndPoint(),于是同一个 goroutine 在同一把 s.lock 上拿了两次读锁,形成 读① 【 写 → 读② 】 的结构
由于 Go 的 RWMutex 是写者优先的,三方构成循环等待:写锁等读① 释放 → 读② 等写锁完成 → 读① 的释放需要函数返回,而函数卡在读②。死锁,无超时、无告警。
读① — transport/session.go:1217,defer 使读锁覆盖了整个 Connection.Send 调用:
func (s *session) Send(pkg any) (int, error) {
if s == nil {
return 0, nil
}
s.lock.RLock()
defer s.lock.RUnlock() // ← 读① 持有到函数返回
if s.Connection != nil {
return s.Connection.Send(pkg) // ← 攥着读① 跳进接口实现
}
return 0, nil
}
读② — transport/session.go:292,PR #107 新加:
func (s *session) EndPoint() EndPoint {
s.lock.RLock() // ← 读②
defer s.lock.RUnlock()
return s.endPoint
}
中间那一跳 — transport/connection.go:507,gettyUDPConn.Send 通过反向引用 u.ss 回调 session:
func (u *gettyUDPConn) Send(udpCtx any) (int, error) {
...
if u.ss.EndPoint().EndPointType() == UDP_ENDPOINT { // ← 回调,读② 在这里发生
peerAddr = ctx.PeerAddr
if peerAddr == nil {
return 0, ErrNullPeerAddr
}
}
u.ss 与调用方的 s 是同一个对象,由 session.go:213 建立:
ss.Connection.SetSession(ss) // Connection 反手持有 session,形成双向引用
写 — 任意一个 s.lock.Lock() 都可以充当,session.go 中共有十余处。都可能被调用(如 setName/gc)
TCP / WebSocket 路径不受影响,因为 gettyTCPConn.Send 与 gettyWSConn.Send 都不回调 session。
危害:两个 goroutine 永久卡死且不可恢复;此后所有访问 s.lock 的操作(包括 Close()、Stat()、心跳)全部堆积,该 session 无法回收,fd 与 goroutine 持续泄漏。Close() 本身走 gc(),正是死锁的另一半,因此无法自救。
What you expected to happen:
降低锁粒度,不要用 defer 把读锁的持有范围扩大到跨越网络 I/O 与接口回调。锁只应用于安全地取出字段快照,随后立即释放,真正的 I/O 在锁外进行。
How to reproduce it (as minimally and precisely as possible):
把下面的测试放到 transport/ 下,go test -run TestUDPSendEndPointRecursiveRLockDeadlock ./transport/。在我的环境上首次运行即在 1 秒内死锁(日志里只打出了一条 WriteMsgUDP)。
package getty
import (
"net"
"runtime"
"testing"
"time"
)
// session.Send -> gettyUDPConn.Send -> session.EndPoint 递归读锁死锁复现
func TestUDPSendEndPointRecursiveRLockDeadlock(t *testing.T) {
srv := newServer(UDP_ENDPOINT, WithLocalAddress("127.0.0.1:0"))
laddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:0")
conn, err := net.ListenUDP("udp", laddr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
peer, _ := net.ResolveUDPAddr("udp", "127.0.0.1:65531")
ss := newUDPSession(conn, srv)
sent := make(chan struct{})
go func() { // goroutine A:持有读①,内部还需要读②
for i := 0; i < 500000; i++ {
_, _ = ss.Send(UDPContext{Pkg: []byte("x"), PeerAddr: peer})
}
close(sent)
}()
go func() { // goroutine B:排队中的写者
for {
select {
case <-sent:
return
default:
}
ss.SetAttribute("k", "v")
}
}()
select {
case <-sent:
t.Log("completed without deadlock this run")
case <-time.After(20 * time.Second):
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, true)
t.Logf("%s", buf[:n])
t.Fatal("DEADLOCK: sender stuck in session.Send -> EndPoint")
}
}
死锁时抓到的 goroutine 栈(Go 1.25.0,linux/amd64):
goroutine A [semacquire]:
sync.runtime_SemacquireRWMutexR(...)
runtime/sema.go:100
sync.(*RWMutex).RLock(...)
sync/rwmutex.go:74
getty/transport.(*session).EndPoint(0xc0000f8480)
transport/session.go:293 ← 读②,阻塞在此
getty/transport.(*gettyUDPConn).Send(0xc0000be180, ...)
transport/connection.go:507
getty/transport.(*session).Send(0xc0000f8480, ...)
transport/session.go:1224 ← 读① 仍持有
goroutine B [semacquire]:
sync.runtime_SemacquireRWMutex(...)
runtime/sema.go:105
sync.(*RWMutex).Lock(...)
sync/rwmutex.go:155
getty/transport.(*session).SetAttribute(0xc0000f8480, ...)
transport/session.go:432 ← 写,排队中
## 建议的修复:
``` go
func (s *session) Send(pkg any) (int, error) {
if s == nil {
return 0, nil
}
// 快照后立即释放:gettyUDPConn.Send 会回调 s.EndPoint(),
// 而后者同样要 s.lock.RLock()。持锁跨越该调用即构成递归读锁。
s.lock.RLock()
conn := s.Connection
s.lock.RUnlock()
if conn != nil {
return conn.Send(pkg)
}
return 0, nil
}
应用该修复后,上面的复现用例 -count=3 连续通过(55s,全部跑满 50 万次发送)。
Anything else we need to know?:
同时 session 还有其他问题,干脆一起修了:
session.WriteTimeout() 缺少 ReadTimeout() 已有的 nil 保护(session.go:1229 有、WriteTimeout 没有),而 stop() 在 session.go:1041 会调用它,gc() / Reset() 会把 s.Connection 置 nil;
gc() 中 conn.CloseConn(int(wait)) 把 time.Duration(纳秒)当作秒传给 SetLinger,3e9 溢出 int32 后变成负数,实测导致 Close() 阻塞超过 120 秒;
CloseConn 结尾把 t.conn 置 nil,而它是在独立 goroutine 中执行的,与用户侧 WriteBytes 并发时直接 SIGSEGV
What happened:
PR #107 为 session.EndPoint() 加上了读锁,用于消除它与 Reset() 中 s.endPoint = nil 之间的数据竞争。这个改动本身是必要的,但它没有检查调用方 —— UDP 路径的 Send 会回调 EndPoint(),于是同一个 goroutine 在同一把 s.lock 上拿了两次读锁,形成 读① 【 写 → 读② 】 的结构
由于 Go 的 RWMutex 是写者优先的,三方构成循环等待:写锁等读① 释放 → 读② 等写锁完成 → 读① 的释放需要函数返回,而函数卡在读②。死锁,无超时、无告警。
读① — transport/session.go:1217,defer 使读锁覆盖了整个 Connection.Send 调用:
读② — transport/session.go:292,PR #107 新加:
中间那一跳 — transport/connection.go:507,gettyUDPConn.Send 通过反向引用 u.ss 回调 session:
ss.Connection.SetSession(ss) // Connection 反手持有 session,形成双向引用
写 — 任意一个 s.lock.Lock() 都可以充当,session.go 中共有十余处。都可能被调用(如 setName/gc)
TCP / WebSocket 路径不受影响,因为 gettyTCPConn.Send 与 gettyWSConn.Send 都不回调 session。
危害:两个 goroutine 永久卡死且不可恢复;此后所有访问 s.lock 的操作(包括 Close()、Stat()、心跳)全部堆积,该 session 无法回收,fd 与 goroutine 持续泄漏。Close() 本身走 gc(),正是死锁的另一半,因此无法自救。
What you expected to happen:
降低锁粒度,不要用 defer 把读锁的持有范围扩大到跨越网络 I/O 与接口回调。锁只应用于安全地取出字段快照,随后立即释放,真正的 I/O 在锁外进行。
How to reproduce it (as minimally and precisely as possible):
把下面的测试放到 transport/ 下,go test -run TestUDPSendEndPointRecursiveRLockDeadlock ./transport/。在我的环境上首次运行即在 1 秒内死锁(日志里只打出了一条 WriteMsgUDP)。
goroutine A [semacquire]:
sync.runtime_SemacquireRWMutexR(...)
runtime/sema.go:100
sync.(*RWMutex).RLock(...)
sync/rwmutex.go:74
getty/transport.(*session).EndPoint(0xc0000f8480)
transport/session.go:293 ← 读②,阻塞在此
getty/transport.(*gettyUDPConn).Send(0xc0000be180, ...)
transport/connection.go:507
getty/transport.(*session).Send(0xc0000f8480, ...)
transport/session.go:1224 ← 读① 仍持有
goroutine B [semacquire]:
sync.runtime_SemacquireRWMutex(...)
runtime/sema.go:105
sync.(*RWMutex).Lock(...)
sync/rwmutex.go:155
getty/transport.(*session).SetAttribute(0xc0000f8480, ...)
transport/session.go:432 ← 写,排队中
应用该修复后,上面的复现用例 -count=3 连续通过(55s,全部跑满 50 万次发送)。
Anything else we need to know?:
同时 session 还有其他问题,干脆一起修了:
session.WriteTimeout() 缺少 ReadTimeout() 已有的 nil 保护(session.go:1229 有、WriteTimeout 没有),而 stop() 在 session.go:1041 会调用它,gc() / Reset() 会把 s.Connection 置 nil;
gc() 中 conn.CloseConn(int(wait)) 把 time.Duration(纳秒)当作秒传给 SetLinger,3e9 溢出 int32 后变成负数,实测导致 Close() 阻塞超过 120 秒;
CloseConn 结尾把 t.conn 置 nil,而它是在独立 goroutine 中执行的,与用户侧 WriteBytes 并发时直接 SIGSEGV