这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记
Goroutine并发安全和锁
有时候我们的代码中可能会存在多个 goroutine 同时操作一个资源(临界区)的情况,这种情况下就会发生竞态问题(数据竞态)。这就好比现实生活中十字路口被各个方向的汽车竞争,还有火车上的卫生间被车厢里的人竞争。
演示一个数据竞争的示例:
package main
import (
"fmt"
"sync"
)
var (
x int64
wg sync.WaitGroup // 等待组
)
// add 对全局变量x执行5000次加1操作
func add() {
for i := 0; i < 5000; i++ {
x = x + 1
}
wg.Done()
}
func main() {
wg.Add(2)
// 用2个goroutine并发的执行add
go add()
go add()
wg.Wait()
fmt.Println(x)
}
我们将上面的代码编译后执行,不出意外每次执行都会输出诸如9537、5865、6527等不同的结果。这是为什么呢?
在上面的示例代码片中,我们开启了两个 goroutine 分别执行 add 函数,这两个 goroutine 在访问和修改全局的x变量时就会存在数据竞争,某个 goroutine 中对全局变量x的修改可能会覆盖掉另一个 goroutine 中的操作,所以导致最后的结果与预期不符。
互斥锁
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同一时间只有一个 goroutine 可以访问共享资源。Go 语言中使用sync包中提供的Mutex(互斥的意思)类型来实现互斥锁。
sync.Mutex提供了两个方法供我们使用:
| 方法名 | 功能 |
|---|---|
| func (m *Mutex) Lock() | 获取互斥锁 |
| func (m *Mutex) Unlock() | 释放互斥锁 |
我们在下面的示例代码中使用互斥锁限制每次只有一个 goroutine 才能修改全局变量x,从而修复上面代码中的问题。
package main
import (
"fmt"
"sync"
)
var (
x int64
wg sync.WaitGroup // 等待组
m sync.Mutex // 互斥锁
)
// add 对全局变量x执行5000次加1操作
func add() {
for i := 0; i < 5000; i++ {
m.Lock() // 修改x前加锁,防止出现数据竞争
x += 1
m.Unlock() // 修改完释放锁,让其他goroutine可以修改此变量
}
wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
将上面的代码编译后多次执行,每一次都会得到预期中的结果—10000。
使用互斥锁能够保证同一时间有且只有一个 goroutine 进入临界区,其他的 goroutine 则在等待锁;当互斥锁释放后,等待的 goroutine 才可以获取锁进入临界区,多个 goroutine 同时等待一个锁时,唤醒的策略是随机的。
读写互斥锁
互斥锁是完全互斥的,但是实际上有很多场景是读多写少的,当我们并发的去读取一个资源而不涉及资源修改的时候是没有必要加互斥锁的,这种场景下使用读写锁是更好的一种选择。读写锁在 Go 语言中使用sync包中的RWMutex类型。
sync.RWMutex提供了以下5个方法。
| 方法名 | 功能 |
|---|---|
| func (rw *RWMutex) Lock() | 获取写锁 |
| func (rw *RWMutex) Unlock() | 释放写锁 |
| func (rw *RWMutex) RLock() | 获取读锁 |
| func (rw *RWMutex) RUnlock() | 释放读锁 |
| func (rw *RWMutex) RLocker() Locker | 返回一个实现Locker接口的读写锁 |
当一个 goroutine 获取到读锁之后,其他的 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待。
当一个 goroutine 获取写锁之后,其他的 goroutine 无论是获取读锁还是写锁都会等待。
下面我们使用代码构造一个读多写少的场景,然后分别使用互斥锁和读写锁查看它们的性能差异。
package main
import (
"fmt"
"sync"
"time"
)
var (
x int64
wg sync.WaitGroup
mutex sync.Mutex
rwMutex sync.RWMutex
)
// 使用互斥锁的写操作
func writeWithLock() {
mutex.Lock() // 加互斥锁
x += 1
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10ms
mutex.Unlock() // 释放互斥锁
wg.Done()
}
// 使用互斥锁的读操作
func readWithLock() {
mutex.Lock()
time.Sleep(time.Millisecond) // 假设读操作耗时1ms
mutex.Unlock()
wg.Done()
}
// 使用读写互斥锁的写操作
func writeWithRWLock() {
rwMutex.Lock()
x += 1
time.Sleep(10 * time.Millisecond)
rwMutex.Unlock()
wg.Done()
}
// 使用读写互斥锁的读操作
func readWithRWLock() {
rwMutex.RLock()
time.Sleep(time.Millisecond)
rwMutex.RUnlock()
wg.Done()
}
func do(writeFunc, readFunc func(), writeCount, readCount int) {
start := time.Now()
// writeCount个并发写操作
for i := 0; i < writeCount; i++ {
wg.Add(1)
go writeFunc()
}
// readCount个并发读操作
for i := 0; i < readCount; i++ {
wg.Add(1)
go readFunc()
}
wg.Wait()
costTime := time.Since(start)
fmt.Printf("x: %v costTime: %v\n", x, costTime)
}
func main() {
// 使用互斥锁,操作10并发写,1000并发读耗时
do(writeWithLock, readWithLock, 10, 1000)
// 使用读写互斥锁,操作10并发写,1000并发读耗时
do(writeWithRWLock, readWithRWLock, 10, 1000)
}
输出:
x: 10 costTime: 1.2687892s
x: 20 costTime: 105.8625ms
从最终的执行结果可以看出,使用读写互斥锁在读多写少的场景下能够极大地提高程序的性能。
但如果一个程序中的读操作和写操作数量级差别不大,那么读写互斥锁的优势就发挥不出来。
sync.WaitGroup
在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步。 sync.WaitGroup有以下几个方法:
| 方法名 | 功能 |
|---|---|
| func (wg * WaitGroup) Add(delta int) | 计数器+delta |
| (wg *WaitGroup) Done() | 计数器-1 |
| (wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了 N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用 Done 方法将计数器减1。通过调用 Wait 来等待并发任务执行完,当计数器值为 0 时,表示所有并发任务已经完成。
示例:
var wg sync.WaitGroup
func hello() {
defer wg.Done()
fmt.Println("Hello Goroutine!")
}
func main() {
wg.Add(1)
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
wg.Wait()
}
需要注意sync.WaitGroup是一个结构体,进行参数传递的时候要传递指针。