Go从入门到精通——同步——保证并发环境下数据访问的准确性(竞态检测、互斥锁、读写互斥锁)
同步——保证并发环境下数据访问的准确性(竞态检测、互斥锁、读写互斥锁)
Go 程序可以使用通道进行多个 goroutine 间的数据交换,但这仅仅是数据同步中的一种方法。通道内部的实现依然使用了各种锁,因此优雅代码的代价是性能。在某些轻量级的场合,原子访问(atomic包)、互斥锁(sync.Mutex)以及等待组(sync.WaitGroup)能最大程度满足需求。
一、竞态检测——检测代码在并发环境下可能出现的问题
当多线程并发运行的程序竞争访问和修改同一块资源时,会发生竞态问题。
下面的代码中有一个 ID生成器,每次调用生成器将会生成一个不会重复的顺序序号,使用 10个并发生成序号,观察 10个并发后的结果。
package main import ( "fmt" "sync/atomic" ) var ( //序号 seq int64 ) //序号生成器 func GenID() int64 { //使用原子操作函数 atomic.AddInt64() 对 seq加 1操作。 //这里没有使用 atomic.AddInt64()的返回值作为 GenID()函数的返回值, //用来引出一个竞态问题。 atomic.AddInt64(&seq, 1) return seq } func main() { //循环10次,生成10个 goroutine 调用 GenID() 函数 for i := 0; i <= 10; i++ { go GenID() } //单独调用一次 GEnID() 函数 fmt.Println(GenID()) }
在运行程序时,为运行参数加入 "-race" 参数,开启运行时(runtime)对竞态问题的分析,命令如下:
PS D:\go-testfiles> go run -race .\racedetect.go
代码运行发生宕机,输出信息如下:
PS D:\go-testfiles> go run -race .\racedetect.go ================== WARNING: DATA RACE Write at 0x0000011fd4f0 by goroutine 8: sync/atomic.AddInt64() C:/Program Files/Go/src/runtime/race_amd64.s:287 +0xb sync/atomic.AddInt64() <autogenerated>:1 +0x1b main.main.func1() D:/go-testfiles/racedetect.go:27 +0x2b Previous read at 0x0000011fd4f0 by goroutine 7: main.GenID() D:/go-testfiles/racedetect.go:20 +0x3a main.main.func1() PS D:\go-testfiles> go run -race .\racedetect.gocls go: go.mod file not found in current directory or any parent directory; see 'go help modules' PS D:\go-testfiles> go run -race .\racedetect.go ================== WARNING: DATA RACE Write at 0x00000023d4f0 by goroutine 8: sync/atomic.AddInt64() C:/Program Files/Go/src/runtime/race_amd64.s:287 +0xb sync/atomic.AddInt64() <autogenerated>:1 +0x1b main.main.func1() D:/go-testfiles/racedetect.go:27 +0x2b Previous read at 0x00000023d4f0 by goroutine 7: main.GenID() D:/go-testfiles/racedetect.go:20 +0x3a main.main.func1() D:/go-testfiles/racedetect.go:27 +0x2b Goroutine 8 (running) created at: main.main() D:/go-testfiles/racedetect.go:27 +0x39 Goroutine 7 (finished) created at: main.main() D:/go-testfiles/racedetect.go:27 +0x39 ================== 11 Found 1 data race(s) exit status 66 PS D:\go-testfiles>
根据报错信息,在 20行 发现有竞态问题,
Previous read at 0x00000023d4f0 by goroutine 7: main.GenID() D:/go-testfiles/racedetect.go:20 +0x3a main.main.func1() D:/go-testfiles/racedetect.go:27 +0x2b
我们修改下:
//序号生成器 func GenID() int64 { return atomic.AddInt64(&seq, 1) }
再次运行:
PS D:\go-testfiles> go run -race .\racedetect.go 11 PS D:\go-testfiles>
没有发生竞态问题了,程序运行正常。
这个例子也可以用下面的互斥锁(sync.Mutex)解决,但是对性能消耗较大,在这种情况下,推荐使用原子操作(atomic)进行变量操作。
二、互斥锁(sync.Mutex)——保证同时只有一个 goroutine 可以访问共享资源
互斥锁是一种常用的控制共享资源访问的方法。在 Go 程序中的使用非常简单。
package main import ( "fmt" "sync" ) var ( //逻辑中使用的某个变量 count int //与变量对应的使用互斥锁,这里将互斥锁的变量命名为 变量名+Guard,以表示保护这个变量 countGuard sync.Mutex ) func GetCount() int { //锁定 countGuard.Lock() //在函数退出时接触锁定 defer countGuard.Unlock() return count } func SetCount(c int) { countGuard.Lock() count = c countGuard.Unlock() } func main() { //可以进行并发安全的设置 SetCount(1) //可以进行并发安全的获取 fmt.Println(GetCount()) }
三、读写互斥锁(sync.RWMutex)——在读比写多的环境下比互斥锁更高效
在读多写少的环境中,可以优先使用读写互斥锁,sync 包中的 RWMutex 提供了读写互斥锁的封装。
package main import ( "fmt" "sync" ) var ( //逻辑中使用的某个变量 count int //与变量对应的使用互斥锁,这里将互斥锁的变量命名为 变量名+Guard,以表示保护这个变量 countGuard sync.RWMutex ) func GetCount() int { //锁定 countGuard.RLock() //在函数退出时接触锁定 defer countGuard.RUnlock() return count } func SetCount(c int) { countGuard.RLock() count = c countGuard.RUnlock() } func main() { //可以进行并发安全的设置 SetCount(1) //可以进行并发安全的获取 fmt.Println(GetCount()) }
四、等待组(sync.WaitGroup)——保证在并发环境中完成指定数量的任务
除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步。
等待组有下面几个方法可用,如下表:
方法名 | 功能 |
(wg *WaitGroup)Add(delta int) | 等待组的计数器+1 |
(wg *WaitGroup)Done() | 等待组的计数器-1 |
(wg *WaitGroup)Wait() | 当等待组计数器不等于0时阻塞直到变0 |
等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。
当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。
package main import ( "fmt" "net/http" "sync" ) func main() { //声明一个等待组 var wg sync.WaitGroup //准备一系列的网站地址 var urls = []string{ "https://www.baidu.com", "https://www.jd.com", "https://www.taobao.com", "https://www.google.com", } //遍历这些地址 for _, url := range urls { //每一个任务开始时,请等待组增加 1 wg.Add(1) //开启一个并发 go func(url string) { //使用 defer,表示函数完成时将等待组减少 1 defer wg.Done() //使用 HTTP 访问提供的地址 _, err := http.Get(url) //访问完成后,打印地址和可能发生的错误 fmt.Println(url, err) //通过参数传递 url 地址 }(url) } //等待所有的任务完成 wg.Wait() fmt.Println("over") }
程序运行后:
Starting: D:\go-testfiles\bin\dlv.exe dap --check-go-version=false --listen=127.0.0.1:52039 from d:\go-testfiles DAP server listening at: 127.0.0.1:52039 Type 'dlv help' for list of commands. https://www.baidu.com <nil> https://www.jd.com <nil> https://www.taobao.com <nil> https://www.google.com Get "https://www.google.com": dial tcp 162.125.32.6:443: connectex: A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond. over Process 20104 has exited with status 0 Detaching dlv dap (5628) exited with code: 0