Go语言互斥锁Mutex与读写锁RWMutex用法详解
Go语言中的协程并发需要严格的同步机制来保护共享资源。本文详细解析了sync.Mutex(排他锁)与sync.RWMutex(读写锁)的实现原理与使用场景。通过具体的代码示例,展示了如何利用Lock/Unlock和RLock/RUnlock操作构建安全的临界区,避免数据竞争,并分析了在高频读取场景下RWMutex相较于Mutex的性能优势。
sync.Mutex:基础排他锁
在Go语言中,sync.Mutex是最基础的互斥锁类型,用于实现排他性访问。其核心逻辑简单且严格:
- 互斥性:
Lock()获取锁,Unlock()释放锁。一旦锁被持有,其他请求Lock()的goroutine将被阻塞,直到锁被释放。 - 无读写区分:Mutex不区分读操作和写操作,任何类型的访问都视为互斥。这意味着即使是只读操作,也会阻塞其他正在进行的读写操作。
- 临界区(Critical Section):
Lock()与Unlock()之间的代码块被称为临界区。该区域内的代码保证在同一时刻只有一个goroutine能够执行,从而实现线程安全。
第1步:使用Mutex保护共享变量
当多个协程同时修改同一个全局变量时,必须使用Mutex进行保护,否则会导致数据竞争(Race Condition)。以下是标准的保护模式:
package main
import (
"fmt"
"sync"
"time"
)
// 共享变量
var (
m sync.Mutex
v1 int
)
// 修改共享变量
func change(i int) {
m.Lock()
// 临界区开始
time.Sleep(time.Second)
v1 = v1 + 1
if v1%10 == 0 {
v1 = v1 - 10*i
}
// 临界区结束
m.Unlock()
}
// 访问共享变量
func read() int {
m.Lock()
a := v1
m.Unlock()
return a
}
第2步:分析并发执行的随机性
在实际并发环境中,goroutine的调度顺序是不确定的。以下测试展示了即使有锁保护,输出顺序仍然可能是随机的:
func main() {
var numGR = 21
var wg sync.WaitGroup
fmt.Printf("%d", read())
// 创建21个goroutine并发执行
for i := 0; i < numGR; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
change(i)
fmt.Printf(" -> %d", read())
}(i)
}
wg.Wait()
}
关键点说明:
- 竞争现象:虽然锁保证了临界区的独占性,但42个临界区(21次修改+21次读取)的执行顺序是随机的。
- 状态依赖:由于
change函数中包含基于v1%10==0的逻辑分支,后续执行到的goroutine可能会根据当前v1的值进行大幅调整(如减去i*10),导致数值出现跳跃。 - 非配对执行
read()并不一定紧跟在某个特定的change()之后。一个goroutine的change结束后,另一个goroutine的change可能先运行,导致read读取的是他人的修改结果。
如果忘记调用Unlock(),程序将陷入永久死锁。因此,最佳实践是使用defer m.Unlock()确保锁最终会被释放。
适合sync.Mutex的数据结构
对于内置类型的单独变量加锁并不符合封装原则。合理的做法是将锁嵌入到包含数据的结构体中:
type SafeCounter struct {
mu sync.Mutex
value int
}
func (sc *SafeCounter) Inc() {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.value++
}
这样,任何对该结构体字段的访问都需要先锁定对象本身,逻辑更加清晰且不易出错。
sync.RWMutex:读写分离锁
当业务场景呈现“读多写少”的特征时,sync.Mutex会因为串行化所有访问而成为性能瓶颈。sync.RWMutex通过区分读锁和写锁解决了这一问题。
1. 锁的特性对比
- 读锁-读锁兼容:允许任意数量的goroutine同时持有读锁进行读取。
- 读锁-写锁互斥:写锁申请时会阻塞,直到所有读锁释放;反之,当写锁持有时,新的读锁申请也会被阻塞。
- 写锁-写锁互斥:同一时间只能有一个写者。
第3步:实现读写分离的代码示例
通过比较两种不同锁策略的表现,可以直观看到RWMutex的优势:
type secret struct {
RWM sync.RWMutex
M sync.Mutex
password string
}
// RWMutex读模式:允许多个并发读取
func rwMutexShow(c *secret) string {
c.RWM.RLock()
defer c.RWM.RUnlock()
// 模拟耗时操作
time.Sleep(1 * time.Second)
return c.password
}
// Mutex读模式:强制串行读取
func mutexShow(c *secret) string {
c.M.Lock()
defer c.M.Unlock()
time.Sleep(1 * time.Second)
return c.password
}
// 写模式:两者一致
func Change(c *secret, pass string) {
c.RWM.Lock()
defer c.RWM.Unlock()
time.Sleep(3 * time.Second)
c.password = pass
}
第4步:测试并发行为
在main函数中启动5个读取goroutine和1个写入goroutine:
func main() {
Password := &secret{password: "myPassword"}
var wg sync.WaitGroup
// 启动5个读取协程
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(rwMutexShow(Password))
}()
}
// 启动1个写入协程
wg.Add(1)
go func() {
defer wg.Done()
Change(Password, "newSecret")
}()
wg.Wait()
}
执行结果分析:
- 使用RWMutex时:5个读取goroutine几乎可以同时进入临界区(因为读锁兼容),虽然每个需等待1秒,但它们在系统层面是并行执行的,总耗时较短。当写goroutine获得写锁后,其余读取任务会排队等待。
- 使用Mutex时:所有5个读取goroutine必须排队依次执行,总耗时将是单个读取耗时的5倍,显著降低了吞吐量。
用Mutex还是用RWMutex
选择哪种锁取决于具体的访问模式:
- 读写比例低(写频繁):直接使用
sync.Mutex。复杂的读写锁内部实现开销较大,如果写操作很频繁,Mutex往往更高效且更不易出错。 - 读多写少:首选
sync.RWMutex。它能极大提升并发系统的读取吞吐量。
注意事项:无论是Mutex还是RWMutex,锁都不绑定特定的goroutine。一个goroutine获得的锁可以由另一个goroutine释放(只要拥有该锁变量的引用即可),但这通常不是推荐的编码风格,容易导致逻辑混乱。
以上就是Go语言协程互斥锁Mutex和读写锁RWMutex用法实例详解的详细内容,更多关于Go语言并发编程的资料请关注本站其它相关文章!
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









