第 17 章 线程同步
在 Go 语言中,经常会遇到并发的问题,当然我们会优先考虑使用通道,同时 Go 语言也给出了传统的解决方式 Mutex(互斥锁) 和 RWMutex(读写锁) 来处理竞争条件。
代码语言:go复制type Bank struct {
balance int
}
func (b *Bank) Deposit(amount int) {
b.balance = amount
}
func (b *Bank) Balance() int {
return b.balance
}
func main() {
b := &Bank{}
b.Deposit(1000)
b.Deposit(1000)
b.Deposit(1000)
fmt.Println(b.Balance()) //3000
}
17.1 临界区
首先我们要理解并发编程中临界区的概念。当程序并发地运行时,多个 Go 协程不应该同时访问那些修改共享资源的代码。这些修改共享资源的代码称为临界区 。
代码语言:go复制func main() {
var wg sync.WaitGroup
b := &Bank{}
n := 1000
wg.Add(n)
for i := 1; i <= n; i {
go func() {
b.Deposit(1000)
wg.Done()
}()
}
wg.Wait()
fmt.Println(b.Balance()) //972000,962000,941000
}
我们这里举一个简单的例子,当前变量的值增加 b.balance = amount
当然,对于只有一个协程的程序来说,上面的代码没有任何问题。但是,如果有多个协程并发运行时,就会发生错误,这种情况就称之为数据竞争(data race)。使用下面的互斥锁 Mutex
就能避免这种情况的发生。
17.2 互斥锁 Mutex
互斥锁(Mutex,mutual exclusion) 用于提供一种 加锁机制(Locking Mechanism) ,可确保在某时刻只有一个协程在临界区运行,以防止出现竞争。也是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确。
Mutex
有两个方法,分别是 Lock()
和 Unlock()
,即对应的加锁和解锁。在 Lock()
和 Unlock()
之间的代码,都只能由一个协程执行,就能避免竞争条件。
如果有一个协程已经持有了锁(Lock),当其他协程试图获得该锁时,这些协程会被阻塞,直到Mutex
解除锁定。
下面使用一个例子来讲一讲互斥锁的使用 :
代码语言:go复制package main
import (
"fmt"
"sync"
)
type BankV2 struct {
balance int
m sync.Mutex
}
func (b *BankV2) Deposit(amount int) {
b.m.Lock()
b.balance = amount
b.m.Unlock()
}
func (b *BankV2) Balance() int {
return b.balance
}
func main() {
var wg sync.WaitGroup
b := &BankV2{}
n := 1000
wg.Add(n)
for i := 1; i <= n; i {
go func() {
b.Deposit(1000)
wg.Done()
}()
}
wg.Wait()
fmt.Println(b.Balance()) //1000000
}
为了解决竞争问题,我们就要对 Deposit
这个方法中加上互斥锁,使同一时刻,只能有一个协程对 balance
进行操作:
更改后的代码不管运行多少次,都只会输出一个结果,那就是 1000000
。
使用互斥锁很简单,但要注意同一协程里不要在尚未解锁时再次加锁,也不要对已经解锁的锁再次解锁。
当然,使用通道也可以处理竞争条件,把通道作为锁在前面讲通道的时候已经讲过,这里就不再赘述。
17.3 读写锁 RWMutex
sync.RWMutex
类型实现读写互斥锁,适用于读多写少的场景,它规定了当有人还在读取数据(即读锁占用)时,不允许有人更新这个数据(即写锁会阻塞);为了保证程序的效率,多个人(协程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex
那样只允许有一个人(协程)读取同一个数据。读锁与读锁兼容,读锁与写锁互斥,写锁与写锁互斥。
- 可以同时申请多个读锁;
- 有读锁时申请写锁将阻塞,有写锁时申请读锁将阻塞;
- 只要有写锁,后续申请读锁和写锁都将阻塞。
定义一个 RWMuteux
读写锁:
var rwMutex sync.RWMutex
RWMutex
里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer
。
- 读锁:调用
RLock
方法开启锁,调用RUnlock
释放锁; - 写锁:调用
Lock
方法开启锁,调用Unlock
释放锁。
package main
import (
"fmt"
"sync"
"time"
)
type BankV3 struct {
balance int
rwMutex sync.RWMutex // read write lock
}
func (b *BankV3) Deposit(amount int) {
b.rwMutex.Lock() // write lock
b.balance = amount
b.rwMutex.Unlock() // wirte unlock
}
func (b *BankV3) Balance() (balance int) {
b.rwMutex.RLock() // read lock
balance = b.balance
b.rwMutex.RUnlock() // read unlock
return
}
func main() {
var wg sync.WaitGroup
b := &BankV3{}
n := 1000
wg.Add(n)
for i := 1; i <= n; i {
go func() {
b.Deposit(1000)
wg.Done()
}()
}
wg.Wait()
fmt.Println(b.Balance())
}
17.4 条件变量 sync.Cond
Cond 实现了一个条件变量,在 Locker 的基础上增加的一个消息通知的功能,保存了一个通知列表,用来唤醒一个或所有因等待条件变量而阻塞的 Go 程,以此来实现多个 Go 程间的同步。
代码语言:go复制type Cond struct {
...
L Locker
...
}
// 创建一个带锁的条件变量,Locker 通常是一个 *Mutex 或 *RWMutex
func NewCond(l Locker) *Cond
// 唤醒所有因等待条件变量 c 阻塞的 goroutine
func (c *Cond) Broadcast()
// 唤醒一个因等待条件变量 c 阻塞的 goroutine
func (c *Cond) Signal()
// 等待 c.L 解锁并挂起 goroutine,在稍后恢复执行后,Wait 返回前锁定 c.L,
// 只有当被 Broadcast 和 Signal 唤醒,Wait 才能返回。
func (c *Cond) Wait()
注意:在调用 Signal,Broadcast 之前,应确保目标 Go 程进入 Wait 阻塞状态。
代码语言:go复制func listen(name string, s []string, c *sync.Cond) {
c.L.Lock()
c.Wait()
fmt.Println(name, " 报名:", s)
c.L.Unlock()
}
func broadcast(event string, c *sync.Cond) {
time.Sleep(time.Second)
c.L.Lock()
fmt.Println(event)
c.Broadcast()
c.L.Unlock()
}
func main() {
s1 := []string{"张三"}
s2 := []string{"赵四"}
s3 := []string{"刘能"}
var m sync.Mutex
cond := sync.NewCond(&m)
// listener 1
go listen("Go语言极简一本通", s1, cond)
// listener 2
go listen("Go语言微服务核心架构22讲", s2, cond)
// listener 3
go listen("从0到Go语言微服务架构师", s3, cond)
// broadcast
go broadcast("秒杀开始:", cond)
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
<-ch
}