找回密码
 立即注册
首页 资源区 代码 golang unsafe遇上字符串拼接优化导致的bug

golang unsafe遇上字符串拼接优化导致的bug

BruceHenne 2025-5-28 22:22:37
最近料理老项目的时候被unsafe坑惨了,这里挑一个最不易察觉的错误记录一下。
这个问题几乎影响近几年来所有的go版本,为了方便讨论我就用最新版的1.24.3做例子了。
线上BUG

我们有一个收集集群信息的线上系统,这个系统有好几个数据源而且数据量比较大。众所周知Go语言总是会在一些关键性能点上拉跨,我们也遇到了,所以作为性能优化策略之一,我们在一部分数据处理逻辑里使用了unsafe以减少不必要的开销。
其中一个函数是利用unsafe把字符串转换成字符切片:
  1. func getBytes(str string) (b []byte) {
  2.         sh := (*reflect.StringHeader)(unsafe.Pointer(&str))
  3.         bh := reflect.SliceHeader{
  4.                 Data: sh.Data,
  5.                 Len:  sh.Len,
  6.                 Cap:  sh.Len,
  7.         }
  8.         return *(*[]byte)(unsafe.Pointer(&bh))
  9. }
复制代码
尽管reflect.StringHeader和它的朋友reflect.SliceHeader都已经标记为废弃了,但因为1.0兼容性保证,这两个东西在1.24里依旧能用,而且还能正常工作。这个函数实际上把字符串底层的内存直接赋值给了slice,让slice和字符串共用这块内存来实现零复制零分配。
这个函数的风险在于如果这块共享的内存被修改了,这个修改会意外地被我们返回的slice看到。然而string在go里是不可变的,改变一个string的值只会重新分配一块内存存放新值,是不是我们多虑了?
事实上我们没有多虑,因为真的有这种意外会发生——线上系统出Bug了。
这个Bug其实很容易观察到,因为上线没多久我们就发现数据里出现了一些重复数据还有一些脏数据。当我们把版本回滚到做unsafe优化前,数据就彻底恢复了正常。
虽然问题现象很容易发现,但问题原因就很棘手了。因为如上面所说,字符串是不可变的,理论上从字符串里拿出来的切片应该也是不会被意外改变的,更何况我们用的字符串都是拼接出来的,也不存在共用字符串变量导致问题的可能。
然而排查了两天最终我们发现正是这个“拼接”导致的问题,在看了go编译器的源码之后我写了一段最小复现代码:
  1. func main() {
  2.         buffers := make([][]byte, 0)
  3.         for i := range 5 {
  4.                 s1 := "test: "
  5.                 s2 := fmt.Sprintf("%02d", i)
  6.                 s3 := s1 + s2
  7.                 buffers = append(buffers, getBytes(s3))
  8.         }
  9.         for _, s := range buffers {
  10.                 fmt.Println(string(s))
  11.         }
  12. }
复制代码
大部分会觉得这段代码会输出test: 01\ntest: 02\n......,然而这段代码会输出五个test: 04,如果不信可以自己在1.24环境下运行一次。当然1.22,1.23结果也是一样的。
golang在我们用+拼接字符串时都做了什么

表达式str1 + str2做了哪些操作,我相信会写go的人都能答出来:创建了一个新字符串然后把str1和str2的内容拼接进新字符串的内存空间里。
表达上也许会有些出入,但大部分书、教程、视频甚至是语言标准都是这么说的。
遗憾的是标准只能规定语言的行为,并不能规定实现这个行为使用的技术细节。而问题正是出现这个技术细节上。
如果要代入技术细节的话,上面对于字符串拼接的表述并不全对。因为go编译器为了优化性能做了一些额外的处理:将小字符串尽量分配在堆上。
具体是实现代码在runtime/string.go中:
  1. // The constant is known to the compiler.
  2. // There is no fundamental theory behind this number.
  3. const tmpStringBufSize = 32
  4. type tmpBuf [tmpStringBufSize]byte
  5. // concatstrings implements a Go string concatenation x+y+z+...
  6. // The operands are passed in the slice a.
  7. // If buf != nil, the compiler has determined that the result does not
  8. // escape the calling function, so the string data can be stored in buf
  9. // if small enough.
  10. func concatstrings(buf *tmpBuf, a []string) string {
  11.         idx := 0
  12.         l := 0
  13.         count := 0
  14.         for i, x := range a {
  15.                 n := len(x)
  16.                 if n == 0 {
  17.                         continue
  18.                 }
  19.                 if l+n < l { // 检测长度是否有整数溢出
  20.                         throw("string concatenation too long")
  21.                 }
  22.                 l += n
  23.                 count++
  24.                 idx = i
  25.         }
  26.         if count == 0 {
  27.                 return ""
  28.         }
  29.         // If there is just one string and either it is not on the stack
  30.         // or our result does not escape the calling frame (buf != nil),
  31.         // then we can return that string directly.
  32.         if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
  33.                 return a[idx]
  34.         }
  35.         s, b := rawstringtmp(buf, l)
  36.         for _, x := range a {
  37.                 n := copy(b, x)
  38.                 b = b[n:]
  39.         }
  40.         return s
  41. }
复制代码
这是负责实现字符串拼接的函数,它会先求出所有字符串拼接后的最终长度并顺手做了长度溢出检查,然后再调用rawstringtmp申请容纳新字符串的空间,最后把字符copy进内存里。所以rawstringtmp这个函数才是重头戏:
[code]func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {        if buf != nil && l
您需要登录后才可以回帖 登录 | 立即注册