记一次 Go 并发赋值问题
最近在排查一处业务故障时,遇到了一个奇怪的问题:在一个接口返回的 URL 字段值中,有概率在尾部多出一个 ","
。最初推测是序列化或字符串截取等方面的问题,但最后发现是并发赋值引起的。
现象
在实际代码中,该值的生成涉及了 ZooKeeper 等服务,还包含 map 和 slice 的并发排序。虽然稍显复杂,但问题可以简化为下面这个测试函数:
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
}
}
}
上面测试执行结果如下:
=== RUN TestAssign
[t t2]
Occured!
--- PASS: TestAssign (0.00s)
通过测试函数执行结果,我们发现了一个与预期不符的现象,即原始字符串通过 Split(",")
切割后,parts 中的元素是 ["t", "t2"]
,并不存在 "t,"
,但最后成功输出了 "Occured!"
,这是为什么呢?
分析
我们先简单过一下 strings.Split
的逻辑:
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 的内存结构:
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 的不一致,从而在某个时刻出现了数据溢出。
而上面的问题代码,可能在某个时刻出现下面这样的数据:
reflect.StringHeader{
Data: 0x12345678, // "t" 的头地址。
Len: 2, // "t2" 的长度。
}
原始数据部分是 "t,t2"
,因此在 "t"
长度多出一位时数据就变成了 "t,"
!
当然,上面的 Split 并非出现该问题的必须条件,再看下面代码:
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
}
}
}
执行结果如下:
=== RUN TestAssign2
The value is 't•'
--- PASS: TestAssign2 (0.00s)
可以看到,出现了和上面场景类似的问题,字符串 s
会在某个时刻溢出,输出错误的内存,导致乱码。
解法
知道了问题是因为非原子赋值产生的,那解决办法就很多了:加锁、atomic.Value、channel 去掉竞态,都解决该问题。这里简单列个通过 atomic 操作解决的代码:
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,一个爱好马拉松的老码农,本文来自我的公众号「兔子哥有话说」,欢迎关注!如需转载请注明来源及出处。
转载自:https://juejin.cn/post/7337959220749434880