Go 并发写map产生错误能够通过recover()恢复吗?

4,136 阅读4分钟

前言

有时候我们会遇到需要并发写map的时候,一般我们都会通过sync.Mapmap+sync.Mutexmap+sync.RWMutex来避免并发写map产生的异常。这时候会产生一个问题,如果不小心并发写map产生了异常,能否通过recover()来恢复?

注:使用的Go版本是1.18

一个并发写map的例子

package main

import (
   "fmt"
)

func main() {
   m := map[int]bool{}
   go func() {
      defer func() {
         a := recover()
         fmt.Println("1", a)
      }()
      for {
         m[10] = true
      }
   }()
   go func() {
      defer func() {
         a := recover()
         fmt.Println("2", a)
      }()
      for {
         m[10] = true
      }
   }()
   select {}
}

这个例子是两个goroutine并发写一个map的一个key,在对于的函数里,使用了defer+recover()试图捕获并发写map产生的异常,主goroutine通过select{}进行阻塞等待。

我们运行这段代码,会产生以下错误信息:

fatal error: concurrent map writes

goroutine 7 [running]:
runtime.throw({0x2c7b35?, 0x2bc2c0?}) <-重点
        C:/Program Files/Go/src/runtime/panic.go:992 +0x76 fp=0xc000051f68 sp=0xc000051f38 pc=0x253076
runtime.mapassign_fast64(0x2b65c0, 0xc000100480, 0xa) <-重点

        C:/Program Files/Go/src/runtime/map_fast64.go:177 +0x2b4 fp=0xc000051fa0 sp=0xc000051f68 pc=0x22fbd4
main.main.func2()

        D:/Github/him/_test/test.go:24 +0x50 fp=0xc000051fe0 sp=0xc000051fa0 pc=0x2ab770
runtime.goexit()

        C:/Program Files/Go/src/runtime/asm_amd64.s:1571 +0x1 fp=0xc000051fe8 sp=0xc000051fe0 pc=0x27be81
created by main.main

        D:/Github/him/_test/test.go:18 +0x7d

goroutine 1 [select (no cases)]:
main.main()
        D:/Github/him/_test/test.go:27 +0x85

goroutine 6 [runnable]:
main.maiD:/Github/him/_test/test.go:15 +0x50
created by main.main
        D:/Github/him/_test/test.go:9 +0x50

Process finished with the exit code 2

可以看到,是在调用runtime/map_fast64.go里面的mapassign_fast64()的时候,在第177行代码调用了runtime.throw()然后程序就结束了

我们先看看runtime/map_fast64.go的mapassign_fast64()函数:

func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer {
   // 省略代码。。。
    
   // 第一处
   if h.flags&hashWriting != 0 { 
      throw("concurrent map writes")
   }
    
   // 第二处
   h.flags ^= hashWriting

   // 省略代码。。。
again:
   // 省略代码。。。

bucketloop:
   // 省略代码。。。

done:
   // 省略代码。。。
   // 第三处
   if h.flags&hashWriting == 0 {
      throw("concurrent map writes")
   }
   // 第四处
   h.flags &^= hashWriting
   return elem
}

其中:

hashWriting  = 4 // 表示有 goroutine 正在写map

我们上面的代码只留下和hashWriting相关的代码,hashWriting表示有 goroutine 正在写map。

我们按照上面分的4个位置:

第一处

if h.flags&hashWriting != 0 { 
   throw("concurrent map writes")
}

在函数的入口处,先检查是否有goroutine正在写map,如果有说明并发写map,直接throw()

第二处

h.flags ^= hashWriting

这里用了异或操作,而不是或操作,是因为,如果有多个goroutine同时通过第一处的判断,这里就会导致h.flags对应hashWriting的那一位变成0,这样在第三处就会检查到

第三处

if h.flags&hashWriting == 0 {
   throw("concurrent map writes")
}

这时候,如果没有并发写map,按道理h.flags&hashWriting结果应该是1,但是因为在并发情况下,可能多个写操作同时执行第二步h.flags ^= hashWriting导致 h.flags对应hashWriting的那一位变成0,所以就会被检测到,然后throw()

第四处

h.flags &^= hashWriting

这里有一个比较特殊的&^操作,它的规则如下:

第一个操作数第二个操作数结果解释
0000&^0
0100&^1
1011&^0
1101&^1

放到这个地方就是,0的位(除了hashWriting对应的位)不会对左边操作数对应的位(h.flags)产生影响,1的位(hashWriting对应的位)会把左边操作数对应的位(h.flags)清0

简单的说就是只把h.flags里面hashWriting对应的那位清0

简单来说

简单来说就是通过一个h.flags去检测map是否被并发写,如果是的话就调用throw()

所以现在我们需要去看throw()到底做了啥事情

runtime.throw()

func throw(s string) {
   systemstack(func() {
      print("fatal error: ", s, "\n")
   })
   gp := getg()
   if gp.m.throwing == 0 {
      gp.m.throwing = 1
   }
   fatalthrow()
   *(*int)(nil) = 0 // not reached
}
  • 首先它先打印了一句fatal error: xxx也就是上面看到输出的第一行
  • 然后我们可以看到它调用了fatalthrow(),所以我们继续看fatalthrow()是执行了什么

runtime.fatalthrow()

我们直接看这个函数的注释:

// fatalthrow implements an unrecoverable runtime throw. It freezes the
// system, prints stack traces starting from its caller, and terminates the
// process.

简单来说就是:

  • fatalthrow()是无法被recover()
  • 它会冻结系统,打印堆栈,然后结束进程

fatalthrow()里面最后会调用exit(2)结束进程,也就是上面输出的最后一行Process finished with the exit code 2

所以我们现在知道了,如果map检测到被并发写入,会调用throw(),而throw()是无法被recover()的,所以我们想试图通过recover()来恢复是没用的

总结

  • map会检测是否存在并发写
  • 如果检测到并发写会调用runtime.throw(),无法被recover(),直接GG
  • 如果要并发写map必须在业务层面上加锁(sync.Mutexsync.RWMutext)或使用sync.Map等同步容器