2团
Published on 2026-04-07 / 3 Visits
0
0

Go中RWMutex与database_sql的隐秘死锁—closingMutex修复揭秘

0. 引言

sync.RWMutex是Go中最常见的并发原语之一,通常面向典型的读写锁场景,尤其适合多读少写的访问模式。sync.RWMutex一旦有写操作进入等待状态,新的读操作申请就会被阻塞。这个特性通常用于避免写者饥饿,但在database/sql中,它与Rows.ScanRows.Columns和后台Close的调用顺序叠加后,会形成一个真实的死锁。

问题的触发并不复杂,根据官方ISSUE描述:Scansql.RawBytes持续占有读锁,context取消后后台Close开始等待写锁,此时同一调用链再执行Columns,新的RLock会被写者优先策略挡住。至此,前一个读锁不释放,写锁拿不到,新的读锁也进不来,循环等待成立,死锁就出现了。

Go官方没有重写database/sql的外层调用关系,而是引入了一个更窄的同步原语:closingMutex。它不是通用读写锁的替代品,而是专门为Close这个退出操作设计的锁。它的关键设计为:写操作等待时,不阻塞新的读操作。换句话说,在这个特定同步场景里,系统更希望先让尚未完成的读操作自然收敛,再让关闭操作取得独占控制权。

1. 为什么这里需要closingMutex

1.1 sync.RWMutex适合什么场景

sync.RWMutex面向的是典型读写锁问题:多个读操作可以并发,写操作需要独占,并且实现需要避免写者长期饥饿。因此,一旦有写操作进入等待状态,新的读操作会被挡在外面。这在一般的多读少写场景里是合理的。

database/sql里的这把锁并不完全服务于“普通写操作”。这里的写锁主要用于Close,也就是退出和清理,而不是更新共享业务状态。对于这种场景,最重要的约束不是“让写操作尽快执行”,而是“退出过程不能把仍在收尾的读操作卡死”。

1.2 closingMutex 是什么

closingMutex是Go官方针对ISSUE:database/sql: avoid deadlock from reentrant RLock提出的针对性改进。它保留了“读共享、写独占”的基本形式,但调整了优先级规则:

  1. 写锁真正持有时,读操作必须等待。
  2. 写操作仅处于等待状态时,新的读操作仍然可以进入。

注意:这不是一个具备普适性的读写锁改造,而是针对Close操作做出的局部调整。

1.3 官方提交与本机源码的对应关系

从官方提交可以看到,src/database/sql/sql.go中用于Rows关闭同步的字段会从:

closemu sync.RWMutex

替换为:

closemu closingMutex

2. 死锁是如何发生的

database/sql.Rows需要协调两类操作:

  • ScanColumnsNext等读操作
  • Close的关闭操作

旧实现使用sync.RWMutex:读操作走RLock,关闭操作走Lock。单看职责划分并没有问题,问题出在下面这组三步顺序:

1. 调用Rows.Scan,并将列读入sql.RawBytes
   -> Rows持有读锁
   -> 因为RawBytes引用内部缓冲区,Scan返回后读锁不能立刻释放

2. context被取消
   -> database/sql后台goroutine调用rows.close()
   -> close尝试获取写锁,但被步骤1的读锁阻塞

3. 调用Rows.Columns
   -> Columns需要再次获取读锁
   -> 由于已有写操作在等待,sync.RWMutex阻塞新的RLock
   -> 死锁

关键点不在“重复加读锁”本身,而在sync.RWMutex的语义:有写操作等待时,新的读操作不能进入。于是等待关系变成了:

  • 读者A持有旧的RLock,等待新的RLock
  • 写者B等待读者A释放旧的RLock

这正是循环等待。如果把这里的锁换成closingMutex,差异就很直接:

  • sync.RWMutex:写操作等待时,新的RLock会阻塞;
  • closingMutex:只要写锁尚未真正持有,新的RLock仍然可以成功。

它不是通用意义上的“更强”读写锁,而是一个专门为Close场景定制的锁。

2.1 用SQLite复现这个问题

下面这段测试可以在真实的database/sql调用链里复现死锁:

func TestSQLiteRealDeadlock(t *testing.T) {
    result := make(chan string, 1)

    go func() {
        db, _ := sql.Open("sqlite", ":memory:")
        db.Exec(`CREATE TABLE documents (id INTEGER, title TEXT, data BLOB)`)
        db.Exec(`INSERT INTO documents VALUES (1, "test", ?)`, make([]byte, 1024))

        ctx, cancel := context.WithCancel(context.Background())
        rows, _ := db.QueryContext(ctx, "SELECT * FROM documents")
        rows.Next()

        var id int
        var title string
        var rb sql.RawBytes
        rows.Scan(&id, &title, &rb)

        cancel()
        time.Sleep(200 * time.Millisecond)

        rows.Columns()
        result <- "done"
    }()

    select {
    case <-result:
        t.Fatal("expected deadlock, but Columns returned")
    case <-time.After(3 * time.Second):
        t.Log("deadlock confirmed")
    }
}

这段示例代码完整展示了database/sql的潜在崩溃场景:RawBytes持锁、context取消触发后台CloseColumns再次取读锁。三者组合后,死锁稳定出现。

3. closingMutex如何修复这个问题

Go官方的修复思路是比较巧妙的:既然问题出在Close与读操作的同步语义,就只替换这里的锁语义,而不改动外层API。

3.1 核心规则

closingMutex的核心规则可以概括为两句:

  1. 写锁真正持有时,读操作必须等待;
  2. 写操作仅仅处于等待状态时,新的读操作仍然可以进入。

第二条正是它与sync.RWMutex的决定性差异。旧锁追求“避免写者饥饿”,这是典型读写锁在多读少写场景下的通用设计;新锁追求“避免关闭操作把读操作卡死”,消除掉循环等待场景。在database/sql这里,写锁并不是为了推进某个普通写动作,而是为了完成退出和清理,因此比起尽快让写操作进入,更重要的是不要让退出过程反过来堵死尚未完成的读取流程。也可以把它理解为:先尽可能让读操作收敛,再让关闭操作完成清理。

3.2 状态编码

锁的内部状态量比较精简:

type closingMutex struct {
    // state = 2*readers + writerWaitingBit
    //   0  : 未锁定
    //   1  : 无读者,但有写者等待
    //   >0 : 存在读者,最低位表示是否有写者等待
    //   -1 : 写锁持有中
    state atomic.Int64
    mu    sync.Mutex
    read  *sync.Cond
    write *sync.Cond
}

这个设计把“读者数量”和“是否有写者等待”压进了一个原子整数里,因此无竞争路径可以直接用CAS完成,只有真正需要等待时才借助sync.Cond

3.3 TryRLock:写操作等待不是阻塞条件

func (m *closingMutex) TryRLock() bool {
    for {
        x := m.state.Load()
        if x < 0 {
            return false
        }
        if m.state.CompareAndSwap(x, x+2) {
            return true
        }
    }
}

这里最重要的判断是x < 0。只有写锁已经持有时,读操作才失败;如果只是有写操作在等待,例如state == 1state == 3,读操作仍然可以通过CAS把状态变更到下一个值。

这和sync.RWMutex正好相反。sync.RWMutex一旦察觉有写操作等待,就会拦下后续的RLock;而closingMutex只关心“写锁是否已经拿到”,不关心“写操作是否已经排队”。

3.4 Lock:写操作先登记,再等待最后一个读操作退出

func (m *closingMutex) Lock() {
    m.mu.Lock()
    defer m.mu.Unlock()
    for {
        x := m.state.Load()
        if (x == 0 || x == 1) && m.state.CompareAndSwap(x, -1) {
            return
        }
        if x&1 == 0 && !m.state.CompareAndSwap(x, x|1) {
            continue
        }
        m.init()
        m.write.Wait()
    }
}

写操作到来后,如果当前还有读操作,就先把最低位设成1,表示“已经有写操作在等”。但和sync.RWMutex不同,这个标志不会阻止新读操作进入;它只是在告诉最后一个离开的读操作:现在应该唤醒写操作了。

3.5 RUnlock:最后一个读操作负责唤醒写操作

func (m *closingMutex) RUnlock() {
    for {
        x := m.state.Load()
        if x < 2 {
            panic("runlock of un-rlocked mutex")
        }
        if m.state.CompareAndSwap(x, x-2) {
            if x-2 == 1 {
                m.mu.Lock()
                defer m.mu.Unlock()
                m.write.Broadcast()
            }
            return
        }
    }
}

x-2 == 1时,说明当前所有的读操作均已经完成,读操作计数归零,但写操作等待标志仍然存在。这正是“最后一个读操作离场”的瞬间,因此需要唤醒等待中的写操作。

整个机制的含义很明确:读操作可以继续进入,但写操作不会永远沉睡;一旦最后一个读操作退出,写操作就会被推进到真正的持锁阶段。

4. 把修复过程放回死锁现场

还是回到前面的三步顺序,只是这次把锁换成closingMutex

初始state = 0

1. Scan获取读锁
   state: 0 -> 2

2. 后台Close到来,发现有读操作
   state: 2 -> 3
   含义:1 个读操作 + 1 个写操作等待

3. Columns再次获取读锁
   state: 3 -> 5
   成功,因为closingMutex不会因“写操作等待”阻塞新读操作

4. Columns结束,释放一次读锁
   state: 5 -> 3

5. RawBytes对应的读锁释放
   state: 3 -> 1
   最后一个读操作离开,唤醒写操作

6. Close获得写锁
   state: 1 -> -1

7. Close完成
   state: -1 -> 0

如果这里仍然使用sync.RWMutex,第3步不会从3走到5,而是直接卡住。修复的关键不在于更快,也不在于更通用,而在于它允许“等待中的关闭操作”与“尚未结束的读操作”按正确顺序自然收敛,而不是互相卡死。这个顺序并不适合作为通用读写锁原则推广,但在Close只承担退出职责的场景下,它恰好合适。

5. 用测试理解这个修复

5.1 官方测试说明了什么

除了SQLite复现之外,Go源码里的官方测试也说明了这一点。它直接验证了closingMutex最重要的一条性质:写操作等待时,新的读操作仍然能进入。

m.RLock()
lock3Done := start(t, m.Lock)

m.RLock()
m.RUnlock()

m.RUnlock()
wait(t, lock3Done)

这段测试很短,但信息非常集中:

  • 第一行让读操作先进入。
  • 第二行让写操作开始等待。
  • 第四行再次RLock,这是整个修复的判定点。

如果锁仍然遵循sync.RWMutex的语义,这里新的RLock会被阻塞;而在closingMutex下,它必须成功。也就是说,官方测试本身就准确刻画了这次修复要保证的行为,而不是只验证“最终没有超时”。

6. 结语

这个问题提醒我们,并发原语的差异不只是性能取舍,更是语义取舍。sync.RWMutex的写者优先在大多数读写锁场景里是合理的,尤其适合多读少写且需要避免写者长期饥饿的场合;但在database/sql的关闭操作里,在特定的场景里它可能导致死锁。

closingMutex的实现并不复杂,却很巧妙:它没有试图成为一个通用的新锁,只是把“写操作等待时是否拦截新读操作”这条规则改成了更适合Close场景的版本,克制的同时,也显现出精妙。


Comment