Golang - map的并发读写导致panic

86

前言

golang在官方的 FAQ 提到过map不是线程安全的,如果有并发场景需要自己加锁,或者使用sync包里的Map。

这本是众所周知的问题,但是本文的重点是记录一个压测过程中进程panic问题,panic的报错信息是map的并发读写和并发写的情况,但是一波分析之后,原因并不出在map上,而是一个slice的操作问题。

压测背景

压测的服务是一个http服务,用的一个github上的go-restful库,没有使用gin这些框架。

panic报错信息:

fatal error: concurrent map writes

goroutine 518470 [running]:
runtime.throw(0x2e063c3, 0x15)
	/usr/local/go/src/runtime/panic.go:617 +0x72 fp=0xc01c8251a8 sp=0xc01c825178 pc=0xef8962
runtime.mapassign_faststr(0x2ab5d20, 0xc01c8c1aa0, 0x2dea8f5, 0x6, 0x2)
	/usr/local/go/src/runtime/map_faststr.go:291 +0x40f fp=0xc01c825210 sp=0xc01c8251a8 pc=0xede0bf
github.com/emicklei/go-restful.(*Request).SetAttribute(...)
	/root/go/pkg/mod/github.com/emicklei/go-restful@v2.12.0+incompatible/request.go:107
github.com/go-chassis/go-chassis/server/restful.Invocation2HTTPRequest(0xc020183e60, 0xc01c8c19e0)
...
net/http.(*conn).serve(0xc0201f2e60, 0x31e02e0, 0xc01ca3f180)
	/usr/local/go/src/net/http/server.go:1878 +0x851 fp=0xc01c825fc8 sp=0xc01c825d20 pc=0x11c5f81
runtime.goexit()
	/usr/local/go/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc01c825fd0 sp=0xc01c825fc8 pc=0xf28eb1
created by net/http.(*Server).Serve
	/usr/local/go/src/net/http/server.go:2884 +0x2f4
	
goroutine 518462 [running]:
runtime.throw(0x2e063c3, 0x15)
	/usr/local/go/src/runtime/panic.go:617 +0x72 fp=0xc01ff4b1a8 sp=0xc01ff4b178 pc=0xef8962
runtime.mapassign_faststr(0x2ab5d20, 0xc01c8c1aa0, 0x2dea8f5, 0x6, 0x2)
	/usr/local/go/src/runtime/map_faststr.go:291 +0x40f fp=0xc01ff4b210 sp=0xc01ff4b1a8 pc=0xede0bf
github.com/emicklei/go-restful.(*Request).SetAttribute(...)
	/root/go/pkg/mod/github.com/emicklei/go-restful@v2.12.0+incompatible/request.go:107
github.com/go-chassis/go-chassis/server/restful.Invocation2HTTPRequest(0xc020334d80, 0xc01c8c19e0)
...
net/http.(*conn).serve(0xc0201bc3c0, 0x31e02e0, 0xc020016ec0)
	/usr/local/go/src/net/http/server.go:1878 +0x851 fp=0xc01ff4bfc8 sp=0xc01ff4bd20 pc=0x11c5f81
runtime.goexit()
	/usr/local/go/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc01ff4bfd0 sp=0xc01ff4bfc8 pc=0xf28eb1
created by net/http.(*Server).Serve
	/usr/local/go/src/net/http/server.go:2884 +0x2f4

分析

从日志上可以明显的看到确实是有两个运行的状态的协程同时操作了同一个map,导致map双写,然后panic

runtime.mapassign_faststr(0x2ab5d20, 0xc01c8c1aa0, 0x2dea8f5, 0x6, 0x2)

但是问题在于,这个map是go-restful库对每一个请求都新建的一个结构体对象,当请求到来的时候http会为每一个请求创建一个协程,所以每个map都是在同一个协程里创建,正常来说不会出现并发的问题,但是寄存器地址明显显示是同一个map,说明问题不在go-restful这个库,继续往下看对栈信息。

继续往下可以看到 Invocation2HTTPRequest 这个函数调用,第二个参数是一样的,离事实更近一步了

Invocation2HTTPRequest(0xc020183e60, 0xc01c8c19e0)

Invocation2HTTPRequest(0xc020334d80, 0xc01c8c19e0)

对go-chassis这个库不熟悉的朋友对这里的 Invocation2HTTPRequest 函数调用应该不太熟悉,跟gin类似,

go-chassis这个库会对每一个请求生成一个handler链,一个请求过来,都会复制条默认的handler链,然后把自己的请求信息作为最后一个handler,追加到默认的handler链上。

想到这里,翻一翻go-chassis这个链路的实现:

...
bs := NewBaseServer(inv.Ctx)
bs.Req = req
bs.Resp = resp
//create a new chain for each resource handler
c := &handler.Chain{}
*c = *originChain
c.AddHandler(newHandler(handleFunc, bs, opts))
...

newHandler(handleFunc, bs, opts) 所做的事情就是为每个请求包装一个handler,然后追加到共享变量 originChain 的拷贝对象中去。

看到了一丝怪异, *c = *originChain 这个拷贝动作仿佛有点问题,点开 handler.Chain{} 结构体:

// Chain struct for service and handlers
type Chain struct {
	ServiceType string
	Name        string
	Handlers    []Handler
}

看到 Handlers 这个切片,果然,问题就是出在这里了。

可以还原一下当时的场景:

A、B请求同时走到 *c = *originChain 这时候,两个hanlder链点拷贝体中Hnadlers还指向同一个slice地址,指向的底层数组还是同一个,此时两个协程都进行append操作,在一种情况下,存在后一个append操作把前一个append操作覆盖的现象。

因为slice的增长是有规律的,当cap小于1024,那么每次增长,cap都变成之前的2倍,当大于1024,那么每次增长就只是1.25倍。当一个slice的cap大于len的时候,底层数组有空余的地址可以写入即将append的那个元素。那么这时候并发append就会有 DATA RACE 的情况发生。

回头继续看压测的情况,当时handler链是从0开始append,压测中的originChain会append三次,那么slice的变化就是:(len: 1, cap: 1) --> (len: 2, cap: 2) --> (len: 3, cap: 4), 第四个位置就有可能发生并发写。假设A请求的append操作在B请求之后,A覆盖B,两条复制的handler链就会变成:

A请求handler链: [hanlder1, handler2, handler3, handlerA]

B请求handler链: [hanlder1, handler2, handler3, handlerA]

而最后一个handler中刚好就是有日志中打印的map,A、B协程并发写,程序panic,报错日志中其他panic是map的并发读写,根因必然也是如此了。

结论

slice共享变量的拷贝和append操作线程不安全,导致map被多个协程操作,引发panic。

回顾整个过程,golang的map并发读写造成的原因可能有很多,但是并发问题一定是有变量被共享了,多个协程一起操作,只要基于这个原则,顺着堆栈,根据代码找到泄漏的地方就可以。