Go并发编程之同步原语

378 阅读2分钟

go语言在sync包中提供了基本的同步原语, 如sync.Mutexsync.RWMutexsync.WaitGroupsync.Oncesync.Condsync.Poolsync.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 看输出结果会发现,每次输出的都不一样。 image.png 从协程的角度去分析的话,每个协程都是去寄存器读取 Balance 的值,接着做加法运算,最后写回到寄存器。

那么,万一有两个goroutine 同时去取 Balance 的值,各自加 1 后存回呢,这就会导致 Balance 只被加了一次(这就是为什么有时候输出结果是 99 )。

其实,这种就是典型的共享资源竞争的问题,如果觉得很难察觉,也不用担心, Go 语言工具链提供的命令中的 -race 可以帮助我们去检查这种问题。 image.png 如图,执行 go run -race main.go 可以发现,Go 在运行过程为我们找出了 1 个 data race 的问题,goroutine 8 在读取 0x00c00013a008 地址的值的时候, goroutine 70x00c00013a008 写入了新的值,这就有可能导致 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 还是会检测到问题,这个下面再解释。 image.png

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 看看问题还在不在。 image.png 好吧,还在,看看提示中的第49行是 fmt.Println(account.Balance) ,所以明白了吧,也就是说 main goroutinegoroutine 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

image.png sync.Cond 提供了通知的机制,可以 Broadcast 通知所有 Wait 状态的 goroutine (按照一定顺序广播通知等待的全部 goroutine), 也可以 Signal 通知某一个Wait 的 goroutine(队列最前面、等待最久的 goroutine)。

Cond 初始化的时候要传入一个Locker接口的实现。BroadcastSignal不需要加锁就可以调用,但是调用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()
}

image.png

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创建一个新的对象
}

image.png

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])
}

image.png 好吧,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)
	}
}

image.png

最后

同步原语中除了 sync.Oncesync.WaitGroup 类型,大部分都是适用于低级别的程序并发,更高级别的并发控制方式 go 语言还是推荐使用 channel 通信更好一些。

To Be Continued