Go语言中slice的扩容与并发安全

在 Go 语言的日常开发中,slice 基本上就是"家常便饭"。可问题来了:当你在多线程(goroutine)下对同一个 sliceappend 时,它真的安全吗?

短答案:不安全。 长答案:且听我慢慢道来。

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 和底层数组,不是并发安全的

扩容机制让情况更复杂:有的人在旧房子,有的人在新房子。

想要并发安全,可以选择:

  1. sync.Mutex ------ 保姆式保护。
  2. channel ------ Go 风格排队。
  3. 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,就像一场宿舍搬家,大家要么排队、有序,要么闹成一锅粥。

相关推荐
canonical_entropy1 天前
AI时代,我们还需要低代码吗?—— 一场关于模型、演化与软件未来的深度问答
后端·低代码·aigc
颜如玉1 天前
HikariCP:Dead code elimination优化
后端·性能优化·源码
考虑考虑1 天前
Jpa使用union all
java·spring boot·后端
bobz9651 天前
virtio vs vfio
后端
天天扭码1 天前
来全面地review一下Flex布局(面试可用)
前端·css·面试
Rexi1 天前
“Controller→Service→DAO”三层架构
后端
Mor_1 天前
UE5 网络通信协议学习笔记
面试
bobz9651 天前
计算虚拟化的设计
后端
沐怡旸1 天前
【底层机制】std::unique_ptr 解决的痛点?是什么?如何实现?怎么正确使用?
c++·面试
前端缘梦1 天前
Vue Keep-Alive 组件详解:优化性能与保留组件状态的终极指南
前端·vue.js·面试