登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go RWMutex 读锁升级为写锁为什么会死锁

来源:17golang原创

时间:2026-09-12 15:57:33 120浏览 收藏

遇到 Go 里的“读锁升级死锁”,先看调用顺序:如果同一个 goroutine 已经执行了 RLock(),又在没有执行 RUnlock() 的情况下调用 Lock(),它会一直等下去。原因不是写锁性能慢,而是 sync.RWMutex 没有把读锁原子升级为写锁的语义;写锁必须等所有读锁离开,当前 goroutine 却正占着其中一个读锁。

安全原则是:读阶段只观察并保存快照,先释放读锁;需要修改时再申请写锁,并在写锁内重新检查条件。若业务要求“检查和更新”不可被插入,就把这段状态放进单独的 Mutex 或专门的状态锁中。
要点速览
  • RLock 后直接 Lock 不是升级,而是互相等待。
  • 释放读锁后重取写锁时,必须接受状态可能已经变化,并重新确认条件。
  • 锁的复制、漏解锁和把外部调用放进临界区,都会把问题扩大。

为什么 RLock 后再 Lock 会卡住

下面的最小示例只用来说明等待关系,不需要在生产代码中保留。Lock 的调用点永远等不到返回,所以后面的 Unlock 也永远不会执行。

package main

import "sync"

func upgrade(mu *sync.RWMutex) {
	mu.RLock()
	defer mu.RUnlock() // 读阶段原本会在函数返回时释放读锁

	mu.Lock() // 错误:写锁等待所有读锁,包括当前持有的读锁
	defer mu.Unlock() // 只有 Lock 返回后才有机会登记写锁释放
}

把现象拆成两个条件就清楚了:RLock 允许多个读者同时进入;Lock 需要排除读者。当前 goroutine 不能“暂时保留读锁并把自己算作例外”,也不能靠再次调用 RLock 来完成升级。

调用状态锁的实际要求结果
已经持有 RLock当前读者仍在锁内可以继续读取,但不能升级
调用 Lock等待全部读锁释放当前 goroutine 被挂起
准备 RUnlock需要先走过 Lock 返回点形成自等待
Go sync.RWMutex 读锁升级写锁时当前 goroutine 与全部读锁释放条件形成等待关系的静态示意图
图1:读锁升级死锁的静态关系示意;当前 goroutine 必须释放 RLock 才能满足 Lock 的条件,但它又在等待 Lock 返回。

修复:释放读锁后重新获取写锁

最常见的改法是把读写阶段分开。读锁内只取出足够判断的快照,离开读锁后再申请写锁;由于等待期间别的 goroutine 可能已经完成更新,写锁内必须再次检查。

func (c *Cache) PutIfMissing(key, value string) bool {
	c.mu.RLock()
	_, exists := c.items[key] // 读锁只负责读取共享 map
	c.mu.RUnlock()
	if exists {
		return false
	}

	c.mu.Lock()
	defer c.mu.Unlock() // 写路径统一负责释放写锁
	if _, exists = c.items[key]; exists {
		return false // 重取写锁后复核,避免覆盖先到的结果
	}
	c.items[key] = value
	return true
}

这段写法解决的是死锁,不等于把整个“检查—写入”变成了无竞争事务。真正的原子性由写锁内的第二次检查保证。若检查需要调用数据库、网络或回调,不要把这些慢操作放进锁里;先取得结果,再用短临界区提交状态。

Go 缓存共享状态从读快照到 RUnlock 再到写锁条件复核的静态边界示意图
图2:安全更新方案的静态边界示意;读阶段只产生快照,写阶段重新确认条件后更新共享状态。

什么时候应该改用 Mutex 或单独状态锁

如果业务真正需要的是“判断状态并立刻更新”,读锁带来的并发读取收益可能不值得承担两阶段重检。此时用普通 sync.Mutex 把状态转换包起来,通常更容易证明正确。

func (c *Cache) PutIfMissing(key, value string) bool {
	c.mu.Lock()
	defer c.mu.Unlock() // 检查与写入共享同一把互斥锁
	if _, exists := c.items[key]; exists {
		return false
	}
	c.items[key] = value
	return true
}

还可以把“高频只读数据”和“需要原子变更的状态”拆开:前者由 RWMutex 保护,后者由独立 Mutex 保护。关键不是锁的名字,而是让一个不变量只由一条清晰的锁边界维护。

排查时别漏掉这几个锁坑

  • defer 位置太晚:defer RUnlock() 只适合保护整个读临界区;如果后面还要申请写锁,应在申请写锁前显式释放。
  • 复制含锁的结构体:RWMutex 不应在首次使用后复制,缓存对象应通过指针或构造后固定的实例共享。
  • 错误的解锁配对:RLock 对应 RUnlockLock 对应 Unlock,混用会触发运行时错误。
  • 把外部工作放进临界区:网络、磁盘和回调时间不可控,会让等待被误判成升级死锁。

修复后可以给锁竞争路径加一个有限超时的测试 harness,再用 go test -race 检查数据竞争。竞态检测器不能证明没有死锁,但能发现另一类“先读后写未受保护”的问题;死锁判断仍要靠清晰的锁顺序和可退出测试。

相关问题

RWMutex 能不能在 RLock 后先 RUnlock 再 Lock?

可以,这是常见的分阶段写法,但两次调用之间状态可能变化,因此写锁内必须重新检查条件。

多个 goroutine 都想从读锁升级会怎样?

它们都可能持有读锁并等待写锁,而写锁又等待读锁清空,形成整体停顿。不要把升级逻辑放进共享的读路径。

读多写少就一定应该用 RWMutex 吗?

不一定。若临界区很短、状态转换很多或证明成本较高,普通 Mutex 往往更直接;先按不变量和实际竞争选择。

结论

Go sync.RWMutex 的读锁和写锁是两种互斥语义,不提供升级通道。看到“RLock 后 Lock 卡住”,先检查是否让当前读者等待自己退出;然后把读快照、释放读锁、写锁复核和状态更新分成清楚的边界。需要原子检查更新时,直接使用 Mutex 或独立状态锁,通常比模拟升级更稳。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>