2

golang中的锁

 1 year ago
source link: https://www.yangyanxing.com/article/lock-in-go.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在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行

该如何避免竞态呢? 可以使用以下几种方式

  1. 使用原子性操作
  2. 加入互斥锁
  3. 使用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 使用RLockRUnlock进行加锁和解锁,注意这里的锁是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, 但是想一个问题,此时,如果有一个协程需要对变量进行写操作了,那么这时候问题就变得复杂了。 我们可以想象有以下几个场景

  1. 某个协程正在读该数据
  2. 某个协程正要准备写数据

读锁和写锁调用加锁的方法是不一样的,对于第一种情况,当某个协程正在读数据的时候,写锁是得不到的,对于第二种情况,当某个协程获取到了写锁,那么所有想要获取读锁的协程也是获取不到锁的,我们来写个程序验证一下。

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

Go 并发读写 sync.map 的强大之处 - 掘金


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK