Go语言并发编程:互斥锁与读写锁详解及实战
在Go语言并发编程中,当多个Goroutine同时访问同一块内存资源时,容易引发竞态条件(Race Condition)。为了解决这一安全问题,Go标准库sync包提供了互斥锁Mutex和读写锁RWMutex。本文将通过具体的存取款代码实例,详细讲解这两种锁的使用场景、操作规范及区别。
一、互斥锁Mutex
第1步:理解互斥锁的作用机制
互斥锁用于保护临界区,确保在同一时刻只有一个Goroutine能够进入该区域。它主要包含两个核心操作:Lock()用于加锁,Unlock()用于解锁。
使用注意事项:
- 严禁重复锁定:重复加锁会导致阻塞甚至死锁(deadlock)。
- 必须配对解锁:每一个
Lock调用都必须有且仅有一个对应的Unlock,通常推荐使用defer mutex.Unlock()来简化逻辑并确保即使发生 panic 也能正确释放。 - 禁止传递值类型:
sync.Mutex是值类型,直接作为参数传递给函数会产生副本,导致原锁状态失效。应传递指针或将其包裹在结构中。 - 避免未锁定解锁:对未锁定或已解锁的锁执行解锁操作会导致运行时恐慌。
第2步:通过代码演示竞态问题
以下代码模拟了一个账户的初始余额为1000,通过10个协程交替执行存钱和取钱操作。其中protecting变量控制是否启用加锁逻辑,sign通道用于等待所有协程结束。
package main
import (
"flag"
"fmt"
"sync"
)
var (
mutex sync.Mutex
balance int
protecting uint // 是否加锁,0表示不加锁,1表示加锁
sign = make(chan struct{}, 10) //通道,用于等待所有goroutine
)
// 存钱
func deposit(value int) {
defer func() {
sign <- struct{}{}
}()
if protecting == 1 {
mutex.Lock()
defer mutex.Unlock()
}
fmt.Printf("余额: %d\n", balance)
balance += value
fmt.Printf("存 %d 后的余额: %d\n", value, balance)
fmt.Println()
}
// 取钱
func withdraw(value int) {
defer func() {
sign <- struct{}{}
}()
if protecting == 1 {
mutex.Lock()
defer mutex.Unlock()
}
fmt.Printf("余额: %d\n", balance)
balance -= value
fmt.Printf("取 %d 后的余额: %d\n", value, balance)
fmt.Println()
}
func main() {
for i:=0; i < 5; i++ {
go withdraw(500) // 取500
go deposit(500) // 存500
}
for i := 0; i < 10; i++ {
<-sign
}
fmt.Printf("当前余额: %d\n", balance)
}
func init() {
balance = 1000 // 初始账户余额为1000
flag.UintVar(&protecting, "protecting", 0, "是否加锁,0表示不加锁,1表示加锁")
}
第3步:分析加锁前后的执行结果
不加锁情况(protecting=0):
当不加锁时,多个协程同时读取并修改balance,导致数据错乱。例如,某次取款后余额显示仍为1000,这是因为在读取旧值的同时,其他协程已经修改了余额,造成了竞态条件。
余额: 1000
存 500 后的余额: 1500
余额: 1000
取 500 后的余额: 1000
...
当前余额: 1000
加锁情况(protecting=1):
启用互斥锁后,同一时间只有一个协程能修改余额。虽然输出顺序可能因调度而异,但余额的变化是原子性的,最终结果将保持在预期范围内(1000元),消除了数据竞争。
余额: 1000
取 500 后的余额: 500
余额: 500
存 500 后的余额: 1000
...
当前余额: 1000
二、读写锁RWMutex
第4步:掌握读写锁的设计原理
sync.RWMutex是互斥锁的优化版本,旨在提高“读多写少”场景下的并发性能。它区分了读操作和写操作:
- 读锁(RLock/RUnlock):允许多个Goroutine同时进行读操作。
- 写锁(Lock/Unlock):要求独占访问,在此期间既不能进行其他写操作,也不能进行读操作。
兼容性规则:
- 读锁可以与其它读锁共存。
- 写锁与读锁、写锁之间互斥。
第5步:构建带有查询功能的账户结构
为了体现读写锁的优势,我们在之前的例子中增加一个check方法查看余额。使用结构体封装账户信息,并通过指针传递RWMutex以避免复制问题。
package main
import (
"fmt"
"sync"
)
type account struct {
num uint // 操作次数
balance int // 余额
rwMu *sync.RWMutex // 读写锁指针
}
var sign = make(chan struct{}, 15) //通道,用于等待所有goroutine
// 查看余额:使用读锁
func (c *account) check() {
defer func() {
sign <- struct{}{}
}()
c.rwMu.RLock()
defer c.rwMu.RUnlock()
fmt.Printf("%d 次操作后的余额: %d\n", c.num, c.balance)
}
// 存钱:写锁
func (c *account) deposit(value int) {
defer func() {
sign <- struct{}{}
}()
c.rwMu.Lock()
defer c.rwMu.Unlock()
c.num += 1
fmt.Printf("余额: %d\n", c.balance)
c.balance += value
fmt.Printf("存 %d 后的余额: %d\n", value, c.balance)
fmt.Println()
}
// 取钱:写锁
func (c *account) withdraw(value int) {
defer func() {
sign <- struct{}{}
}()
c.rwMu.Lock()
defer c.rwMu.Unlock()
c.num += 1
fmt.Printf("余额: %d\n", c.balance)
c.balance -= value
fmt.Printf("取 %d 后的余额: %d\n", value, c.balance)
fmt.Println()
}
func main() {
c := account{num: 0, balance: 1000, rwMu: new(sync.RWMutex)}
// 启动5组存/取操作 + 5次查询,共15个goroutine
for i:=0; i < 5; i++ {
go c.withdraw(500) // 取500
go c.deposit(500) // 存500
go c.check() // 查余额
}
for i := 0; i < 15; i++ {
<-sign
}
fmt.Printf("%d 次操作后的余额: %d\n", c.num, c.balance)
}
第6步:验证读写锁的执行效果
运行上述代码,我们可以看到check方法的输出穿插在存钱和取钱操作之间。由于check只持有了读锁,它不会阻塞其他的读操作,也不会被写操作无限期阻塞(但在有写请求排队时,读锁获取可能会稍作等待以保证公平性)。
余额: 1000
取 500 后的余额: 500
1 次操作后的余额: 500
1 次操作后的余额: 500
1 次操作后的余额: 500
...
10 次操作后的余额: 1000
此例展示了更细粒度的访问控制:写操作(存/取)依然是串行的,但读操作(查余额)可以与其他读操作并行(尽管在此单核演示中体现不明显,在多核高并发下优势显著)。
三、总结与最佳实践
互斥锁和读写锁都是Go语言中解决并发安全的重要手段。选择哪种锁取决于业务场景:
- 如果读写比例相当或写操作较多,直接使用
sync.Mutex即可,逻辑简单且开销小。 - 如果是典型的读多写少场景(如缓存配置加载、仪表盘数据展示等),使用
sync.RWMutex能显著提升吞吐量。
通用原则:
无论使用哪种锁,务必牢记“加锁后必解锁”、“不重复加锁”、“不传递锁的值拷贝”。合理利用defer语句可以有效防止因路径提前退出导致的死锁风险。
以上就是Go语言互斥锁Mutex和读写锁RWMutex的详细内容,更多关于Go语言并发同步的资料请关注本站其它相关文章!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













