Skip to content

【BUG】Session 锁的粒度过大导致 UDP 路径出现死锁现象 #112

Description

@NeverENG

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.0linux/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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions