Go 的进程在 64 位系统中无故消失?|Go主题月

264 阅读2分钟

大家编写的 Go 程序,有在生产环境试过无故消失吗?可能大部分人都没试过遇到这种神奇的问题。但十分不巧,笔者我就恰恰碰上,然而当时只会进行重启大法(虽然慌的不行)。而其中原因,在碰上 3 次之后,终于痛下决心寻找其中原因。

程序使用 Go 1.5 进行编译,运行在一台 CentOS release 6.3 (Final) 2.6.32-279.el6.x86_64 机器上。每一次出错都会报出以下的错误:

fatal error: bad map state


goroutine 89 \[running\]:
runtime.throw(0x990ca0, 0xd)
        /usr/local/go/src/runtime/panic.go:527 +0x90 fp=0xc8323e9bb0 sp=0xc8323e9b98
runtime.evacuate(0x803440, 0xc8200f4c30, 0x6b)
        /usr/local/go/src/runtime/hashmap.go:825 +0x3b1 fp=0xc8323e9c70 sp=0xc8323e9bb0
runtime.growWork(0x803440, 0xc8200f4c30, 0xa5)
        /usr/local/go/src/runtime/hashmap.go:795 +0x83 fp=0xc8323e9c90 sp=0xc8323e9c70
runtime.mapassign1(0x803440, 0xc8200f4c30, 0xc8323e9d60, 0xc8323e9d70)
        /usr/local/go/src/runtime/hashmap.go:433 +0x176 fp=0xc8323e9d38 sp=0xc8323e9c90

看着这个错误,如何定位是什么问题呢?先分析 hashmap.go:825 对应的代码的上下文。

for ; b != nil; b = b.overflow(t) {
      k := add(unsafe.Pointer(b), dataOffset)
      v := add(k, bucketCnt\*uintptr(t.keysize))
      for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
        top := b.tophash\[i\]
        if top == empty {
          b.tophash\[i\] = evacuatedEmpty
          continue
        }
        if top < minTopHash {
          throw("bad map state")   <----- 825 行
        }
        ...
     }
     ...
}

从上下文可以看出:映射桶在 NaCl/amd64p32 上指针溢出。然而为什么会溢出呢?

在大多数系统中,指针是比较糟糕的对齐方式,因此在结构的末尾添加指针字段可以保证在该字段之后不会添加填充(由于还存在一些需要更多对齐的字段,因此可以满足整个结构的对齐)。

而在运行时,map 需要一种快速的方法来获取溢出指针,它的 bucket 结构中的最后一个指针,因此它使用 size-sizeof(指针)作为偏移量。

NaCl/amd64p32 是例外,一如既往都是使用对齐方式是 64 位,但指针是32位。关于这个事情,有一大段不值得深入研究的历史,但是当我们将溢出指针移到结构的末尾时,我们不能得到正确的结果。编译器计算了常规结构的大小,然后在 amd64p32 上添加了另一个 32 位。运行时假设它可以退回两个 32 位(一个64位寄存器大小)以到达溢出指针。

但实际上,如果结构需要64 位对齐,常规结构大小的计算已经添加了一个 32 位,然后代码无条件地添加了第二个 32 位。这个把溢出指针放在 3 字(1 字= 2 字节 = 2 * 16 位)的末尾,而不是 2 字后。而最后 2 个字是填充,由于运行时一致使用倒数第 2 字作为溢出指针,在覆盖有用内存的意义上没有伤害。但将溢出指针写入内存的非指针字,意味着GC看不到溢出块,所以它会过早收集,从而导致了错误。

综上所述,64 位操作系统中,写一个溢出指针到内存的非指针字符(a non-pointer word of memory),导致GC的时,不能看到该溢出块,所以导致GC过早回收该块,然后就导致崩溃(目前在NaCl/amd64p32已发现该问题)。

解决方案

本文提供3种方案可选

  • 升级Golang编译版本 1.5->1.8 (必须)

  • 增加进程监控

  • 增加supervior管理进程

如何修正

升级是必然,但 Go 官方是如何在后续的版本修正该问题的呢?

  • 在编译器的 bucket 布局的末尾添加一个显式检查,确保溢出字段是结构中的最后一个字段,后面从不加填充。

  • 当 NaCl 上需要填充时(仅在需要时),将其插入溢出指针之前,以保留“结构中的最后一个”属性。

  • 通过插入一个显式的填充字段,而不是覆盖它所做的宽度计算的结果,让编译器对结构的宽度有最终决定权。

  • 出于同样的原因(需要告诉编译器真相),当我们试图假装溢出字段不是指针时,请设置溢出字段的类型(在本例中,运行时在别处维护溢出块的列表)。

  • 使运行时使用 “结构中的最后一个” 作为其定位算法。

欢迎关注我的技术公众号

gongzhonghao.png

加入社区

在公众号后台回复关键字【dubbogo】加入 dubbo-go 社区。