记一次 Go 并发赋值问题

最近在排查一处业务故障时,遇到了一个奇怪的问题:在一个接口返回的 URL 字段值中,有概率在尾部多出一个 ","。最初推测是序列化或字符串截取等方面的问题,但最后发现是并发赋值引起的。

现象

在实际代码中,该值的生成涉及了 ZooKeeper 等服务,还包含 map 和 slice 的并发排序。虽然稍显复杂,但问题可以简化为下面这个测试函数:

go 复制代码
func TestAssign(t *testing.T) {
  const input = "t,t2"
  parts := strings.Split(input, ",")
​
  var s string
​
  // 2 个 goroutine 模拟并发赋值。
  go func() {
    for {
      s = parts[0]
    }
  }()
​
  go func() {
    for {
      s = parts[1]
    }
  }()
​
  for {
    if s == "t," {
      fmt.Println("Occured!")
      break
    }
  }
}

上面测试执行结果如下:

diff 复制代码
=== RUN   TestAssign
[t t2]
Occured!
--- PASS: TestAssign (0.00s)

通过测试函数执行结果,我们发现了一个与预期不符的现象,即原始字符串通过 Split(",") 切割后,parts 中的元素是 ["t", "t2"],并不存在 "t,",但最后成功输出了 "Occured!",这是为什么呢?

分析

我们先简单过一下 strings.Split 的逻辑:

less 复制代码
func genSplit(s, sep string, sepSave, n int) []string {
  // 忽略部分代码。
  // ...
  
  for i < n {
    m := Index(s, sep)
    if m < 0 {
      break
    }
    a[i] = s[:m+sepSave] // 结果片段。
    s = s[m+len(sep):] 
    i++
  }
  a[i] = s
  return a[:i+1]
}

可以看到,strings.Split 的结果是通过在原始 string 的数据上进行切片的结果。这里我们有必要复习下 string 的内存结构:

go 复制代码
type StringHeader struct {
  Data uintptr // 数据部分的头指针。
  Len  int     // 字符串占用字节数。
}

string 的切片是通过移动 Data 指针和调整 Len 的值,创建了这样一个 StringHeader 结构,这里需要注意de的点是:Data 的指针是在原字符串内存空间上移动产生的。

Tips: 说到这里另外做个小提示,类似切出来的 string 或 slice 由于存在着原始数据的指针,都会导致原数据无法被垃圾回收,因此在部分场景下占用额外内存,可通过 copy()、strings.Clone()、slices.Clone() 等方式解决。

对应的,string 的赋值操作实际上对应了上面这个结构的赋值,该结构包含了 2 个指针长度的字段( int 长度与指针长度相等),超过了一个机器字长,涉及了两次存储指令。没有控制的并发赋值导致了 Data 指针和 Len 的不一致,从而在某个时刻出现了数据溢出。

而上面的问题代码,可能在某个时刻出现下面这样的数据:

perl 复制代码
  reflect.StringHeader{
    Data: 0x12345678, // "t" 的头地址。
    Len:  2,          // "t2" 的长度。
  }

原始数据部分是 "t,t2",因此在 "t"长度多出一位时数据就变成了 "t,"

当然,上面的 Split 并非出现该问题的必须条件,再看下面代码:

go 复制代码
​
func TestAssign2(t *testing.T) {
  parts := []string{"t", "t2"}
​
  s := parts[0]
​
  go func() {
    for {
      s = parts[0]
    }
  }()
​
  go func() {
    for {
      s = parts[1]
    }
  }()
​
  for {
    st := s
    if st != parts[0] && st != parts[1] {
      fmt.Printf("The value is '%s'\n", st)
      break
    }
  }
}

执行结果如下:

csharp 复制代码
=== RUN   TestAssign2
The value is 't•'
--- PASS: TestAssign2 (0.00s)

可以看到,出现了和上面场景类似的问题,字符串 s 会在某个时刻溢出,输出错误的内存,导致乱码。

解法

知道了问题是因为非原子赋值产生的,那解决办法就很多了:加锁、atomic.Value、channel 去掉竞态,都解决该问题。这里简单列个通过 atomic 操作解决的代码:

scss 复制代码
func TestAssignByAtomic(t *testing.T) {
  input := "t,t2"
  parts := strings.Split(input, ",")
​
  s := &parts[0]
​
  go func() {
    for {
      atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&s)), unsafe.Pointer(&parts[0]))
    }
  }()
​
  go func() {
    for {
      atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&s)), unsafe.Pointer(&parts[1]))
    }
  }()
​
  for {
    st := *s
    if st == "t," {
      fmt.Printf("The value is '%s'\n", st)
      break
    }
  }
}

总结

上面遇到的问题是对 stirng 的并发赋值导致的,实际上任何大于 1 个机器字长的赋值,都可能发生类似问题(另外这类操作还可能出现内存屏障相关问题):例如 interface 的赋值可能出现方法表和数据不一致。

这类并发赋值问题很容易被忽视,我们平时写或 Review 代码时都需要留个心眼。

我是 bunnier,一个爱好马拉松的老码农,本文来自我的公众号「兔子哥有话说」,欢迎关注!如需转载请注明来源及出处。

相关推荐
你的人类朋友2 小时前
✍️【Node.js程序员】的数据库【索引优化】指南
前端·javascript·后端
why技术6 小时前
翻译翻译,什么叫“编程专用”的显示器?
前端·后端
野生技术架构师7 小时前
SpringBoot集成Tess4j :低成本解锁OCR 图片识别能力
spring boot·后端·ocr
qqxhb7 小时前
零基础设计模式——行为型模式 - 命令模式
java·设计模式·go·命令模式
天天摸鱼的java工程师8 小时前
要在 Spring IoC 容器构建完毕之后执行一些逻辑,怎么实现
后端
程序猿小D8 小时前
第25节 Node.js 断言测试
后端·node.js·log4j·编辑器·vim·apache·restful
shengjk18 小时前
一文搞懂 TCP TCP/IP 和 TCP/IP网络分层之间的联系和区别
后端
述雾学java9 小时前
Spring Boot + Vue 前后端分离项目解决跨域问题详解
vue.js·spring boot·后端
酷爱码9 小时前
Spring Boot 中实现 HTTPS 加密通信及常见问题排查指南
spring boot·后端·https