作者:arthurlhan,腾讯CDG应用开发工程师
日常维护服务中发现,整个集群每隔几天便会告警有一个进程挂掉。这个问题发生的频率比较低,脚本也配置了进程自动重新拉起,所以影响不大,不过这里还是想探究下具体原因。
观察挂掉的进程,在挂掉前,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.raiseat /usr/local/go/src/runtime/sys_linux_amd64.s:1641 0x000000000045ea1d in runtime.dieFromSignalat /usr/local/go/src/runtime/signal_unix.go:7682 0x000000000045f071 in runtime.sigfwdgoat /usr/local/go/src/runtime/signal_unix.go:9823 0x000000000045d894 in runtime.sigtrampgoat /usr/local/go/src/runtime/signal_unix.go:4164 0x0000000000483163 in runtime.sigtrampat /usr/local/go/src/runtime/sys_linux_amd64.s:3995 0x00007fadea96a630 in ???at ?:-16 0x00000000004473ac in runtime.crashat /usr/local/go/src/runtime/signal_unix.go:8607 0x00000000004473ac in runtime.fatalpanicat /usr/local/go/src/runtime/panic.go:12178 0x0000000000446ce5 in runtime.gopanicat /usr/local/go/src/runtime/panic.go:10659 0x00000000004449fb in runtime.panicmemat /usr/local/go/src/runtime/panic.go:21210 0x000000000045e853 in runtime.sigpanicat /usr/local/go/src/runtime/signal_unix.go:73411 0x00000000004824e7 in runtime.memmoveat /usr/local/go/src/runtime/memmove_amd64.s:17912 0x000000000050654e in fmt.(*buffer).writeStringat /usr/local/go/src/fmt/print.go:8213 0x000000000050654e in fmt.(*fmt).padStringat /usr/local/go/src/fmt/format.go:11014 0x00000000005074a5 in fmt.(*fmt).fmtSat /usr/local/go/src/fmt/format.go:35915 0x000000000050a951 in fmt.(*pp).fmtStringat /usr/local/go/src/fmt/print.go:44316 0x000000000050f056 in fmt.(*pp).printValueat /usr/local/go/src/fmt/print.go:75717 0x000000000050e9a8 in fmt.(*pp).printValueat /usr/local/go/src/fmt/print.go:80618 0x000000000050e9a8 in fmt.(*pp).printValueat /usr/local/go/src/fmt/print.go:80619 0x000000000050e797 in fmt.(*pp).printValueat /usr/local/go/src/fmt/print.go:87620 0x000000000050c813 in fmt.(*pp).printArgat /usr/local/go/src/fmt/print.go:71221 0x000000000050fe48 in fmt.(*pp).doPrintfat /usr/local/go/src/fmt/print.go:102622 0x0000000000509186 in fmt.Sprintfat /usr/local/go/src/fmt/print.go:21923 0x0000000000cf96e6 in xxx.xxx.xxx.com/xxx/xxx/xxx/log.(*ZapLog).Infofat /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 uintptrLen int}
字符串的赋值实际是分为两个部分,指针和长度,如果并发写入,可能导致导致指向的内容和长度不匹配,比如这个指针是0x0,也就是指向nil,而长度不为空。
不过我看了下我这里的场景,这个变量都是从空值变成长度大于0的字符串,而且结果是同样的字符串,如果是先赋值指针,再赋值长度(Len从0变成一个大于0的值),理论上也应该不会报错,因为也没有越界。
这里写了一份报错demo代码,一个协程一直不停打印变量,另起协程不断修改变量值。
package mainimport (fmttime)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=0x00x0000 00000 (main.go:24) TEXT .main.func2(SB), ABIInternal, $8-80x0000 00000 (main.go:24) MOVQ (TLS), CX0x0009 00009 (main.go:24) CMPQ SP, 16(CX)0x000d 00013 (main.go:24) PCDATA $0, $-20x000d 00013 (main.go:24) JLS 800x000f 00015 (main.go:24) PCDATA $0, $-10x000f 00015 (main.go:24) SUBQ $8, SP0x0013 00019 (main.go:24) MOVQ BP, (SP)0x0017 00023 (main.go:24) LEAQ (SP), BP0x001b 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), DI0x0020 00032 (main.go:25) TESTB AL, (DI)0x0022 00034 (main.go:25) MOVQ $0, 8(DI)0x002a 00042 (main.go:25) PCDATA $0, $-20x002a 00042 (main.go:25) CMPL runtime.writeBarrier(SB), $00x0031 00049 (main.go:25) JEQ 530x0033 00051 (main.go:25) JMP 710x0035 00053 (main.go:25) MOVQ $0, (DI)0x003c 00060 (main.go:25) JMP 620x003e 00062 (main.go:26) PCDATA $0, $-10x003e 00062 (main.go:26) MOVQ (SP), BP0x0042 00066 (main.go:26) ADDQ $8, SP0x0046 00070 (main.go:26) RET0x0047 00071 (main.go:25) PCDATA $0, $-20x0047 00071 (main.go:25) XORL AX, AX0x0049 00073 (main.go:25) CALL runtime.gcWriteBarrier(SB)0x004e 00078 (main.go:25) JMP 620x0050 00080 (main.go:25) NOP0x0050 00080 (main.go:24) PCDATA $1, $-10x0050 00080 (main.go:24) PCDATA $0, $-20x0050 00080 (main.go:24) CALL runtime.morestack_noctxt(SB)0x0055 00085 (main.go:24) PCDATA $0, $-10x0055 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=0x00x0000 00000 (main.go:28) TEXT .main.func3(SB), ABIInternal, $8-80x0000 00000 (main.go:28) MOVQ (TLS), CX0x0009 00009 (main.go:28) CMPQ SP, 16(CX)0x000d 00013 (main.go:28) PCDATA $0, $-20x000d 00013 (main.go:28) JLS 890x000f 00015 (main.go:28) PCDATA $0, $-10x000f 00015 (main.go:28) SUBQ $8, SP0x0013 00019 (main.go:28) MOVQ BP, (SP)0x0017 00023 (main.go:28) LEAQ (SP), BP0x001b 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), DI0x0020 00032 (main.go:29) TESTB AL, (DI)0x0022 00034 (main.go:29) MOVQ $5, 8(DI)0x002a 00042 (main.go:29) PCDATA $0, $-20x002a 00042 (main.go:29) CMPL runtime.writeBarrier(SB), $00x0031 00049 (main.go:29) JEQ 530x0033 00051 (main.go:29) JMP 750x0035 00053 (main.go:29) LEAQ go.string.value(SB), AX0x003c 00060 (main.go:29) MOVQ AX, (DI)0x003f 00063 (main.go:29) NOP0x0040 00064 (main.go:29) JMP 660x0042 00066 (main.go:30) PCDATA $0, $-10x0042 00066 (main.go:30) MOVQ (SP), BP0x0046 00070 (main.go:30) ADDQ $8, SP0x004a 00074 (main.go:30) RET0x004b 00075 (main.go:29) PCDATA $0, $-20x004b 00075 (main.go:29) LEAQ go.string.value(SB), AX0x0052 00082 (main.go:29) CALL runtime.gcWriteBarrier(SB)0x0057 00087 (main.go:29) JMP 660x0059 00089 (main.go:29) NOP0x0059 00089 (main.go:28) PCDATA $1, $-10x0059 00089 (main.go:28) PCDATA $0, $-20x0059 00089 (main.go:28) CALL runtime.morestack_noctxt(SB)0x005e 00094 (main.go:28) PCDATA $0, $-10x005e 00094 (main.go:28) NOP0x0060 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变量是一个结构体,它的赋值分为两步,赋值长度和赋值指向内容的指针,不是原子性的,并发情况下可能会出现问题。
这个问题不局限于string类型,其他基础类型也会有并发问题。这点可以参考官方博客https://go.dev/ref/mem#badsync 的Incorrect synchronization章节和https://go.dev/doc/articles/race_detector 的 Primitive unprotected variable章节。
参考或者相关的文章链接:
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
本文由梁桂钊于2022-07-21发表在梁桂钊的博客,如有疑问,请联系我们。
本文链接:https://720ui.com/3950.html