在 Go 语言的日常开发中,slice
基本上就是"家常便饭"。可问题来了:当你在多线程(goroutine)下对同一个 slice
做 append
时,它真的安全吗?
短答案:不安全。 长答案:且听我慢慢道来。
1. Slice 的真面目
slice
在 Go 底层,其实就是这么一个小 struct:
go
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 当前长度
cap int // 容量
}
- array:真正存放数据的数组指针。
- len:当前有多少元素。
- cap:底层数组最多能装多少。
所以 slice
就像一个"搬家租户":
- len:家里住了几口人。
- cap:房子最多能住几口人。
- array:房子地址。
2. append 并不简单
append
的过程,其实是这样的:
1)看看房子里(cap)还能不能再塞一个人。
- 能塞?那就
len++
,放进去。 - 不能塞?那就换大房子!(扩容)
2)换大房子的时候:
- 买一个更大的新房子。
- 把旧房子的家具(老数据)全搬过去。
- 把新住户塞进新房子。
- 最后更新租户手里的"房产证"(slice 头部)。
这就像你宿舍太挤了,宿管阿姨给你换了个大寝室,结果你舍友还在旧宿舍搬床,另一个人已经躺到新宿舍的床上了------尴尬不?
3. 为什么 append 并发不安全?
情况 A:没扩容
多个 goroutine 同时 append
,都会修改 len
。 举个例子:
- goroutine1 读到
len = 5
,打算写s[5]
。 - goroutine2 也读到
len = 5
,它也写s[5]
。
结果?两个人挤同一张床,最后还只算一个人入住。
数据覆盖,len 错乱。
情况 B:触发扩容
事情更刺激了:
- goroutine1 发现房子不够大,买了个大房子(新数组),家具都搬过去了。
- goroutine2 还在旧房子刷墙(往旧数组里写数据)。
结果:
- 有的人住进了新宿舍。
- 有的人还留在旧宿舍。
- 一点也不温馨,数据直接丢失。
4. 现场演示:不安全的 append
go
package main
import (
"fmt"
"sync"
)
func main() {
s := []int{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(v int) {
defer wg.Done()
s = append(s, v) // 并发 append
}(i)
}
wg.Wait()
fmt.Println("slice 长度:", len(s)) // < 1000,不确定
}
运行时:
- 有时长度 < 1000。
- 有时直接 panic。
- 加上
-race
参数跑,更能看到一堆红色警告。
5. 如何解决这场"宿舍闹剧"?
方案一:加锁(宿管阿姨看门)
go
var mu sync.Mutex
s := []int{}
mu.Lock()
s = append(s, v)
mu.Unlock()
简单粗暴,保证一个一个来,绝对安全。
方案二:用 channel 串行化(排队进宿舍)
go
ch := make(chan int, 1000)
s := []int{}
go func() {
for v := range ch {
s = append(s, v)
}
}()
ch <- 1
ch <- 2
// ...
大家排队交钥匙,唯一的搬家工负责 append
,高效又优雅。
方案三:atomic + 预分配(提前买好大别墅)
go
var idx int64
s := make([]int, 1000)
pos := atomic.AddInt64(&idx, 1) - 1
s[pos] = v
适合已知人数的场景,大家直接去各自的房间,互不干扰。
6. 总结
append
本质上修改了 len
和底层数组,不是并发安全的。
扩容机制让情况更复杂:有的人在旧房子,有的人在新房子。
想要并发安全,可以选择:
sync.Mutex
------ 保姆式保护。channel
------ Go 风格排队。atomic
+ 预分配 ------ 高性能方案。
7. 小总结
ini
┌─────────────┐
│ slice │
│ len = 3 │
│ cap = 4 │
│ array ---> [0][1][2][_]
└─────────────┘
goroutine1 append: len = 4, array[3] = X
goroutine2 append: len = 4, array[3] = Y
结果:X 或 Y 被覆盖
再来扩容场景:
css
旧数组: [0][1][2]
↑ goroutine2 还在写
新数组: [0][1][2][_][_][_]
↑ goroutine1 已经搬过去
最终:数据丢失,互相看不见。
✍️ 写到这里,你应该能理解: Go 的 slice 扩容和 append,就像一场宿舍搬家,大家要么排队、有序,要么闹成一锅粥。