4.6 学习 Go 协程:互斥锁和读写锁
在 Golang 里有专门的方法来实现锁,还是上一节里介绍的 sync 包。
这个包有两个很重要的锁类型
一个叫 sync.Mutex 利用它可以实现互斥锁。
一个叫 sync.RWMutex,利用它可以实现读写锁。
一、互斥锁(Mutex)
1. 概念与作用
互斥锁(Mutex,Mutual Exclusion)是一种最常用的控制共享资源访问的方法。它确保在任何时间点,只有一个 goroutine 能够访问被保护的共享资源(也称为临界区)。
- 特点: 完全互斥。一旦一个 goroutine 获得锁,其他所有试图获取该锁的 goroutine(无论读写)都会被阻塞,直到锁被释放。
2. Mutex 的操作方法
sync.Mutex 提供了两个核心方法:
| 方法 | 作用 | 描述 |
|---|---|---|
Lock() | 加锁 | 锁定互斥锁。如果锁已经被其他 goroutine 占用,则当前 goroutine 会被阻塞,直到锁被释放。 |
Unlock() | 解锁 | 释放互斥锁。将锁交给等待中的 goroutine(唤醒策略是随机的)。 |
3. 使用示例
下面这段代码,开启了三个协程,每个协程分别往 count 这个变量加1000次 1,理论上看,最终的 count 值应试为 3000:
package main
import (
"fmt"
"sync"
)
func add(count *int, wg *sync.WaitGroup) {
for i := 0; i < 1000; i++ {
*count = *count + 1
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
count := 0
wg.Add(3)
go add(&count, &wg)
go add(&count, &wg)
go add(&count, &wg)
wg.Wait()
fmt.Println("count 的值为:", count)
}可运行多次的结果,都不相同
count 的值为: 2850
count 的值为: 2966
count 的值为: 2959原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。
import (
"fmt"
"sync"
)
func add(count *int, wg *sync.WaitGroup, lock *sync.Mutex) {
for i := 0; i < 1000; i++ {
lock.Lock()
*count = *count + 1
lock.Unlock()
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
lock := &sync.Mutex{}
count := 0
wg.Add(3)
go add(&count, &wg, lock)
go add(&count, &wg, lock)
go add(&count, &wg, lock)
wg.Wait()
fmt.Println("count 的值为:", count)
}此时,不管你执行多少次,输出都只有一个结果
count 的值为: 30004. 使用注意事项
- 加解锁配对: 必须在
Lock()之后调用Unlock()。 defer推荐: 强烈建议使用defer lock.Unlock()来确保锁的释放,即使函数提前返回或发生panic。- 禁止重复操作:
- 不要对一个尚未解锁的锁再次加锁(会导致死锁)。
- 不要对一个已解锁的锁再次解锁(会导致
panic)。
二、读写锁(RWMutex)
1. 概念与作用
读写锁(RWMutex,Read-Write Mutex)适用于读多写少的场景。它对操作进行了区分,允许多个 goroutine 同时进行读操作,但写操作必须是独占的。
- 特性:
- 读锁共享: 当一个 goroutine 获得读锁后,其他 goroutine 仍然可以获得读锁(共享访问)。
- 写锁独占: 当一个 goroutine 获得写锁后,其他 goroutine 无论是想获取读锁还是写锁,都必须等待(独占访问)。
2. RWMutex 的操作方法
sync.RWMutex 提供了四种方法,分别对应读操作和写操作的加锁和解锁:
| 类型 | 方法 | 作用 | 描述 |
|---|---|---|---|
| 写锁 | Lock() | 加写锁 | 阻塞所有读写操作。用于写入数据前加锁。 |
| 写锁 | Unlock() | 解写锁 | 释放写锁。 |
| 读锁 | RLock() | 加读锁 | 允许其他 goroutine 同时持有读锁。用于读取数据前加锁。 |
| 读锁 | RUnlock() | 解读锁 | 释放读锁。 |
3. 读写锁的工作机制
| 状态 | 谁可以进入临界区? | 试图进入的 goroutine 会发生什么? |
|---|---|---|
| 正在写(持有写锁) | 只有当前写 goroutine | 所有新的读/写操作都会阻塞 |
| 正在读(持有读锁) | 当前读 goroutine 和其他读 goroutine | 新的读操作可以继续;新的写操作会阻塞 |
| 空闲 | 任何读或写操作 | 读/写操作都可以立即获得相应的锁 |
4. 使用示例(读多写少)
使用读写锁可以大幅提升读操作的并发性能,尤其在读操作耗时较长时。
package main
import (
"fmt"
"sync"
"time"
)
var (
data int64
wg sync.WaitGroup
rwlock sync.RWMutex // 1. 定义一个读写锁
)
// 写操作:耗时较长
func write() {
rwlock.Lock() // 2. 加写锁
defer rwlock.Unlock() // 3. 释放写锁
data++
fmt.Printf("执行写操作,Data = %d\n", data)
time.Sleep(10 * time.Millisecond) // 模拟写操作耗时
}
// 读操作:耗时较短
func read(id int) {
rwlock.RLock() // 4. 加读锁
defer rwlock.RUnlock() // 5. 释放读锁
fmt.Printf("Reader %d 读取 Data = %d\n", id, data)
time.Sleep(time.Millisecond) // 模拟读操作耗时
}
func main() {
// 启动 10 个写操作(修改数据)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
write()
wg.Done()
}()
}
// 启动 100 个读操作(查询数据)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
read(id)
wg.Done()
}(i)
}
wg.Wait()
fmt.Println("所有操作完成。")
}
// 在执行时,你会发现多个 Reader 可以同时打印结果,
// 而 Mutex 则会强制所有操作排队,RWMutex 显著提高了读并发性。三、零值可用性
sync.Mutex 和 sync.RWMutex,可以只定义(声明),不需要显式地实例化(比如使用 new() 或 & 运算符)就可以直接使用。
在 Go 语言中,sync.Mutex 和 sync.RWMutex 都是结构体。它们遵循 Go 接口和并发原语设计的一个重要原则:零值可用性。
当您仅声明一个 sync.Mutex 或 sync.RWMutex 变量时,Go 语言会将其初始化为其类型的零值。对于这两种锁结构体,它们的零值被设计成一个 未锁定的(unlocked) 可用状态。
1. sync.Mutex 示例
package main
import "sync"
// 仅声明,使用零值
var lock sync.Mutex
func main() {
// 零值(未锁定)可以直接调用 Lock()
lock.Lock()
// ... 临界区代码
// 零值可以直接调用 Unlock()
lock.Unlock()
}2. sync.RWMutex 示例
package main
import "sync"
// 仅声明,使用零值
var rwLock sync.RWMutex
func main() {
// 零值可以直接调用读锁方法
rwLock.RLock()
// ... 读取操作
rwLock.RUnlock()
// 零值可以直接调用写锁方法
rwLock.Lock()
// ... 写入操作
rwLock.Unlock()
}总结与推荐用法
| 声明方式 | 示例 | 状态 | 推荐场景 |
|---|---|---|---|
| 零值声明 | var lock sync.Mutex | 零值(未锁定),可用 | 作为全局变量或结构体的字段时,最常用且推荐。 |
| 显式初始化 | lock := &sync.Mutex{} | 零值(未锁定),可用 | 仅在你希望明确创建一个指针时使用,但不必要。 |
最佳实践: 当将锁作为结构体的字段使用时,只需将其定义为字段类型即可,无需显式初始化,它会自动初始化为零值并可用。
type SafeData struct {
mu sync.Mutex // 零值可用,不需要写成 mu: sync.Mutex{}
data int
}四、总结与选择
| 特性 | sync.Mutex(互斥锁) | sync.RWMutex(读写锁) |
|---|---|---|
| 并发读 | 独占(只能一个 goroutine 读) | 共享(可以多个 goroutine 同时读) |
| 并发写 | 独占(只能一个 goroutine 写) | 独占(只能一个 goroutine 写) |
| 使用场景 | 共享资源被频繁修改,或者读写比例接近。 | 读多写少的场景,可以最大化读的并发效率。 |
| 性能开销 | 较低(简单) | 较高(内部实现更复杂) |
选择建议:
- 首选 Channel:如果可以使用 Channel 实现数据安全交换,优先使用 Channel。
- 写多/读写均衡:使用
sync.Mutex。 - 读多写少:使用
sync.RWMutex以获得更好的性能。
希望这份关于 Go 语言锁机制的教程能帮助您更好地理解并发安全!