「Go语言面试题」10 - Go中的互斥锁(Mutex)和读写锁(RWMutex)有什么区别?

在并发编程中,管理对共享资源的访问是核心挑战。Go语言通过sync
包提供了强大的同步原语,其中最常用的就是互斥锁(Mutex
)和读写锁(RWMutex
)。很多开发者对二者的区别和适用场景感到困惑。今天,我们就来彻底讲清楚。
核心概念
1. 互斥锁 (Mutex - Mutual Exclusion Lock)
- 行为:就像一间只有一个钥匙的“单人卫生间”。每次只允许一个goroutine进入(加锁),其他所有goroutine,无论是想读还是想写,都必须在门口排队等待(阻塞),直到当前的goroutine用完出来(解锁)。
- 特点:完全排他,保证同一时刻只有一个goroutine能访问共享资源,保证了最强的数据一致性,但可能牺牲一些性能。
2. 读写锁 (RWMutex - Read-Write Mutex)
- 行为:更像一间“公共图书馆”。
- 读锁 (RLock):允许很多读者(goroutine)同时进入阅读(读取数据)。只要没有人在写,读者们可以共享资源。
- 写锁 (Lock):当有管理者要整理书架(写入数据)时,会获得写锁。此时,所有新的读者会被拦在门外,并且会等待所有现有的读者离开。然后,写锁保证图书馆里只有他一个人工作,防止任何人(读者或其他写者)进入,直到工作完成。
- 特点:区分了读和写操作,在高读低写的场景下能极大提升性能。
区别对比表
特性 | 互斥锁 (sync.Mutex ) |
读写锁 (sync.RWMutex ) |
---|---|---|
访问模式 | 完全互斥,不区分读写 | 区分读和写访问 |
读操作 | 串行,读的时候也不能并发读 | 并行,多个goroutine可以同时持有读锁 |
写操作 | 串行 | 串行,且写锁是排他的 |
性能倾向 | 读写操作比例相近,或写操作频繁时 | 读多写少时性能优势巨大 |
饥饿风险 | 公平锁(新版本Go实现了饥饿模式) | 写操作可能被大量读操作“饿死”(需要等待所有读锁释放) |
代码示例
让我们通过一个简单的“银行账户”例子来感受两者的区别。
1. 使用互斥锁 (Mutex)
package main
import (
"fmt"
"sync"
"time"
)
type Account struct {
balance int
mutex sync.Mutex // 保护 balance 的互斥锁
}
func (a *Account) ReadBalance() int {
a.mutex.Lock() // 加锁(即使是读,也要抢锁)
defer a.mutex.Unlock() // 确保函数返回时解锁
return a.balance
}
func (a *Account) Deposit(amount int) {
a.mutex.Lock()
defer a.mutex.Unlock()
a.balance += amount
}
func main() {
account := &Account{}
// 启动100个goroutine并发读余额
for i := 0; i < 100; i++ {
go func() {
_ = account.ReadBalance()
// time.Sleep 只是为了增大并发冲突的可能性,便于观察现象
time.Sleep(time.Millisecond)
}()
}
// 启动10个goroutine并发写余额
for i := 0; i < 10; i++ {
go func() {
account.Deposit(100)
time.Sleep(time.Millisecond)
}()
}
time.Sleep(2 * time.Second)
fmt.Println("Final balance:", account.ReadBalance())
}
在这个例子中,所有的读操作和写操作都是串行的,因为它们都在竞争同一把Mutex
锁。
2. 使用读写锁 (RWMutex)
package main
import (
"fmt"
"sync"
"time"
)
type Account struct {
balance int
rwMutex sync.RWMutex // 保护 balance 的读写锁
}
func (a *Account) ReadBalance() int {
a.rwMutex.RLock() // 加读锁(允许多个读锁同时存在)
defer a.rwMutex.RUnlock() // 解读锁
return a.balance
}
func (a *Account) Deposit(amount int) {
a.rwMutex.Lock() // 加写锁(排他锁)
defer a.rwMutex.Unlock()
a.balance += amount
}
func main() {
account := &Account{}
// 启动1000个goroutine并发读余额
// 注意:这里数量可以开得更大,因为读锁是并发的,开销很小
for i := 0; i < 1000; i++ {
go func() {
_ = account.ReadBalance()
}()
}
// 启动10个goroutine并发写余额
for i := 0; i < 10; i++ {
go func() {
account.Deposit(100)
}()
}
time.Sleep(time.Second) // 等待所有goroutine完成
fmt.Println("Final balance:", account.ReadBalance()) // 输出 Final balance: 1000
}
在这个例子中,1000
个读goroutine
可以同时执行ReadBalance
函数,而不会被彼此阻塞,它们只会被Deposit
操作中的写锁阻塞。这在高并发读取的场景下,性能提升是数量级的。
如何选择?记住这几点!
- 默认选择
Mutex
:如果你不确定该用哪个,或者读写操作比例差不多,优先使用Mutex
。它的逻辑更简单,不易出错。 - 读多写少用
RWMutex
:当你明确知道代码中存在大量的读操作(例如:缓存系统、配置读取、热点数据查询),而写操作相对很少时,RWMutex
是你的不二之选。 - 写多用
Mutex
:如果你的场景是写操作非常频繁,甚至比读操作还多,那么RWMutex
的复杂性和额外的开销(维护读者计数等)可能反而使其性能不如简单的Mutex
。因为写锁的排他性会导致读锁也无法获取,优势丧失殆尽。 - 关注临界区耗时:临界区(被锁保护的代码块)本身的执行时间也很关键。如果临界区非常短,那么所有锁的竞争都不会太激烈,
Mutex
和RWMutex
的性能差异可能不明显。如果临界区操作耗时(例如一次复杂的查询),RWMutex
的优势就会凸显。
注意事项
- 不要复制锁:锁一旦被使用,就不要再复制它。应该通过指针传递包含锁的结构体。
- 避免嵌套死锁:确保加锁和解锁成对出现,并小心在持有锁的情况下再去申请另一个锁,这很容易导致死锁。
- 使用
defer
:尽可能使用defer
来解锁,特别是在有多个返回分支的函数中,这能有效避免忘记解锁而导致死锁。
总结
Mutex
是“全能型”的卫士,简单粗暴,保证绝对安全;RWMutex
是“智慧型”的管家,懂得灵活变通,在合适的场景(读多写少)下能极大地提升效率。
选择哪一个,取决于你的具体业务场景和对性能的要求。希望本文能帮助你在下一次面对并发挑战时,做出最明智的选择。
