golang中的锁

作者: adm 分类: go 发布时间: 2023-04-16

在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 类型

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!