日常维护服务中发现,整个集群每隔几天便会告警有一个进程挂掉。这个问题发生的频率比较低,脚本也配置了进程自动重新拉起,所以影响不大,不过这里还是想探究下具体原因。
观察挂掉的进程,在挂掉前,cpu、日志报错、磁盘、内存等看起来都没有异常,发生时间和ip也不固定。
根据之前的排查经验,启动脚本加入两行命令,设置进程挂掉时自动生成core文件。
# 不限制core文件大小ulimit -c unlimited# 设置环境变量GOTRACEBACK为crash级别export GOTRACEBACK=crash
收到报警后,从容器下载下core文件,然后用delve这个工具分析core文件,可以拿到报错堆栈,一般拿到这个就基本可以定位。根据之前经验,常见原因有并发读写map等。
# exe_file 进程文件 core_file core文件dlv core ${exe_file} ${core_file}# 打印堆栈bt
打印出来的报错堆栈如下
0 0x0000000000482de1 in runtime.raise at /usr/local/go/src/runtime/sys_linux_amd64.s:164 1 0x000000000045ea1d in runtime.dieFromSignal at /usr/local/go/src/runtime/signal_unix.go:768 2 0x000000000045f071 in runtime.sigfwdgo at /usr/local/go/src/runtime/signal_unix.go:982 3 0x000000000045d894 in runtime.sigtrampgo at /usr/local/go/src/runtime/signal_unix.go:416 4 0x0000000000483163 in runtime.sigtramp at /usr/local/go/src/runtime/sys_linux_amd64.s:399 5 0x00007fadea96a630 in ??? at ?:-1 6 0x00000000004473ac in runtime.crash at /usr/local/go/src/runtime/signal_unix.go:860 7 0x00000000004473ac in runtime.fatalpanic at /usr/local/go/src/runtime/panic.go:1217 8 0x0000000000446ce5 in runtime.gopanic at /usr/local/go/src/runtime/panic.go:1065 9 0x00000000004449fb in runtime.panicmem at /usr/local/go/src/runtime/panic.go:21210 0x000000000045e853 in runtime.sigpanic at /usr/local/go/src/runtime/signal_unix.go:73411 0x00000000004824e7 in runtime.memmove at /usr/local/go/src/runtime/memmove_amd64.s:17912 0x000000000050654e in fmt.(*buffer).writeString at /usr/local/go/src/fmt/print.go:8213 0x000000000050654e in fmt.(*fmt).padString at /usr/local/go/src/fmt/format.go:11014 0x00000000005074a5 in fmt.(*fmt).fmtS at /usr/local/go/src/fmt/format.go:35915 0x000000000050a951 in fmt.(*pp).fmtString at /usr/local/go/src/fmt/print.go:44316 0x000000000050f056 in fmt.(*pp).printValue at /usr/local/go/src/fmt/print.go:75717 0x000000000050e9a8 in fmt.(*pp).printValue at /usr/local/go/src/fmt/print.go:80618 0x000000000050e9a8 in fmt.(*pp).printValue at /usr/local/go/src/fmt/print.go:80619 0x000000000050e797 in fmt.(*pp).printValue at /usr/local/go/src/fmt/print.go:87620 0x000000000050c813 in fmt.(*pp).printArg at /usr/local/go/src/fmt/print.go:71221 0x000000000050fe48 in fmt.(*pp).doPrintf at /usr/local/go/src/fmt/print.go:102622 0x0000000000509186 in fmt.Sprintf at /usr/local/go/src/fmt/print.go:21923 0x0000000000cf96e6 in xxx.xxx.xxx.com/xxx/xxx/xxx/log.(*ZapLog).Infof at /tmp/xxx/xx/log/zaplogger.go:274
具体报错的业务代码其实十分简单,就是单纯打印下一个变量value,初看起来没啥报错的可能,即便为nil也没啥问题。
log.InfoContextf(ctx, "XXXXX_Value: %+v", value)
接着往底层看,log.InfoContextf底层实际就是fmt.Sprintf。
最底层的错误是SIGSEGV段错误,也就是程序试图访问不被允许访问的内存区域,一般原因有空指针、数组越界等。
找到非错误处理的最后一处代码,代码很简单,就是append下string对应的byte数组。
func (b *buffer) writeString(s string) { *b = append(*b, s...)}
这里的变量b打印了下,发现并不为空,这里变量的s无法打印出来,不过这应该是生成core文件的问题,确实有也有其他变量无法打印。
排查到这里就比较困惑了,b不为空,s又是基本类型,不会为空,看不出啥场景基本类型会报错。
不过看了下业务逻辑,这个字符串会有并发读写的情况,初步怀疑跟并发有关。
初步检索了下,发现确实有一些但不太多的讨论,golang的string在并发情况下是不安全的。官方的回应也是不能保证并发情况下程序的行为,如果没有做并发控制而去访问共享变量。
确实正确的方式是不要并发使用,不过这里想深究一下原因,为什么会导致进程崩溃。
继续检索过程中,发现一个比较关键的点是string在运行时是一个结构体,存放着指向数据的指针Data和长度Len.
源码见reflect/value.go,go版本是16.9。
// StringHeader is the runtime representation of a string.// It cannot be used safely or portably and its representation may// change in a later release.// Moreover, the Data field is not sufficient to guarantee the data// it references will not be garbage collected, so programs must keep// a separate, correctly typed pointer to the underlying data.type StringHeader struct { Data uintptr Len int}
字符串的赋值实际是分为两个部分,指针和长度,如果并发写入,可能导致导致指向的内容和长度不匹配,比如这个指针是0x0,也就是指向nil,而长度不为空。
不过我看了下我这里的场景,这个变量都是从空值变成长度大于0的字符串,而且结果是同样的字符串,如果是先赋值指针,再赋值长度(Len从0变成一个大于0的值),理论上也应该不会报错,因为也没有越界。
这里写了一份报错demo代码,一个协程一直不停打印变量,另起协程不断修改变量值。
package mainimport ( "fmt" "time")type SampleStruct struct { Value string}func main() { a := &SampleStruct{ Value: "", } go func() { for { fmt.Printf("%+v %v", a.Value, time.Now().Unix()) } }() for { go func() { a.Value = "" }() time.Sleep(time.Microsecond) go func() { a.Value = "value" }() time.Sleep(time.Microsecond) }}
跑一段时间后会panic,标准输出如下
panic: runtime error: invalid memory address or nil pointer dereference[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x465282]goroutine 18 [running]:fmt.(*buffer).writeString(...) /usr/local/go/src/fmt/print.go:82fmt.(*fmt).padString(0xc00010a110, 0x0, 0x5) /usr/local/go/src/fmt/format.go:110 +0x8efmt.(*fmt).fmtS(0xc00010a110, 0x0, 0x5) /usr/local/go/src/fmt/format.go:359 +0x65fmt.(*pp).fmtString(0xc00010a0d0, 0x0, 0x5, 0x76) /usr/local/go/src/fmt/print.go:443 +0x131fmt.(*pp).printArg(0xc00010a0d0, 0x4a47e0, 0xc0004a30c0, 0x76) /usr/local/go/src/fmt/print.go:694 +0x875fmt.(*pp).doPrintf(0xc00010a0d0, 0x4bcd72, 0x6, 0xc000100fa8, 0x2, 0x2) /usr/local/go/src/fmt/print.go:1026 +0x168fmt.Fprintf(0x4dc418, 0xc000182008, 0x4bcd72, 0x6, 0xc000100fa8, 0x2, 0x2, 0x10, 0x0, 0x0) /usr/local/go/src/fmt/print.go:204 +0x72fmt.Printf(...) /usr/local/go/src/fmt/print.go:213main.main.func1(0xc00018a050)
这里参考博客,打印并分析了下汇编代码。
go tool compile -S -N -l main.go
第25行代码a.Value = ""
"".main.func2 STEXT size=87 args=0x8 locals=0x8 funcid=0x0 0x0000 00000 (main.go:24) TEXT "".main.func2(SB), ABIInternal, $8-8 0x0000 00000 (main.go:24) MOVQ (TLS), CX 0x0009 00009 (main.go:24) CMPQ SP, 16(CX) 0x000d 00013 (main.go:24) PCDATA $0, $-2 0x000d 00013 (main.go:24) JLS 80 0x000f 00015 (main.go:24) PCDATA $0, $-1 0x000f 00015 (main.go:24) SUBQ $8, SP 0x0013 00019 (main.go:24) MOVQ BP, (SP) 0x0017 00023 (main.go:24) LEAQ (SP), BP 0x001b 00027 (main.go:24) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB) 0x001b 00027 (main.go:24) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB) 0x001b 00027 (main.go:25) MOVQ "".a+16(SP), DI 0x0020 00032 (main.go:25) TESTB AL, (DI) 0x0022 00034 (main.go:25) MOVQ $0, 8(DI) 0x002a 00042 (main.go:25) PCDATA $0, $-2 0x002a 00042 (main.go:25) CMPL runtime.writeBarrier(SB), $0 0x0031 00049 (main.go:25) JEQ 53 0x0033 00051 (main.go:25) JMP 71 0x0035 00053 (main.go:25) MOVQ $0, (DI) 0x003c 00060 (main.go:25) JMP 62 0x003e 00062 (main.go:26) PCDATA $0, $-1 0x003e 00062 (main.go:26) MOVQ (SP), BP 0x0042 00066 (main.go:26) ADDQ $8, SP 0x0046 00070 (main.go:26) RET 0x0047 00071 (main.go:25) PCDATA $0, $-2 0x0047 00071 (main.go:25) XORL AX, AX 0x0049 00073 (main.go:25) CALL runtime.gcWriteBarrier(SB) 0x004e 00078 (main.go:25) JMP 62 0x0050 00080 (main.go:25) NOP 0x0050 00080 (main.go:24) PCDATA $1, $-1 0x0050 00080 (main.go:24) PCDATA $0, $-2 0x0050 00080 (main.go:24) CALL runtime.morestack_noctxt(SB) 0x0055 00085 (main.go:24) PCDATA $0, $-1 0x0055 00085 (main.go:24) JMP 0
这里摘出关键两行
# 将数0存入DI地址+8,即把长度存入Len0x0022 00034 (main.go:25) MOVQ $0, 8(DI)# 将数0存入DI地址,即指向字符串内容的指针为0,也就是nil0x0035 00053 (main.go:25) MOVQ $0, (DI)
第28行代码a.Value = "value"
"".main.func3 STEXT size=98 args=0x8 locals=0x8 funcid=0x0 0x0000 00000 (main.go:28) TEXT "".main.func3(SB), ABIInternal, $8-8 0x0000 00000 (main.go:28) MOVQ (TLS), CX 0x0009 00009 (main.go:28) CMPQ SP, 16(CX) 0x000d 00013 (main.go:28) PCDATA $0, $-2 0x000d 00013 (main.go:28) JLS 89 0x000f 00015 (main.go:28) PCDATA $0, $-1 0x000f 00015 (main.go:28) SUBQ $8, SP 0x0013 00019 (main.go:28) MOVQ BP, (SP) 0x0017 00023 (main.go:28) LEAQ (SP), BP 0x001b 00027 (main.go:28) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB) 0x001b 00027 (main.go:28) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB) 0x001b 00027 (main.go:29) MOVQ "".a+16(SP), DI 0x0020 00032 (main.go:29) TESTB AL, (DI) 0x0022 00034 (main.go:29) MOVQ $5, 8(DI) 0x002a 00042 (main.go:29) PCDATA $0, $-2 0x002a 00042 (main.go:29) CMPL runtime.writeBarrier(SB), $0 0x0031 00049 (main.go:29) JEQ 53 0x0033 00051 (main.go:29) JMP 75 0x0035 00053 (main.go:29) LEAQ go.string."value"(SB), AX 0x003c 00060 (main.go:29) MOVQ AX, (DI) 0x003f 00063 (main.go:29) NOP 0x0040 00064 (main.go:29) JMP 66 0x0042 00066 (main.go:30) PCDATA $0, $-1 0x0042 00066 (main.go:30) MOVQ (SP), BP 0x0046 00070 (main.go:30) ADDQ $8, SP 0x004a 00074 (main.go:30) RET 0x004b 00075 (main.go:29) PCDATA $0, $-2 0x004b 00075 (main.go:29) LEAQ go.string."value"(SB), AX 0x0052 00082 (main.go:29) CALL runtime.gcWriteBarrier(SB) 0x0057 00087 (main.go:29) JMP 66 0x0059 00089 (main.go:29) NOP 0x0059 00089 (main.go:28) PCDATA $1, $-1 0x0059 00089 (main.go:28) PCDATA $0, $-2 0x0059 00089 (main.go:28) CALL runtime.morestack_noctxt(SB) 0x005e 00094 (main.go:28) PCDATA $0, $-1 0x005e 00094 (main.go:28) NOP 0x0060 00096 (main.go:28) JMP 0
这里也摘出关键几行
# 将数5存入DI地址+8,也就是"value"的长度5存入Len0x0022 00034 (main.go:29) MOVQ $5, 8(DI)# 将指向"value"的地址存入AX0x0035 00053 (main.go:29) LEAQ go.string."value"(SB), AX# 再将AX的地址存入DI0x003c 00060 (main.go:29) MOVQ AX, (DI)
所以从这汇编代码可以看出是先赋值长度,然后再赋值指向内容的指针。(PS: 不能保证一定是这个顺序,如果换一种写法,也可以是先赋值指针,再赋值长度)。
所以问题就是这个string变量SampleStruct.Value,Data和Len的初始值均为0,当协程A先赋值Len为一个大于0的值,还未来得及赋值Data为对应字符串的指针地址时,此时协程B去打印,就会发现指向的是空指针,但长度却不为空,从而数据越界发生段错误。
这点也可以从标准输出看出,指向的是空指针 (0x0),但长度不为0 (0x5)。
fmt.(*fmt).padString(0xc00010a110, 0x0, 0x5)
总结来说string变量是一个结构体,它的赋值分为两步,赋值长度和赋值指向内容的指针,不是原子性的,并发情况下可能会出现问题。
参考或者相关的文章链接:
https://stackoverflow.com/questions/64290030/what-would-cause-this-reference-pointer-panic-in-fmt-sprintf-occasionally https://github.com/golang/go/issues/39587 https://groups.google.com/g/golang-nuts/c/EOQWeqJwc68?pli=1 https://stackoverflow.com/questions/54938609/sprintf-access-segment-violation-in-gloang https://shaffer.cn/golang/golang-panic-problem-2/ https://www.jianshu.com/p/6dba7d3ecaf1 https://github.com/golang/go/issues/56725