likes
comments
collection
share

记一次 Go 并发赋值问题

作者站长头像
站长
· 阅读数 6

最近在排查一处业务故障时,遇到了一个奇怪的问题:在一个接口返回的 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,一个爱好马拉松的老码农,本文来自我的公众号「兔子哥有话说」,欢迎关注!如需转载请注明来源及出处。