"串行执行”也会并发冲突?记一次panic排查

40 阅读3分钟

起因

日常值班时,突然来了一个panic报警,通过argos查看panic日志,可以比较容易发现是数组越界,可为什么会越界呢?

image

仔细看,panic的报错方法是

encoding/binary.bigEndian.PutUint32(...)

报错文件和代码行号为

/usr/local/go/src/encoding/binary/binary.go:117

这也不是我写的代码呀?再往下翻,都是和kitex框架相关的代码,难道kitex有漏洞🤔️

排查过程

  1. 复制了错误区域的代码,飞书+google,都没有找到相似的问题

  2. 可能和kitex相关,在kitex oncall记录里搜索,发现一个和我问题报错一模一样的文档,大致原因是由于业务并发读写同一变量,导致可能赋值为空,kitex打包Request/Response时,记录长度与实际存储长度不符,拷贝时数组越界,导致panic了。

  3. 通过logid上游的日志捞出调用该服务的方法,代码逻辑简化后大致如下

func Handler() Response {
    checkParam() // 校验参数
    loadData() // 加载数据
    pack() // 打包
    return Response
}

func loadData() ([]*Module, error) {
    result, err := loadThroughCache() // 先走本地缓存,查不到再走db
    if err != il {
        return nil, err
    } 
    return result, nil
}

func pack() {
    ...
    result.Raw, _ := json.Marshal(result)
    ...
}
  1. 粗一看都是串行执行,没有起协程,理论上不会出现并发调用。但注意⚠️,loadData是从缓存里加载的数据,对于并发请求来说,两次请求如果都走到了缓存,引用的对象是同一个,而pack时对对象的内容进行了修改,这就导致对同一变量进行了并发读写,产生了数据竞争,也就导致result.Raw可能被赋予了一个无效的[]byte(比如Raw.Data = nil,但Len != 0),kitex打包Respnse时数组越界,panic了。

  2. 也就是说,虽然单次请求是串行执行的,但请求与请求之间由于引用了同一对象,请求之间产生了并发读写冲突

修复方式

从缓存中读取对象后,如果需要对对象进行修改,先执行深拷贝

func moduleDeepCopy(module *Module) *Module{
        return deepcopy.Copy(module).(*Module)
}

思考

  1. 函数内的并发读写更容易关注到,跨请求间的并发很容易忽视,但在目前高并发服务较多的情况下,很容易出现并发冲突

  2. 除了缓存引起的并发冲突,还有一些原因,比如Client 复用 request 对象,循环请求,每次使用同一个Request,由于框架在调用时可能会修改 request 里的数据,也会有并发读写的风险

  3. 对一些偶发问题深挖下去,能够有不小的收获

延伸阅读

可能有同学对于“同一变量的读写为什么会有并发冲突”比较疑惑,可以阅读stackoverflow.com/questions/4…

再举一个例子,猜猜下面的输出是什么?

const (
   tiger = "TIGER"
   cat   = "CAT"
)

func main() {
   runtime.GOMAXPROCS(2)
   i := tiger
   go func() {
      for {
         fmt.Println(i)
      }
   }()
   for {
      if i == tiger {
         i = cat
      } else {
         i = tiger
      }
   }
}

可能你会给出CAT,TIGER,但执行后,会有少量的TIG、CATCB😮 什么情况!

golang里string其实是一个结构体,在并发读写时,可能Data已经修改了,但是Len还没有修改,比如TIG的产生,就是Data已经修改为TIGER,但Len还保持了3的长度;同样的,CATCB,后面的“CB”是内存中CAT后面的两个字符

type StringHeader struct {
        Data uintptr
        Len  int
}

竞争条件检测

即使我们小心到不能再小心,但在并发程序中犯错还是太容易了。幸运的是,Go的runtime和工具链为我们装备了一个复杂但好用的动态分析工具,竞争检查器(the race detector)。

只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器创建一个你的应用的“修改”版或者一个附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下所有的同步事件,比如go语句,channel操作,以及对(*sync.Mutex).Lock,(*sync.WaitGroup).Wait等等的调用。