记一次 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,一个爱好马拉松的老码农,本文来自我的公众号「兔子哥有话说」,欢迎关注!如需转载请注明来源及出处。

相关推荐
全栈派森44 分钟前
云存储最佳实践
后端·python·程序人生·flask
CircleMouse1 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
獨枭2 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架2 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱2 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
☞无能盖世♛逞何英雄☜2 小时前
Flask框架搭建
后端·python·flask
进击的雷神3 小时前
Perl语言深度考查:从文本处理到正则表达式的全面掌握
开发语言·后端·scala
进击的雷神3 小时前
Perl测试起步:从零到精通的完整指南
开发语言·后端·scala
豌豆花下猫4 小时前
Python 潮流周刊#102:微软裁员 Faster CPython 团队(摘要)
后端·python·ai
秋野酱4 小时前
基于javaweb的SpringBoot驾校预约学习系统设计与实现(源码+文档+部署讲解)
spring boot·后端·学习