golang中的锁
在golang中,goroutine 可以理解为其它语言中的线程,在其它语言中存在的数据竞态的问题,在golang中同样存在
本文记录一下数据竞态与各种锁的使用
race condition 竞争状态
这个词也没有听起来很高大上,其实并没有什么新鲜的东西,就是多个协程对同一个变量进行读写,造成了状态不一致,得不到正确的结果,我们来看一下代码
package main import ( "fmt" "sync" ) var data int func incr(wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 10000; i++ { data = data + 1 } } func main() { wg := sync.WaitGroup{} wg.Add(2) go incr(&wg) go incr(&wg) wg.Wait() fmt.Println(data) }
有一个函数,对全局的变量data 进行加10000操作,如果有两个这样的协程同时进行操作,我们希望得到的结果是20000, 可是上面的结果并不会得到20000,而且每次的结果都不太一样,更像是一些随机的数。 两个协程同时操作data变量,这两个协程产生了竞争状态,这就产生了race conditiion。 go 为我们提供了竞态检查命令, go build -race main.go , 这时再运行打包出来的程序,如果有竞态,则会打印出具体的代码位置
================== WARNING: DATA RACE Read at 0x000001200788 by goroutine 8: main.incr() golock/main.go:14 +0x95 main.main·dwrap·3() golock/main.go:22 +0x39 Previous write at 0x000001200788 by goroutine 7: main.incr() golock/main.go:14 +0xad main.main·dwrap·2() golock/main.go:21 +0x39 Goroutine 8 (running) created at: main.main() golock/main.go:22 +0x138 Goroutine 7 (finished) created at: main.main() golock/main.go:21 +0xd0 ================== 20000 Found 1 data race(s) 在源码中的14,21,22行存在竞态 data = data + 1 // 14行 go incr(&wg) // 21行 go incr(&wg) // 22行
该如何避免竞态呢? 可以使用以下几种方式
使用原子性操作
加入互斥锁
使用channel
使用原子性操作
上面的问题主要产生于 data = data + 1 这个操作不是原子性的,程序是先取出data的值,比如5,这时候如果系统调度到了别的协程,则另外一个协程也会拿到相同的data值,之后再将data 值加1,但是两个协程都在原来值上加1,就是6,也要造成了虽然执行了两次,但是值只加了1
golang的atomic 中提供了一些能用的方法,如对int32类型的值做加操作, 将上面的incr函数修改一下
func incr(wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 10000; i++ { atomic.AddInt32(&data, 1) } }
此时就不再有竞态了,且每次都会得到20000的结果。
使用锁
atomic 包中提供的函数比较单一,对于上面的需求可以很好的满足,但是通常情况下,我们的处理逻辑不会这么简单,这时我们就需要使用锁来保证读写的原子性了.
锁使用比较多的是互斥锁,读写锁
互斥锁,这种锁和其它语言的互斥锁是一样的,谁获取到了锁就有执行权,没有获取到的就只能等着了
package main import ( "fmt" "sync" ) var data int32 func incr(wg *sync.WaitGroup, lock *sync.Mutex) { defer wg.Done() lock.Lock() for i := 0; i < 10000; i++ { data = data + 1 } lock.Unlock() } func main() { wg := sync.WaitGroup{} lock := sync.Mutex{} wg.Add(2) go incr(&wg, &lock) go incr(&wg, &lock) wg.Wait() fmt.Println(data) }
上面代码在对for 循环加1操作进行的加锁,结束之后释放掉锁。
注意的问题,如果锁作为参数传递到函数中,需要使用指针形式,如上面的 func incr(wg *sync.WaitGroup, lock *sync.Mutex), 如果传递的是值,则起不到加锁的功能。
当我们定义一个结构体,有用到Mutex 匿名字段的时候, 在声明结构体方法时,也需要使用指针形式
type mylock struct { sync.Mutex } func (m *mylock) test() { m.Lock() for i := 0; i < 10000; i++ { data = data + 1 } }
如果声明结构体方法为 func (m mylock) test() 时,则使用 m.Lock()并不会起到加锁的效果。
锁的范围
上面的代码中,我们是将整体for 循环锁住了,但其实我们更应该将锁的颗粒度减小
func incr(wg *sync.WaitGroup, lock *sync.Mutex) { defer wg.Done() for i := 0; i < 10000; i++ { lock.Lock() data = data + 1 lock.Unlock() } }
锁不支持递归获取
java 中有可重入锁,但是在 golang 中不存在, 即使在同一个协程中也不行
func (m *mylock) test() { m.Lock() m.Lock() ...... m.Unlock() m.Unlock() }
当然你不太可能傻傻的写出上面的代码,但是下面的写法可能会不小心发生
type mylock struct { sync.Mutex } func (m *mylock) test() { m.Lock() m.test2() m.Unlock() } func (m *mylock) test2() { m.Lock() fmt.Println("in test2") m.Unlock() } var ml mylock = mylock{} ml.test()
此时,调用ml的test方法,这个方法要进行加锁,然后test方法又调用test2方法,这个方法也要获取锁,这种情况下也会造成死锁。
读写锁 RWMutex
互斥锁使用起来比较方便,但是有一个问题就是,它锁权利太大了,同时只能有一个协程能操作数据,但是我们想一个问题,如果一个变量,多个协程只是读它的数据,并没有写的操作,此时对于多个协程同时读是不会造成竞态的。此时如果我们还是使用互斥锁的话,在效率上难免会受到一些影响。
package main import ( "fmt" "sync" "time" ) var data int = 10 func readata(id int, lock *sync.Mutex, wg *sync.WaitGroup) { lock.Lock() fmt.Printf("goroutine %d get lock, data is %d \n", id, data) time.Sleep(1 * time.Second) lock.Unlock() wg.Done() } func main() { var lock *sync.Mutex = new(sync.Mutex) var wg sync.WaitGroup start := time.Now() wg.Add(5) for i := 0; i < 5; i++ { go readata(i, lock, &wg) } wg.Wait() used := time.Since(start).Seconds() fmt.Printf("Use %f second \n", used) }
上面的代码,起了5个协程,每个协程,每个协程都尝试去读data 的值 ,并没有写的操作,每个协程耗时1秒钟,在互斥锁的加持下,同时只能有一个协程得到运行,这时总的耗时大概就是5秒钟
➜ golock go run main.go goroutine 4 get lock, data is 10 goroutine 1 get lock, data is 10 goroutine 0 get lock, data is 10 goroutine 2 get lock, data is 10 goroutine 3 get lock, data is 10 Use 5.018138 second
这时我们就可以使用读锁来取带互斥锁,读锁可以让5个协程同时读
package main import ( "fmt" "sync" "time" ) var data int = 10 func readata(id int, lock *sync.RWMutex, wg *sync.WaitGroup) { lock.RLock() // 修改点 1 fmt.Printf("goroutine %d get lock, data is %d \n", id, data) time.Sleep(1 * time.Second) lock.RUnlock() // 修改点 2 wg.Done() } func main() { var lock *sync.RWMutex = new(sync.RWMutex) // 修改点3 var wg sync.WaitGroup start := time.Now() wg.Add(5) for i := 0; i < 5; i++ { go readata(i, lock, &wg) } wg.Wait() used := time.Since(start).Seconds() fmt.Printf("Use %f second \n", used) }
上面主要修改三处,修改点1和修改点2 使用RLock和RUnlock进行加锁和解锁,注意这里的锁是sync.RWMutex指针变量。 修改点3 为lock 对象的初始化,之前的sync.Mutex,这里是sync.RWMutex
这时5个协程就可以同时的进行读取操作了
➜ golock go run main.go goroutine 1 get lock, data is 10 goroutine 4 get lock, data is 10 goroutine 0 get lock, data is 10 goroutine 2 get lock, data is 10 goroutine 3 get lock, data is 10 Use 1.003802 second
这种情况下,有人会问了,这样加锁和不加锁效果不是一样的吗? 我不加锁也同样可以5个协程同时读取变量呀!
是的,对于上面的场景确实加不加读锁都一样的,没有涉及到写的操作,只有读也不会产生race condition, 但是想一个问题,此时,如果有一个协程需要对变量进行写操作了,那么这时候问题就变得复杂了。 我们可以想象有以下几个场景
某个协程正在读该数据
某个协程正要准备写数据
读锁和写锁调用加锁的方法是不一样的,对于第一种情况,当某个协程正在读数据的时候,写锁是得不到的,对于第二种情况,当某个协程获取到了写锁,那么所有想要获取读锁的协程也是获取不到锁的,我们来写个程序验证一下。
package main import ( "fmt" "sync" "time" ) var data int = 10 func readata(id int, lock *sync.RWMutex, wg *sync.WaitGroup) { lock.RLock() fmt.Printf("goroutine %d get lock, data is %d \n", id, data) time.Sleep(1 * time.Second) lock.RUnlock() wg.Done() } func setdata(id int, lock *sync.RWMutex, wg *sync.WaitGroup) { defer wg.Done() lock.Lock() // 关键处 1 data = data + 1 fmt.Printf("goroutine %d get wlock, set data %d \n", id, data) time.Sleep(1 * time.Second) lock.Unlock() } func main() { var lock *sync.RWMutex = new(sync.RWMutex) var wg sync.WaitGroup start := time.Now() wg.Add(8) for i := 0; i < 4; i++ { go readata(i, lock, &wg) } for i := 0; i < 4; i++ { go setdata(i, lock, &wg) } wg.Wait() used := time.Since(start).Seconds() fmt.Printf("Use %f second \n", used) }
我们写了个setdata 函数,这个函数的参数,是一个读写锁,但是在函数体内,在关键处1 我们使用lock.Lock()来获取写锁。之后起了8个协程,其中有4个协程进行读,4个协程进行写,但是这段代码的运行结果就比较有意思了。
➜ golock go run main.go goroutine 0 get lock, data is 10 goroutine 3 get lock, data is 10 goroutine 3 get wlock, set data 11 goroutine 1 get lock, data is 11 goroutine 2 get lock, data is 11 goroutine 0 get wlock, set data 12 goroutine 1 get wlock, set data 13 goroutine 2 get wlock, set data 14 Use 6.023362 second ➜ golock go run main.go goroutine 3 get wlock, set data 11 goroutine 1 get lock, data is 11 goroutine 3 get lock, data is 11 goroutine 0 get lock, data is 11 goroutine 2 get lock, data is 11 goroutine 1 get wlock, set data 12 goroutine 0 get wlock, set data 13 goroutine 2 get wlock, set data 14 Use 5.015582 second
我多次运行,总的耗时是不确认的,我们来分析一下, 先看第一次运行结果 goroutine 0 get lock, data is 10 goroutine 3 get lock, data is 10 先由goroutine 0 和 3 这两个协程获取到读锁,然后打印出data的结果10,这时耗时1秒,总时间1秒
然后写协程获取到写锁,读data设置为11 goroutine 3 get wlock, set data 11 这里只能有一个写协程获取到写锁,这时又耗时1秒,总时间2秒。 之后又有两个读协程获取到读锁 ,读到的data值已经变为了11 goroutine 1 get lock, data is 11 goroutine 2 get lock, data is 11
这时又耗时1秒,总时间3秒, 这时读协程已经运行完毕。 goroutine 0 get wlock, set data 12 goroutine 1 get wlock, set data 13 goroutine 2 get wlock, set data 14 之后就是三个写协程分别单独获取到写锁,并分别耗时1秒,总的时间是6秒。
第二次运行的结果 goroutine 3 get wlock, set data 11 写协程3 获取写锁,耗时1秒, 总耗时1秒 goroutine 1 get lock, data is 11 goroutine 3 get lock, data is 11 goroutine 0 get lock, data is 11 goroutine 2 get lock, data is 11 之后四个读协程同时获取到读锁,耗时1秒,总耗时2秒 goroutine 1 get wlock, set data 12 goroutine 0 get wlock, set data 13 goroutine 2 get wlock, set data 14 三个写协程分别获取到写锁,各耗时1秒,总耗时5秒。
上面的程序如果使用互斥锁的话,那么8个协程运行下来,总的耗时是在8秒钟左右,使用读写锁来优化以后,程序最短需要5秒,最坏的情况下也是8秒。 有了读写锁,会使程序既保证了数据的准确性,又提高了运行效率。 对于读多写少的协程间操作,我们可以使用读写锁来优化,
sync.Map
golang 原生的map 是不支持并发的
func readmap(m map[int]int, wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 1000; i++ { fmt.Println(m[i]) } } func setmap(m map[int]int, wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 1000; i++ { m[i] = i } } func main() { var wg sync.WaitGroup var m map[int]int = make(map[int]int) wg.Add(2) go readmap(m, &wg) go setmap(m, &wg) wg.Wait() fmt.Println("main over") }
这里有两个协程,一个写,一个读,这时运行程序就会报fatal error: concurrent map read and map write 解决办法也简单,加上锁就可以, 但是golang 为我们提供了一个sync.Map的结构体,这是线程安全的map,我们可以在有多个协程操作map的时候使用该结构
func readmap(m *sync.Map, wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 10; i++ { fmt.Println(m.Load(i)) } } func setmap(m *sync.Map, wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 10; i++ { m.Store(i, i) } } func main() { var wg sync.WaitGroup var m sync.Map wg.Add(2) go setmap(&m, &wg) go readmap(&m, &wg) wg.Wait() fmt.Println("main over") }
sync.Map 结构体主要有以下几个方法 func (m *Map) Load(key interface{}) (value interface{}, ok bool) 从sync.Map中取值 func (m *Map) Store(key, value interface{})向一个sync.Map设置值 func (m *Map) Delete(key interface{})删除sync.Map 中的的某个键 func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) 从sync.Map中取出某个值,并从sync.Map中删除掉该键 func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) 读取或者设置某个键值,如果该键存在,则返回该值,如果不存在,会先设置该键值,并且将value返回
sync.Map 适合那种读出写少的场景,以下这篇文章详细的对原生map+互斥锁,原生map+读写锁, sync.Map 之间的性能做了对比,得出的结论就是读多写少的场景,会更建议使用 sync.Map 类型