起因
日常值班时,突然来了一个panic报警,通过argos查看panic日志,可以比较容易发现是数组越界,可为什么会越界呢?
仔细看,panic的报错方法是
encoding/binary.bigEndian.PutUint32(...)
报错文件和代码行号为
/usr/local/go/src/encoding/binary/binary.go:117
这也不是我写的代码呀?再往下翻,都是和kitex框架相关的代码,难道kitex有漏洞🤔️
排查过程
-
复制了错误区域的代码,飞书+google,都没有找到相似的问题
-
可能和kitex相关,在kitex oncall记录里搜索,发现一个和我问题报错一模一样的文档,大致原因是由于业务并发读写同一变量,导致可能赋值为空,kitex打包Request/Response时,记录长度与实际存储长度不符,拷贝时数组越界,导致panic了。
-
通过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)
...
}
-
粗一看都是串行执行,没有起协程,理论上不会出现并发调用。但注意⚠️,loadData是从缓存里加载的数据,对于并发请求来说,两次请求如果都走到了缓存,引用的对象是同一个,而pack时对对象的内容进行了修改,这就导致对同一变量进行了并发读写,产生了数据竞争,也就导致result.Raw可能被赋予了一个无效的[]byte(比如Raw.Data = nil,但Len != 0),kitex打包Respnse时数组越界,panic了。
-
也就是说,虽然单次请求是串行执行的,但请求与请求之间由于引用了同一对象,请求之间产生了并发读写冲突
修复方式
从缓存中读取对象后,如果需要对对象进行修改,先执行深拷贝
func moduleDeepCopy(module *Module) *Module{
return deepcopy.Copy(module).(*Module)
}
思考
-
函数内的并发读写更容易关注到,跨请求间的并发很容易忽视,但在目前高并发服务较多的情况下,很容易出现并发冲突
-
除了缓存引起的并发冲突,还有一些原因,比如Client 复用 request 对象,循环请求,每次使用同一个Request,由于框架在调用时可能会修改 request 里的数据,也会有并发读写的风险
-
对一些偶发问题深挖下去,能够有不小的收获
延伸阅读
可能有同学对于“同一变量的读写为什么会有并发冲突”比较疑惑,可以阅读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等等的调用。