当前位置:首页 > 生活 > 正文

golang并发读写string变量导致进程崩溃(golang strings.count)

作者: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