跳到主要内容
约 14 分钟8 张卡

Go 互斥锁

互斥锁让使用同一把锁的 goroutine 依次访问一段共享状态。Go 的 sync.Mutex 提供独占访问,sync.RWMutex 允许多个读者同时访问,但写者需要独占。

加锁是否正确,取决于锁保护了什么:一次余额更新、一个 map,还是几个字段之间的关系。锁的名字和调用次数都不能说明这个问题。解释并发代码时,要先找出需要共同成立的条件,再检查所有访问是否遵守同一套规则。本文示例和实现说明以 Go 1.22.12 为准。

1. 面试核心​

先记住这几条,再读正文
  • Mutex 的零值可以直接使用。完整的读改写操作要放在同一个临界区内,读者和写者都要遵守同一套保护规则;分别给读和写加锁仍可能丢失更新。卡 · 无数据竞争不等于操作正确
  • Mutex 同时提供互斥和同步关系。同一把 Mutex 的解锁与后续成功的 Lock 返回建立同步关系,不能用不同锁或失败的尝试代替。
  • Mutex 和 RWMutex 首次使用后不能复制;包含它们的结构体也要避免值接收者、赋值或传参产生的复制。卡 · 为什么不能复制已使用的锁
  • Mutex 不可重入,也不绑定某个 goroutine。同一个 goroutine 再次加锁仍会等待;跨 goroutine 解锁虽被允许,仍要有明确的交接与释放责任。卡 · 不可重入与跨 goroutine 解锁
  • RWMutex 允许读者共享,写者与其他访问互斥。读锁下不能修改受保护状态,不能直接升级成写锁,也不应递归加读锁。 卡 · RWMutex 与读写访问卡 · 等待写者、递归读锁与升级
  • Go 1.22.12 的 Mutex 结合快速获取、有限自旋和挂起等待。正常模式侧重吞吐,饥饿模式向等待者交接锁;实现阈值不是业务等待时限。卡 · Mutex 的等待与交接
  • TryLock 成功才获得锁,失败不建立同步关系,也不允许读取受保护数据。Lock 没有 context 参数,另起一个 goroutine 等锁也不会让锁等待自动取消。 卡 · 失败的尝试没有取得访问权卡 · Lock 不接受取消信号
  • 锁、channel 和任务计数分别表达不同约束。锁保护共享状态,channel 传递值或通知,WaitGroup 等待任务结束;选择依据是数据和执行关系,具体分工见 channel。

2. Mutex 与临界区​

2.1 保护完整操作​

临界区是需要作为一个整体访问共享状态的代码区域。mu.Lock() 成功返回后,当前执行路径获得这把锁;mu.Unlock() 释放它。其他使用同一把锁的路径要等到锁可用。

下面的 Counter 把 n 和保护它的 mu 放在一起,两个 goroutine 各加 1000 次:

package main

import (
"fmt"
"sync"
)

type Counter struct {
mu sync.Mutex
n int
}

func (c *Counter) Add() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}

func main() {
var c Counter
done := make(chan struct{}, 2)
for i := 0; i < 2; i++ {
go func() {
for j := 0; j < 1000; j++ {
c.Add()
}
done <- struct{}{}
}()
}
<-done
<-done
fmt.Println(c.Value())
}

输出 2000。c.n++ 包含读取、计算和写回,Add 保护的是这整个过程。Value 也使用同一把锁,因此它在与 Add 并发调用时仍遵守保护规则。done 只负责让主 goroutine 等到两项任务结束。

Mutex 的零值处于未锁定状态,不需要额外初始化。defer c.mu.Unlock() 在函数退出时释放锁,便于覆盖提前返回和正常的 panic 展开;它不会在循环每轮结束时执行。若循环每轮都要持锁处理,应把单轮操作放进单独函数,或在每轮明确释放。

2.2 无数据竞争不等于操作正确​

分别给读取和写入加锁,只能防止它们各自与受保护访问并发,不能让两次调用自动组成一个整体。

假设两个 goroutine 都通过加锁读取取得 n = 0,再各自加锁写入 n = 1。所有访问都遵守了锁,仍然少了一次更新。正确做法是像 Add 一样,让读取、加一和写回处于同一个临界区。数据竞争与这种业务竞态需要分开检查。

锁也不会自动保护通过别名访问的数据。方法持锁返回内部 map 或切片后,如果调用方在锁外修改它,保护就被绕过了。可以返回独立副本、只暴露受保护的方法,或者明确转移所有权;具体规则取决于对象是否还被其他路径共享。

2.3 解锁与后续加锁的同步关系​

锁还用于安排共享数据的可见性。按照 Go 内存模型,同一把 Mutex 上,第 n 次 Unlock 与之后第 m 次 Lock 的成功返回建立同步顺序,其中 n < m。前一个临界区中的写入,能够通过这条关系传递给后续临界区。

前提是访问确实使用同一把锁,而且没有其他路径在同步规则之外并发写入。变量旁边有一个 Mutex 字段,不会使编译器自动为这个变量加锁。基本同步目的见线程同步。

3. 复制、重入与释放责任​

3.1 为什么不能复制已使用的锁​

Mutex 与 RWMutex 首次使用后都不能复制。复制得到的是另一份锁状态,不是另一条指向原锁的引用;内部等待者也不会随结构体复制而迁移。

沿用前面的 Counter,下面的值接收者是错误设计:

func (c Counter) BadAdd() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

这个方法能够编译,但每次调用都会复制整个 Counter。对一个尚未使用的零值 Counter 调用 BadAdd,再读取原对象,得到的仍是 0:n 增加在副本上。go vet 会报告值接收者传递了锁。

如果结构体内保存的是 map、切片或指针,问题还会进一步变化:复制锁后,不同副本可能仍然指向同一份可变数据,于是各自加锁也无法互斥。共享引用的复制规则见Go 类型与值传递。

包含锁的可变对象通常通过指针传递,方法使用指针接收者。但仅改接收者还不够,赋值、值参数、接口装箱或容器中的值复制也可能复制整个对象。判断标准始终是:这次操作是否复制了已经使用的锁,是否让同一份数据改由不同锁保护。

3.2 不可重入与跨 goroutine 解锁​

不可重入表示同一条执行路径再次申请已经持有的锁时,不会被当作特殊情况放行。Mutex 不记录 goroutine 身份;一个 goroutine 连续两次调用同一把锁的 Lock,第二次会等待锁释放。若解锁写在第二次加锁之后,这条路径就无法继续。

方法之间的调用也会产生同样的问题:Update 持锁调用 Value,而 Value 又申请同一把锁,就会自我等待。可以把需要持锁的内部操作与对外加锁入口分开,并明确内部函数的调用前提;不要让所有函数各自加锁后再任意嵌套调用。

Go 允许一个 goroutine 加锁,再由另一个 goroutine 解锁。这是 API 的行为,不代表可以随意转交:原路径必须停止受保护访问,接收方要按明确协议承担后续访问和释放责任。普通临界区仍优先在同一条执行路径上配对加锁和解锁。

对未锁定的 Mutex 调用 Unlock 属于运行时错误;Go 1.22.12 会终止程序,不能把它当成普通的、可由 recover 修复的业务异常。RWMutex 的读锁与写锁也必须分别用 RUnlock 和 Unlock 配对。

4. RWMutex 与读写访问​

4.1 读者共享,写者独占​

读写锁区分只读访问和可能修改共享状态的访问。RLock/RUnlock 取得和释放读锁,Lock/Unlock 取得和释放写锁。

已有访问另一读者申请读锁写者申请写锁
没有持锁者,也没有待处理写者可以取得可以竞争取得
存在读者通常可共享;已有等待写者时,新读者也可能阻塞等现有读者退出
存在写者等写者释放等写者释放

下面的 Store 把 map 留在内部,读取单个整数或复制一份快照:

package main

import (
"fmt"
"sync"
)

type Store struct {
mu sync.RWMutex
values map[string]int
}

func (s *Store) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
if s.values == nil {
s.values = make(map[string]int)
}
s.values[key] = value
}

func (s *Store) Get(key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.values[key]
}

func (s *Store) Snapshot() map[string]int {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]int, len(s.values))
for key, value := range s.values {
result[key] = value
}
return result
}

func main() {
var s Store
s.Set("count", 1)
snapshot := s.Snapshot()
snapshot["count"] = 9
fmt.Println(s.Get("count"), snapshot["count"])
}

输出 1 9。Snapshot 复制了 map 条目,调用方修改新 map 不会改动 s.values;当前值类型为 int,没有继续共享的可变对象。若值改为指针或切片,这种浅拷贝只复制条目,不能自动隔离条目指向的内容。

读锁只允许共享读取,不是“比较轻的写锁”。两个 goroutine 都持读锁时仍能同时执行,因此在读锁下执行计数增加、map 赋值或更新缓存,会破坏只读前提。

4.2 等待写者、递归读锁与升级​

已有读者时,写者申请 Lock 会等待这些读者退出。为使写者能够取得锁,待处理的写锁还会阻止新的读锁直接进入。因此,不能依赖“读锁总能再次取得”。

递归读锁可能形成下面的等待关系:

  1. goroutine A 已经取得读锁。
  2. goroutine B 申请写锁,等待 A 释放。
  3. A 再次调用 RLock,这次被待处理写者阻塞。
  4. A 要等第二次 RLock 返回才释放第一次读锁,B 又要等 A,双方无法继续。

同样,持读锁再调用 Lock 不是锁升级:写者也要等待当前这个读锁释放。持写锁再调用 RLock 也不能实现原子降级。

若确实需要从读取转为修改,应先释放读锁,再取得写锁,并重新检查条件。两次持锁之间状态可能变化,不能把之前读到的条件直接当作写入依据。

4.3 读多写少是否一定更快​

RWMutex 的价值是让有意义的只读工作并行执行。读锁计数、缓存一致性、写者等待及临界区长度也有成本;读操作非常短时,额外管理成本可能抵消并行收益。

选择时同时看读写比例、并发数、持锁时间和延迟目标,再在实际工作负载下比较。不能仅凭“读操作占 99%”就断言读写锁一定优于 Mutex,也不能把单机基准结果外推到所有服务。

5. Mutex 的等待与交接​

5.1 快速获取、自旋与挂起​

Go 1.22.12 的 Mutex 用状态字段表示锁定、唤醒、饥饿和等待数量,并通过运行时信号量协调等待者。没有竞争时,快速路径尝试用原子比较并交换取得锁。

进入慢路径后,不是所有等待者都一直自旋,也不是每次竞争都立即让整个操作系统线程休眠。运行时允许时,goroutine 可以先有限自旋;仍未取得锁时登记等待并挂起,解锁再唤醒或交接。挂起的是等待的 goroutine,具体线程调度由运行时处理。

CAS 只能完成一次原子状态转换。等待队列、唤醒和状态切换仍是互斥锁实现的一部分,不能把 Mutex 简化成一个无界 CAS 循环。

5.2 正常模式与饥饿模式​

正常模式允许被唤醒的等待者与新到达的 goroutine 竞争锁。新到达者已经在 CPU 上运行,可能先拿到锁;因此正常模式不是严格的先进先出交接。

饥饿模式把锁直接交给队首等待者,新到达者进入队尾等待,不再绕过等待者争抢。Go 1.22.12 的实现使用约 1ms 的等待阈值判断是否需要进入这种模式,并在最后一个等待者或短等待等条件下切回正常模式。

这个阈值是实现策略,不是“最多等待 1ms”的承诺。调度、临界区工作量和系统负载都会影响实际等待;也不能仅凭一段程序的获取顺序,证明某次运行进入了哪种内部模式。

6. TryLock 与等待取消​

6.1 失败的尝试没有取得访问权​

TryLock 尝试立即取得锁,返回是否成功。成功时与 Lock 一样需要配对 Unlock;失败时既没有锁,也没有建立解锁到加锁的同步关系。

package main

import (
"fmt"
"sync"
)

func main() {
var mu sync.Mutex
mu.Lock()
fmt.Println(mu.TryLock())
mu.Unlock()

acquired := mu.TryLock()
fmt.Println(acquired)
if acquired {
mu.Unlock()
}
}

输出 false 和 true。第一次尝试发生在锁被持有时,失败后不能读取受保护字段来“看看有没有更新”。第二次成功,才取得访问权。TryRLock 和 RWMutex.TryLock 也要按相应读写权限使用。

即使一次尝试失败,也不能据此推断稍后谁还持有锁或对象处于什么业务状态。反复调用 TryLock 忙等,会把原本的等待变成 CPU 消耗,还需要额外处理调度、退避与取消;应先判断为何不能使用普通加锁或更合适的任务协议。

6.2 Lock 不接受取消信号​

Mutex.Lock 与 RWMutex.Lock 没有 context 或超时参数。等待调用不会因为请求的 context 已取消就自动返回。

一种有缺陷的做法是另起 goroutine 调用 Lock,外层用 select 等待超时。外层超时离开后,内部 goroutine 仍在等锁,甚至会在稍后取得锁;若无人继续执行对应的释放逻辑,任务和锁都会遗留。

需要可取消等待时,应先缩短持锁区间,避免在锁内等待网络或其他不受控工作,再设计有明确取消与结束确认的协调方式。channel 可以参与这种协议,但它也需要约定发送方、接收方和退出责任;具体见 select。不能仅把锁换成一个 channel 就默认问题已经解决。

7. 锁的范围与其他同步工具​

7.1 锁与 channel 各自表达什么​

channel 负责传值或通知,WaitGroup 等待工作结束;它们不会自动保护任务之间另外共享的状态。若这些状态仍由多个 goroutine 修改,就需要 Mutex 等同步规则;任务队列中的具体分工见 channel。

7.2 临界区长度与重新检查​

临界区应覆盖完整不变量,同时避免不必要的慢操作。持锁调用外部服务,会让其他访问一起承担网络延迟;持锁调用未知回调,还可能再次进入同一对象,造成自我等待。

如果把慢操作移到锁外,就要重新分析状态是否可能变化。例如先读到“缓存中没有这个键”,解锁并加载,再加锁时可能已经有其他请求填入结果。应在再次持锁后重新检查条件,必要时结合版本或任务合并,不能直接把旧判断当作当前事实。

涉及多把锁时,需要一致的获取顺序或其他明确的避让协议。锁只能约束遵守协议的路径,无法替你证明整个系统没有循环等待;基本方法见死锁。

8 · 面试题卡片​

本页题目与参考回答

8.1 分别给读取和写入加锁,为什么仍可能丢失更新?​

考察重点:临界区、不变量、数据竞争与业务竞态。

相关内容:无数据竞争不等于操作正确。

参考回答

两次独立加锁没有把读改写变成一个整体。 两个 goroutine 都可能先读到同一个旧值,再依次写回各自算出的相同新值。

应在一个临界区内完成读取、计算和写回,并让所有访问遵守同一套保护规则。没有数据竞争只说明访问之间有必要的同步,不能保证业务操作的划分正确。

8.2 包含 Mutex 的结构体为什么不适合使用值接收者?​

考察重点:结构体复制、锁身份与共享对象。

相关内容:为什么不能复制已使用的锁。

参考回答

值接收者会复制整个结构体,而 Mutex 首次使用后不能复制。 新副本不是原锁的别名,不能继续承担原锁的同步职责。

若数据也是普通值,方法可能只修改副本;若内部是 map、切片或指针,不同锁还可能保护同一份数据,造成并发访问错误。应使用指针接收者,并继续检查赋值和传参等其他复制位置;go vet 可以帮助发现部分问题。

8.3 Mutex 能否重入,能否由另一个 goroutine 解锁?​

考察重点:不可重入、锁身份与释放责任。

相关内容:不可重入与跨 goroutine 解锁。

参考回答

Mutex 不可重入,但允许跨 goroutine 解锁。 它不按 goroutine 身份判断“自己已经持有”,再次 Lock 仍会等待。

跨 goroutine 释放必须有明确交接协议,原执行路径不能继续无约束访问。对未锁定的锁解锁属于运行时错误,不能依赖 recover 把它恢复成正常流程。

8.4 Mutex 与 RWMutex 有什么区别,怎样选择?​

考察重点:独占与共享、读写比例、临界区长度和竞争代价。

相关内容:RWMutex 与读写访问。

参考回答

Mutex 的访问全部独占;RWMutex 允许多个读者共享,写者需要独占。 读锁下不能修改受保护状态,等待写者还会影响新读者的进入。

读写锁能否更快取决于只读工作是否有足够并行收益,以及锁管理和写等待的成本。根据并发数、临界区长度和读写比例实测,不只根据“读多写少”作判断。

8.5 持有读锁后再加读锁或写锁,有什么风险?​

考察重点:等待写者、递归读锁、锁升级和条件变化。

相关内容:等待写者、递归读锁与升级。

参考回答

递归读锁可能被等待中的写者阻塞,持读锁再申请写锁也不能完成升级。 前者可能形成读者等写者、写者等原读锁的循环;后者让写者等待自己仍未释放的读锁。

需要转为修改时,先释放读锁,再取得写锁,并重新检查共享状态。释放到重新取得之间并不是原子过程。

8.6 Go Mutex 怎样处理竞争,正常模式与饥饿模式有什么作用?​

考察重点:原子状态、自旋、挂起、唤醒与交接策略。

相关内容:Mutex 的等待与交接。

参考回答

Go 1.22.12 在无竞争时尝试快速原子获取,竞争时结合有限自旋与运行时等待。 正常模式允许被唤醒者与新到达者竞争,提高吞吐;饥饿模式向队首等待者交接,减少持续被插队的情况。

该版本使用约 1ms 的等待阈值参与模式切换,但这不是最长等待保证。状态字段、阈值和队列策略属于具体实现,不能代替 API 的互斥与同步契约。

8.7 TryLock 失败后能否读取受保护的数据?​

考察重点:获取失败、访问权限与同步关系。

相关内容:失败的尝试没有取得访问权。

参考回答

不能仅因为尝试过加锁就读取。 TryLock 返回 false 时没有取得锁,也没有建立对应的同步关系;其他路径仍可能正在修改数据。

应放弃这次受保护访问或按既定协议等待。成功后才进入临界区,并配对释放;失败结果也不是可长期依赖的锁状态快照。

8.8 在 goroutine 中等待 Lock,外层超时返回,是否就取消了加锁?​

考察重点:等待调用、取消通知与任务生命周期。

相关内容:Lock 不接受取消信号。

参考回答

没有,外层停止等待不会取消内部的 Lock。 内部 goroutine 可能继续等待,稍后获得锁;如果没有后续释放协议,就会遗留任务或占住锁。

先缩短临界区、移出不受控等待,再根据业务需要设计可取消的协调方式。退出时仍要确认派生任务结束及资源释放,不能只让调用方按时返回。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明