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,就像一场宿舍搬家,大家要么排队、有序,要么闹成一锅粥。

相关推荐
青槿吖几秒前
Feign 微服务远程调用指南:告别手写 RestTemplate
java·redis·后端·spring·微服务·云原生·架构
神奇小汤圆1 分钟前
Linux 动态库 .so 工作原理,后端 / 嵌入式必看
后端
shy^-^cky4 分钟前
RESTful 中的状态转移方法
后端·restful
枕星而眠6 分钟前
C 语言结构体硬核总结:内存对齐、#pragma pack、位段、柔性数组(面试+工程双指南)
c语言·后端·面试·柔性数组
前端摸鱼匠7 分钟前
【AI大模型春招面试题22】层归一化(Layer Norm)与批归一化(Batch Norm)的区别?为何大模型更倾向于使用Layer Norm?
开发语言·人工智能·面试·求职招聘·batch
spring2997929 分钟前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
武子康11 分钟前
大数据-274 Spark MLib-决策树剪枝完全指南:预剪枝与后剪枝原理对比
大数据·后端·spark
SamDeepThinking13 分钟前
从DDD的仓储层反向依赖,理解DIP、IOC和DI
java·后端·架构
木斯佳15 分钟前
前端八股文面经大全:正泰电气前端实习一面(2026-04-19)·面经深度解析
前端·面试·笔试·校招·面经
前端摸鱼匠18 分钟前
【AI大模型春招面试题23】大模型的参数量、计算量如何计算?FLOPs与FLOPS的区别?
开发语言·人工智能·面试·求职招聘·batch