原创分享 Go 的 append 操作是线程安全的吗

yudotyang · 2021年01月01日 · 最后由 cfanbo 回复于 2021年01月28日 · 279 次阅读

  根据 golang 中 slice 的数据结构可知,slice 依托数组实现,在底层数组容量充足时,append 操作不是只读操作,会将元素直接加入数组的空闲位置。因此,在多协程 对全局 slice 进行 append 操作时,会操作同一个底层数据,导致读写冲突。

  下面我将介绍两个对切片执行 append 操作的例子。一个是线程安全的,一个是线程不安全的。然后分析线程不安全产生的原因以及对应的解决方案。

线程安全的例子

package main

import (
  "sync"
  "fmt"
)

func main() {
    x := []string{"Start"} //初始化时,slice的容量为1
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
      defer wg.Done()
      y := append(x, "Hello", "World")
      fmt.Printf("y slice len:%d, cap:%d\n", len(y), cap(y))
    }()

    go func() {
        defer wg.Done()
        z := append(x, "Java", "Golang", "React")
        fmt.Printf("z len:%d, cap:%d\n", len(z), cap(z))
    }()
    wg.Wait()
}

在终端执行 go run -race main.go 命令运行程序,发现正常执行,不存在数据竞争。

线程不安全(数据竞争)的例子

package main

import (
  "fmt"
  "sync"
)

func main() {
    x := make([]string, 0, 6) //初始化时slice的容量为6
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
        defer wg.Done()
        y := append(x, "Hello", "World")
        fmt.Printf("y slice len:%d, cap:%d, value:%+v\n", len(y), cap(y), y)
    }()

    go func() {
        defer wg.Done()
        z := append(x, "Java", "Go", "React")
        fmt.Printf("z slice len:%d, cap:%d, value:%+v\n", len(z), cap(z), z)
    }()
    wg.Wait()
}

在终端执行 go run -race main.go 命令运行程序,发现提示 WARNING:DATA RACE,存在数据竞争。结果如下:

sh-3.2# go run -race main.go
y slice len:2, cap:6, value:[Hello World]
==================
WARNING: DATA RACE
Write at 0x00c0000b4120 by goroutine 8:
  main.TestAppendNotSafeThread.func2()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:89 +0xd7

Previous write at 0x00c0000b4120 by goroutine 7:
  main.TestAppendNotSafeThread.func1()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:83 +0xd7

Goroutine 8 (running) created at:
  main.TestAppendNotSafeThread()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:87 +0x12c
  main.main()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f

Goroutine 7 (finished) created at:
  main.TestAppendNotSafeThread()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:81 +0xee
  main.main()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f
==================
==================
WARNING: DATA RACE
Write at 0x00c0000b4130 by goroutine 8:
  main.TestAppendNotSafeThread.func2()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:89 +0x145

Previous write at 0x00c0000b4130 by goroutine 7:
  main.TestAppendNotSafeThread.func1()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:83 +0x12c

Goroutine 8 (running) created at:
  main.TestAppendNotSafeThread()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:87 +0x12c
  main.main()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f

Goroutine 7 (finished) created at:
  main.TestAppendNotSafeThread()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:81 +0xee
  main.main()
      /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f
==================
z slice len:3, cap:6, value:[Java Go React]
Found 2 data race(s)
exit status 66

根因分析

在分析根因之前,我们先来看下 slice 的数据结构

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

从结构上看 slice 很清晰,array 指针指向底层数组,len 标识切片长度,cap 表示底层数组容量. 例如,slice := make([] int, 4, 8) 语句所创建的 slice 数据结构如下图所示: 了解了 slice 的底层结构,我们看两个例子的不同之处,在于初始化 slice 时的容量。线程安全的例子中,x := [] string{"start"} 的容量为 1,在 append 操作时,会自动分配新的内存空间,故不存在数据竞争关系。如下图: 线程不安全的例子中,x := make([] string, 0, 6) 的容量为 6。这里执行 append 操作时,Go 注意到有空闲空间可以存放 “Hello”, “World” 等新的元素,而另一个协程也注意到有空间可以存放 “Java”, “Go”,“React” 等新的元素,这时两个协程同时试图往同一块空闲空间中写入数据,竞争就出现了。最终谁胜出也就存在不确定性。如下图: 这是 append 的一个特点,而非 bug。当每次调用 append 操作时,不用每次都关注是否需要分配新的内存。优势是,允许用户在循环内追加,而无需破坏垃圾回收。缺点是,开发者必须意识到,当多个 goroutine 中的同一个原始切片被操作时,会存在线程不安全风险。 ## 解决方案 最简单的解决方法是不使用多个切片操作同一个数组,以防止读写冲突。相反,创建一个具有所需总容量的新切片,并将新切片用作要追加的第一个变量。

package main

import (
  "fmt"
  "sync"
)

func main() {
    x := make([]string, 0, 6)
    wg := sync.WaitGroup{}
    wg.Add(2)

    go func() {
        defer wg.Done()
        y := make([]string, 0, len(x) + 2) //分配新的内存
        y = append(y, x...)
        y = append(y, "Hello", "World", "!")
        fmt.Printf("y slice len:%d, cap:%d, value:%+v\n", len(y), cap(y), y)
    }()

    go func() {
        defer wg.Done()
        z := make([]string, 0, len(x) + 2) //分配新的内存
        z = append(z, x...)
        z = append(z, "PHP", "Go", "Java")
        fmt.Printf("z slice len:%d, cap:%d, value:%+v\n", len(z), cap(z), z)
    }()
    wg.Wait()
}

切片扩容基本规则

这里引用《Go 专家编程》里面的基本扩容原则

1、 如果原slice的容量小于1024,则新slie的容量将扩大为原来的2倍 
2 、如果原slice的容量大于或等于1024,则新slice的容量将扩大为原来的1.25倍 

在该规则的基础上,还会考虑元素类型与内存分配规则,对实际扩张值做一些微调。从这个规则中可以看出 Go 对 slice 的性能和空间使用率的思考。

  • 1、当切片较小时,采用较大的扩容倍速,可以避免频繁地扩容,从而减少内存分配的
  • 2、次数和数据拷贝的代价 当切片较大时,采用较小的扩容倍速,主要是为了避免浪费空间。
更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio

有没有一个比较,不扩容的情况下,比扩容重新分配地址的切片单次添加快了多少😃 ,先感谢大神分析一下

主题里的称之为并发安全,其实只能说凑巧了没有发生 data race,但最终还是不安全的,这一点应该死记才行。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册