二话不说,上demo

    1. const (
    2. FIRST = "WHAT THE"
    3. SECOND = "F*CK"
    4. )
    5. func main() {
    6. var s string
    7. go func() {
    8. i := 1
    9. for {
    10. i = 1 - i
    11. if i == 0 {
    12. s = FIRST
    13. } else {
    14. s = SECOND
    15. }
    16. time.Sleep(10)
    17. }
    18. }()
    19. for {
    20. if s != "WHAT THE" && s != "F*CK" && s != "" {
    21. fmt.Println(s)
    22. fmt.Println("--------------")
    23. return
    24. }
    25. fmt.Println(s)
    26. time.Sleep(10)
    27. }
    28. }

    执行结果:

    1. ....
    2. WHAT
    3. --------------
    1. ....
    2. F*CKGOGC
    3. --------------

    以下结果正常,if判断时,s是满足了条件的,只是打印时s正好赋值完毕,但还是进入了if

    1. WHAT THE
    2. --------------

    那么为什么 string 的并发读写会出现这种现象呢?
    这就得从 string 底层的数据结构说起了。在 go 的 reflect 包里有一个 type StringHeader ,对应的就是 string 在 go runtime的表示:

    1. type StringHeader struct {
    2. Data uintptr
    3. Len int
    4. }

    可以看到, string 由一个指针(指向字符串实际内容)和一个长度组成。

    比如说我们可以这么玩弄 StringHeader:

    1. s := "hello"
    2. p := *(*reflect.StringHeader)(unsafe.Pointer(&s))
    3. fmt.Println(p.Len)

    对于这样一个 struct ,golang 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(Data)、还没来得及修改长度(Len),goroutine 2 就读取了这个string 的情况。

    因此我们看到了 “WHAT” 这个输出 —— 这就是将 s 从 “F*CK” 改成 “WHAT THE” 时,Data 改了、Len 还没来得及改的情况(仍然等于4)。

    至于 “F*CKGOGC” 则正好相反,而且显然是出现了越界,只不过越界访问的地址仍然在进程可访问的地址空间里。

    越界不报错?
    难道是碰巧越界访问的地址仍然在进程可访问的地址空间里?再来demo,尝试加大两个字符串之间的长度差距

    1. const (
    2. FIRST = "WHAT THE 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
    3. SECOND = "F*CK"
    4. )
    5. func main() {
    6. var s string
    7. go func() {
    8. i := 1
    9. for {
    10. i = 1 - i
    11. if i == 0 {
    12. s = FIRST
    13. } else {
    14. s = SECOND
    15. }
    16. time.Sleep(10)
    17. }
    18. }()
    19. for {
    20. if s != "WHAT THE 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" && s != "F*CK" && s != "" {
    21. fmt.Println(s)
    22. fmt.Println("--------------")
    23. return
    24. }
    25. fmt.Println(s)
    26. time.Sleep(10)
    27. }
    28. }
    1. ......
    2. F*CKGOGCLisuMiaoModiNZDTNZSTNewaSASTThai
    3. m=] = ] n=avx2basebindbmi1bmi2boolcallcas1cas2cas3cas4cas5cas6chandeadermsfilefuncidleint8kindpipesbrksse2sse3t
    4. --------------

    额,没报错,编译器做了优化?或例子的长度差距不够?不得而知

    再用 go 的 race detector 瞅瞅:

    1. $ go run -race poc.go >/dev/null
    2. ==================
    3. WARNING: DATA RACE
    4. Write at 0x00c00010a1e0 by goroutine 7:
    5. main.main.func1()
    6. C:/Users/user/Desktop/SEGI/src/test/10.go:20 +0x6c // s = FIRST赋值那行
    7. Previous read at 0x00c00010a1e0 by main goroutine:
    8. main.main()
    9. C:/Users/user/Desktop/SEGI/src/test/10.go:29 +0x150 // if 判断那行,会做取值
    10. Goroutine 7 (running) created at:
    11. main.main()
    12. C:/Users/user/Desktop/SEGI/src/test/10.go:15 +0xa7
    13. ==================

    既然问题定位到了,解决起来就很简单了。最直接的方法是使用 sync.Mutex:

    1. var mu sync.RWMutex
    2. mu.Lock()
    3. s = FIRST
    4. mu.Unlock()

    性能更好的方案是将 s 类型改成 atomic.Value,然后

    1. var s atomic.Value
    2. s.Store(FIRST)

    实际上,Golang 不保证任何单独的操作是原子性的,除非使用 atomic 包里提供的原语或加锁

    总结一下:

    • string 没有看起来那么人畜无害
    • 并发的坑可以找 -race 帮帮忙
    • 记得使用 mutex 或 atomic

    参考:字节跳动踩坑记#3:Go服务灵异panic