go语言在sync包中提供了基本的同步原语,
如sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 、 sync.Cond 、sync.Pool 和 sync.Map。
sync.Mutex
Mutex 是一个互斥锁,可以作为其他结构体的字段使用,它的零值是解锁状态。 Mutex 类型的锁和 goroutine 无关,可以由不同的 goroutine 加锁和解锁。
当一个 goroutine 加锁后,其他 goroutine 就不能再继续对其加锁,只能等到其解锁后才能再加锁,也就保障了在同一时刻只有一个 goroutine 执行某段代码,其他 goroutine 都要等待该 goroutine 执行完毕并解锁后才能继续执行。
不过要注意的是,如果在还没有加锁之前就去调用解锁是会导致 panic 异常的,如果在同一个 goroutine 中的 Mutex 解锁之前又再次进行加锁,会导致死锁。
先来看一段代码:
package main
import (
"fmt"
"time"
)
// 账户
type Account struct {
Balance int // 余额
}
func NewAccount() *Account {
return &Account{
Balance: 0,
}
}
// 账户充值
func (a *Account) Recharge(balance int) {
a.Balance += balance
}
func main() {
account := NewAccount()
for i := 0; i < 100; i++ {
go func() {
account.Recharge(1)
}()
}
time.Sleep(5 * time.Second) // 休眠5秒,等待所有协程充值完毕
fmt.Println(account.Balance)
}
正常的预期结果应该是100,但是我们执行 go run main.go 看输出结果会发现,每次输出的都不一样。
从协程的角度去分析的话,每个协程都是去寄存器读取 Balance 的值,接着做加法运算,最后写回到寄存器。
那么,万一有两个goroutine 同时去取 Balance 的值,各自加 1 后存回呢,这就会导致 Balance 只被加了一次(这就是为什么有时候输出结果是 99 )。
其实,这种就是典型的共享资源竞争的问题,如果觉得很难察觉,也不用担心, Go 语言工具链提供的命令中的 -race 可以帮助我们去检查这种问题。
如图,执行
go run -race main.go 可以发现,Go 在运行过程为我们找出了 1 个 data race 的问题,goroutine 8 在读取 0x00c00013a008 地址的值的时候, goroutine 7 往 0x00c00013a008 写入了新的值,这就有可能导致 goroutine 8 拿到的值是旧数据。
既然知道了原因,我们只要保证不同的 goroutine 不要同时去执行 a.Balance += balance 就好了,此时就可以使用 sync.Mutex 互斥锁。
更改代码为:
package main
import (
"fmt"
"sync"
"time"
)
// 账户
type Account struct {
Balance int // 余额
sync.Mutex // 互斥锁
}
func NewAccount() *Account {
return &Account{
Balance: 0,
Mutex: sync.Mutex{},
}
}
// 账户充值
func (a *Account) Recharge(balance int) {
a.Lock() // 先加锁
defer a.Unlock() // 执行完毕后解锁
a.Balance += balance
}
func main() {
account := NewAccount()
for i := 0; i < 100; i++ {
go func() {
account.Recharge(1)
}()
}
time.Sleep(5 * time.Second) // 休眠5秒,等待所有协程充值完毕
fmt.Println(account.Balance)
}
此时,执行 go run main.go 就可以得到正常的预期结果100了,但是执行 go run -race main.go 还是会检测到问题,这个下面再解释。
sync.RWMutex
读写互斥锁 sync.RWMutex 是一种细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。
在上面提到说 Mutex 是一个互斥锁,通过互斥锁去保证了 a.Balance += balance 不会被多个 goroutine 同时执行。
这里再增加一个需求,去查询余额,同理,避免在查询余额的同时,其他 goroutine 去改变了余额,导致读到了脏数据,这里查询余额也得加入锁。
代码如下:
package main
import (
"fmt"
"sync"
"time"
)
// 账户
type Account struct {
Balance int // 余额
sync.Mutex // 互斥锁
}
func NewAccount() *Account {
return &Account{
Balance: 0,
Mutex: sync.Mutex{},
}
}
// 账户充值
func (a *Account) Recharge(balance int) {
a.Lock() // 先加锁
defer a.Unlock() // 执行完毕后解锁
a.Balance += balance
}
// 账户查询
func (a *Account) Query() int {
a.Lock() // 加锁,避免读取时,被其他 goroutine 写入,读到旧数据
defer a.Unlock()
return a.Balance
}
func main() {
account := NewAccount()
for i := 0; i < 100; i++ {
go func() {
account.Recharge(1)
}()
}
for i := 0; i < 100; i++ {
go func() {
fmt.Println(account.Query())
}()
}
time.Sleep(5 * time.Second) // 休眠5秒,等待所有协程处理完毕
fmt.Println(account.Balance)
}
回想一下上面留下的一个待解决的问题,再次执行 go run -race main.go 看看问题还在不在。
好吧,还在,看看提示中的第49行是
fmt.Println(account.Balance) ,所以明白了吧,也就是说 main goroutine 和 goroutine 41 也有可能会冲突导致 account.Balance 获取到的是旧数据。
这里只要把 fmt.Println(account.Balance) 改为 fmt.Println(account.Query()) 就好了。
🤔 继续思考一下,对余额变量来讲,写的时候肯定不能同时有其他 goroutine 去写或读,读的时候也不能有其他goroutine 去写,但是读的时候允不允许其他 goroutine 去读呢,答案是可以呀,所以为了优化性能,可以用读写锁 sync.RWMutex 来改进。
代码改进如下:
package main
import (
"fmt"
"sync"
"time"
)
// 账户
type Account struct {
Balance int // 余额
sync.RWMutex // 读写锁
}
func NewAccount() *Account {
return &Account{
Balance: 0,
RWMutex: sync.RWMutex{},
}
}
// 账户充值
func (a *Account) Recharge(balance int) {
a.Lock() // 先加锁
defer a.Unlock() // 执行完毕后解锁
a.Balance += balance
}
// 账户查询
func (a *Account) Query() int {
a.RLock() // 只加读锁
defer a.RUnlock()
return a.Balance
}
func main() {
account := NewAccount()
for i := 0; i < 100; i++ {
go func() {
account.Recharge(1)
}()
}
for i := 0; i < 100; i++ {
go func() {
fmt.Println(account.Query())
}()
}
time.Sleep(5 * time.Second) // 休眠5秒,等待所有协程处理完毕
fmt.Println(account.Query())
}
sync.WaitGroup
在之前的所有示例代码中,都通过 time.Sleep(5 * time.Second) 休眠 5 秒来保障协程都处理完成,这种做法有很大的不确定性,如果程序运行不止 5 秒呢?
我们的需求也很简单,就是想要知道每一个 goroutine 会在什么时候结束返回吧。怎么去实现呢,直接上代码:
package main
import (
"fmt"
"sync"
)
// 账户
type Account struct {
Balance int // 余额
sync.RWMutex // 读写锁
}
func NewAccount() *Account {
return &Account{
Balance: 0,
RWMutex: sync.RWMutex{},
}
}
// 账户充值
func (a *Account) Recharge(balance int) {
a.Lock() // 先加锁
defer a.Unlock() // 执行完毕后解锁
a.Balance += balance
}
// 账户查询
func (a *Account) Query() int {
a.RLock() // 只加读锁
defer a.RUnlock()
return a.Balance
}
func main() {
wg := sync.WaitGroup{}
account := NewAccount()
for i := 0; i < 100; i++ {
wg.Add(1) // 添加一个 goroutine 的个数,计数+1
go func() {
account.Recharge(1)
wg.Done() // goroutine 完成一个,计数-1
}()
}
for i := 0; i < 100; i++ {
wg.Add(1) // 添加一个 goroutine 的个数,计数+1
go func() {
fmt.Println(account.Query())
wg.Done() // goroutine 完成一个,计数-1
}()
}
wg.Wait() // 等待所有 goroutine 处理完毕
fmt.Println(account.Query())
}
这样就不用再漫长的去等待 5 秒了😄。
sync.Once
sync.Once 可以保证在 Go 程序运行期间的某段代码只会执行一次(在高并发的情况下也可以保证)。常用于实现单例,单元测试时环境的准备等只执行一次的场景。
package main
import (
"fmt"
"sync"
)
func onlyOnce() {
fmt.Println("only print once")
}
func main() {
once := &sync.Once{}
for i := 0; i < 100; i++ {
once.Do(onlyOnce)
}
}
// 只会打印一次 only print once
要注意的是,不要在传给Do的函数中再调用这个once,否则会死锁。
sync.Cond
sync.Cond 提供了通知的机制,可以 Broadcast 通知所有 Wait 状态的 goroutine (按照一定顺序广播通知等待的全部 goroutine), 也可以 Signal 通知某一个Wait 的 goroutine(队列最前面、等待最久的 goroutine)。
Cond 初始化的时候要传入一个Locker接口的实现。Broadcast和Signal不需要加锁就可以调用,但是调用Wait的时候一定要使用锁,否则会触发程序崩溃。
贴一个例子:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
cond := sync.NewCond(&sync.Mutex{})
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cond.L.Lock()
defer cond.L.Unlock()
cond.Wait() // 等待下课铃
fmt.Println("下课啦,跑啊🏃")
}()
}
time.Sleep(5 * time.Second)
fmt.Println("下课啦!铃铃铃~~~")
time.Sleep(1 * time.Second)
cond.Broadcast()
wg.Wait()
}
sync.Pool
sync.Pool 是一个临时对象池,是可伸缩的,并发安全的。其大小仅受限于内存的大小,可以被看作是一个存放可重用对象的值的容器。
设计的目的是存放已经分配的但是暂时不用的对象,在需要用到的时候直接从pool中取。
主要适用场景:
当多个 goroutine 都需要创建同⼀个对象的时候,如果 goroutine 数过多,导致对象的创建数⽬剧增,进⽽导致 GC 压⼒增大。形成 “并发⼤-占⽤内存⼤-GC 缓慢-处理并发能⼒降低-并发更⼤”这样的恶性循环。
在这个时候,需要有⼀个对象池,每个 goroutine 不再⾃⼰单独创建对象,⽽是从对象池中获取出⼀个对象(如果池中已经有的话)。
例子:
package main
import (
"fmt"
"sync"
)
type Object struct {
Name string
}
func newPool() *sync.Pool {
return &sync.Pool{
New: func() interface{} {
fmt.Println("创建一个新的 Object")
return new(Object)
},
}
}
func main() {
pool := newPool()
p := pool.Get().(*Object) // 调用Get获取对象,如果获取不到会自动调用New创建一个新的对象
fmt.Println(p)
p.Name = "hello"
pool.Put(p) // 加到池中
fmt.Println(pool.Get().(*Object)) // "阅后即焚"
fmt.Println(pool.Get().(*Object)) // 获取不到,自动调用New创建一个新的对象
}
sync.Map
为什么有 sync.Map ?
因为Go语言中的 map 在并发情况下,只读是线程安全的,同时读写是线程不安全的。
测试一下,如果并发对 map 写入看看会发生什么:
package main
import (
"fmt"
"sync"
)
func main() {
m := make(map[int]int, 0)
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m[i] = i
}(i)
}
wg.Wait()
fmt.Println(m[99])
}
好吧,panic了。报了
fatal error: concurrent map writes 的错误,提示也很明显,说了并发对map写入导致的。
改为 sync.Map :
package main
import (
"fmt"
"sync"
)
func main() {
m := sync.Map{}
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m.Store(i, i)
}(i)
}
wg.Wait()
if v, ok := m.Load(99); ok {
fmt.Println(v)
}
}
最后
同步原语中除了 sync.Once 和 sync.WaitGroup 类型,大部分都是适用于低级别的程序并发,更高级别的并发控制方式 go 语言还是推荐使用 channel 通信更好一些。