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

相关推荐
ftpeak4 分钟前
Rust Web开发指南 第六章(动态网页模板技术-MiniJinja速成教程)
开发语言·前端·后端·rust·web
编码浪子10 分钟前
趣味学Rust基础篇(数据类型)
开发语言·后端·rust
南囝coding22 分钟前
Claude Code 官方内部团队最佳实践!
前端·后端·程序员
IT_陈寒1 小时前
Python性能优化必知必会:7个让代码快3倍的底层技巧与实战案例
前端·人工智能·后端
你我约定有三1 小时前
面试tips--JVM(3)--类加载过程
jvm·面试·职场和发展
拾忆,想起2 小时前
Redis发布订阅:实时消息系统的极简解决方案
java·开发语言·数据库·redis·后端·缓存·性能优化
SimonKing2 小时前
想搭建知识库?Dify、MaxKB、Pandawiki 到底哪家强?
java·后端·程序员
程序员清风2 小时前
为什么Tomcat可以把线程数设置为200,而不是2N?
java·后端·面试
MrSYJ2 小时前
nimbus-jose-jwt你都会吗?
java·后端·微服务
Bug生产工厂3 小时前
AI 驱动支付路由(下篇):代码实践与系统优化
后端