互斥锁是一种常见的控制共享资源访问的方法,它能够保证同一时间只有一个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() //改完解锁
}
wg.Done()
}
func main() {
for i := 0; i < 2; i++ {
wg.Add(1)
go add()
}
wg.Wait()
fmt.Println(x)
}
//运行结果10000
将上面的代码编译后多次执行,每次都会得到预期的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 无论是获取读锁还是写锁都会等待。
下面我们使用代码构造一个读多写少的场景,然后分别使用互斥锁和读写锁查看它们的性能差异。
var (
x int64
wg sync.WaitGroup
mutex sync.Mutex
rwMutex sync.RWMutex
)
// writeWithLock 使用互斥锁的写操作
func writeWithLock() {
mutex.Lock() // 加互斥锁
x = x + 1
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
mutex.Unlock() // 解互斥锁
wg.Done()
}
// readWithLock 使用互斥锁的读操作
func readWithLock() {
mutex.Lock() // 加互斥锁
time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
mutex.Unlock() // 释放互斥锁
wg.Done()
}
// writeWithLock 使用读写互斥锁的写操作
func writeWithRWLock() {
rwMutex.Lock() // 加写锁
x = x + 1
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
rwMutex.Unlock() // 释放写锁
wg.Done()
}
// readWithRWLock 使用读写互斥锁的读操作
func readWithRWLock() {
rwMutex.RLock() // 加读锁
time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
rwMutex.RUnlock() // 释放读锁
wg.Done()
}
func do(wf, rf func(), wc, rc int) {
start := time.Now()
// wc个并发写操作
for i := 0; i < wc; i++ {
wg.Add(1)
go wf()
}
// rc个并发读操作
for i := 0; i < rc; i++ {
wg.Add(1)
go rf()
}
wg.Wait()
cost := time.Since(start)
fmt.Printf("x:%v cost:%v\n", x, cost)
}
我们假设每一次读操作都会耗时1ms,而每一次写操作会耗时10ms,我们分别测试使用互斥锁和读写互斥锁执行10次并发写和1000次并发读的耗时数据。
// 使用互斥锁,10并发写,1000并发读
do(writeWithLock, readWithLock, 10, 1000) // x:10 cost:1.466500951s
// 使用读写互斥锁,10并发写,1000并发读
do(writeWithRWLock, readWithRWLock, 10, 1000) // x:10 cost:117.207592ms
从最终的执行结果可以看出,使用读写互斥锁在读多写少的场景下能够极大地提高程序的性能。不过需要注意的是如果一个程序中的读操作和写操作数量级差别不大,那么读写互斥锁的优势就发挥不出来。
在代码中生硬的使用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 时,表示所有并发任务已经完成。
我们利用sync.WaitGroup
将上面的代码优化一下:
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
是一个结构体,进行参数传递的时候要传递指针。
应用的两个条件:
确保一个函数在并发的场景下只执行一次。
只有一个方法:Do(func())
Go内置的 map 不是并发安全的
应用场景:
当一个map变量或者结构体里面的一个map类型的字段 可能会被多个goroutine访问的时候
方法1:自己加锁
方法2:sync.Map
concurrent map writes