go 内置了协程安全的 sync 包来方便我们同步各协程之间的执行状态,使用起来也非常方便。
最近在排查解决一个线下服务的数据同步问题,review 核心代码后,发现这么一段流程控制代码。
错误示例
package main
import (
"log"
"runtime"
"sync"
)
func main() {
// 可并行也是重点,生产场景没几个单核的吧??
runtime.GOMAXPROCS(runtime.NumCPU())
waitGrp := &sync.WaitGroup{}
waitGrp.Add(1)
syncTaskProcessMap := &sync.Map{}
for i := 0; i < 100; i++ {
syncTaskProcessMap.Store(i, i)
}
for j := 0; j < 100; j++ {
go func(j int) {
// 协程可能并行抢占一轮开始
syncTaskProcessMap.Delete(j)
// 协程可能并行抢占一轮结束
// 在当前协程 Delete 后 Range 前 又被其他协程 Delete 操作了
syncTaskProcessCount := 0
syncTaskProcessMap.Range(func(key, value interface{}) bool {
syncTaskProcessCount++
return true
})
if syncTaskProcessCount == 0 {
log.Println(GetGoroutineID(), "syncTaskProcessMap empty, start syncOnline", syncTaskProcessCount)
}
}(j)
}
waitGrp.Wait()
}
func GetGoroutineID() uint64 {
b := make([]byte, 64)
runtime.Stack(b, false)
b = bytes.TrimPrefix(b, []byte("goroutine "))
b = b[:bytes.IndexByte(b, ' ')]
n, _ := strconv.ParseUint(string(b), 10, 64)
return n
}
代码的本意,是在 i 个协程并发的执行完成后,启动一次 nextProcess 任务,代码使用了 sync.Map 来维护和同步 i 个协程的执行进度,防止多协程并发造成的 map 不安全读写。当最后一个协程执行完毕,sync.Map 为空,启动一次 nextProcess。但能读到状态值 syncTaskProcessCount 为 0 的协程,只会是 最后一个 执行完成的协程吗?
sync.Map::Store\Load\Delete\Range 都是协程安全的操作,在调用期间只会被当前 协程 抢占访问,但它们的组合操作并不是 独占 的,上面的代码认为,Delete && Range 两项操作期间 不会 夹带其他协程对 sync.Map 读写操作,导致能读到 syncTaskProcessCount 为 0 的协程可能不止最后一个执行完毕的。
多执行几次,可能得到一下输出:
sqrtcat:demo$ go run test.go
2021/04/20 14:30:27 114 syncTaskProcessMap empty, start syncOnline 0
^Csignal: interrupt
sqrtcat:demo$ go run test.go
2021/04/20 14:30:30 117 syncTaskProcessMap empty, start syncOnline 0
2021/04/20 14:30:30 116 syncTaskProcessMap empty, start syncOnline 0
^Csignal: interrupt
sqrtcat:demo$ go run test.go
2021/04/20 14:30:33 117 syncTaskProcessMap empty, start syncOnline 0
^Csignal: interrupt
sqrtcat:demo$ go run test.go
2021/04/20 14:30:35 117 syncTaskProcessMap empty, start syncOnline 0
2021/04/20 14:30:35 118 syncTaskProcessMap empty, start syncOnline 0
2021/04/20 14:30:35 115 syncTaskProcessMap empty, start syncOnline 0
^Csignal: interrupt
sqrtcat:demo$ go run test.go
2021/04/20 14:30:38 131 syncTaskProcessMap empty, start syncOnline 0
2021/04/20 14:30:38 132 syncTaskProcessMap empty, start syncOnline 0
^Csignal: interrupt
可以看到,syncTaskProcessMap empty 的状态被多个协程读到了。
G117,G118,G115 在多核场景下肯能 并行 执行。
SyncMap 被 G117 抢占,Delete 后 2,SyncMap 被释放。
SyncMap 被 G118 抢占,Delete 后 1,SyncMap 被释放。
SyncMap 被 G115 抢占,Delete 后 0,SyncMap 被释放。
这时的 syncMap 已然为空,G117、G118、G115 继续 Range 得到的 syncTaskProcessCount 都为 0,这样就导致了代码执行与期望不同了。
所以,虽然 sync.Map 的单一操作是自动加锁的排他操作,但组合在一起就不是了,我们要自行在 code section 上加锁。