[go: up one dir, main page]

Go Slice 奇技淫巧
Go Slice 奇技淫巧
•约 11.3 千字

Go 的切片 (slice) 很容易学:len 是长度,cap 是容量,不够就 append。这三句话足够写完大多数业务代码,也足够埋下不少只有在线上才肯露头的问题。

把一个切片赋值给另一个变量,改其中一个,另一个为什么也会变?同样是 append,为什么有时修改原数据,有时又像复制了一份?从大文件中只截取十几个字节,为什么几百 MB 内存还没有释放?两个看起来内容完全相同的空切片,为什么一个等于 nil,一个却不等于?[]byte(string) 是否一定分配?能不能把字符串「零拷贝」转成字节切片?

本文内容早已过时,考古请参考 Go 1.16

一、切片不是数组

runtime 内部用下面这个结构表示切片:

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

这不是可以从业务代码直接引用的公开类型,只是 runtime 源码中的实现。它在 64 位平台上通常占 24 字节:一个 8 字节指针,加两个 8 字节的 int;在 32 位平台上通常是 12 字节。不要把 24 写进协议、文件格式或网络报文,语言规范没有承诺切片头的内存布局,更没有承诺 int 永远是 64 位。

三个字段分别表示:

  • array 指向当前可见区域的第一个元素,而不一定指向原始数组的第一个元素;
  • len 表示当前可以通过下标访问的元素数量,合法下标是 [0, len);
  • cap 表示从 array 开始,到底层数组末尾还有多少个元素位置,始终满足 0 <= len <= cap。

数组 [N]T 则是一个完整的值,长度是类型的一部分。[3]int 和 [4]int 是不同类型,赋值数组会复制全部元素:

go
package main

import "fmt"

func main() {
    a := [3]int{10, 20, 30}
    b := a
    b[0] = 99

    fmt.Println(a) // [10 20 30]
    fmt.Println(b) // [99 20 30]
}

切片赋值只复制切片头:

go
a := []int{10, 20, 30}
b := a
b[0] = 99

fmt.Println(a) // [99 20 30]
fmt.Println(b) // [99 20 30]

此时 a 和 b 是两张独立的小票,但两张小票上的 array 指向同一块底层数组。修改 b[0],实际改的是共享数组里的元素。之后若只给 b 做重切片,a 的 len 不会跟着变化,因为 len 位于各自的切片头中:

go
a := []int{10, 20, 30}
b := a[:1]

fmt.Println(len(a), len(b)) // 3 1
b[0] = 99
fmt.Println(a, b)           // [99 20 30] [99]

这也解释了为什么函数按值接收切片仍能修改元素,却不能直接替调用者更换切片头:

go
func changeElement(s []int) {
    s[0] = 42 // 修改共享底层数组
}

func growLocally(s []int) {
    s = append(s, 42) // 只把函数内那份切片头赋成新值
}

func growForCaller(s []int) []int {
    return append(s, 42)
}

所以 append 的结果必须接住。最普通也最正确的写法仍是:

go
s = append(s, value)

如果函数确实要替调用方改变长度,可以返回新切片;只有 API 形态要求原地修改切片变量时,才考虑 *[]T。大多数情况下,返回值比「指向切片的指针」更清楚。

二、零值、空切片和「看起来什么都没有」

下面三种写法的长度与容量都是 0,但语义并不完全相同:

go
var a []int
b := []int{}
c := make([]int, 0)

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false

切片只能与 nil 比较,不能用 a == b 比较两个切片。原因不只是实现麻烦:切片是可变视图,元素本身还可能不可比较。若要比较内容,可以手写循环,或在测试、低频路径里谨慎使用 reflect.DeepEqual。后者会区分 nil 切片与非 nil 空切片,并不总符合业务上的「内容相等」。

nil 切片的零值很好用:len 和 cap 都是 0,可以 range,可以 append,也可以作为 copy 的源或目标。除非接口协议要求区分「未提供」与「提供了空集合」,我通常优先保留 nil 切片,不为了形式统一到处 make([]T, 0)。

边界差异经常出现在序列化中。encoding/json 默认把 nil 切片编码为 null,把非 nil 空切片编码为 []:

go
type Response struct {
    Items []string `json:"items"`
}

// Response{}                  -> {"items":null}
// Response{Items: []string{}} -> {"items":[]}

如果前端或协议严格要求数组,构造响应时就应明确初始化。反过来,如果 null 代表「尚未加载」,就不能用一个顺手的空切片把状态抹平。这里不是谁更高级,而是要先确定协议语义。

还要留意零大小元素:

go
s := make([]struct{}, 0)
for i := 0; i < 1_000_000; i++ {
    s = append(s, struct{}{})
}

struct{} 的大小为 0。growslice 对它有专门处理,不需要为一百万个空结构体分配一百万份元素存储。它适合做集合的占位值,例如 map[string]struct{}。不过切片头、长度运算和循环仍然存在,零大小不等于零成本。

三、make 创建切片

创建切片最常见的方式有三种:

go
var zero []int             // nil,len=0,cap=0
literal := []int{1, 2, 3} // len=3,cap=3
fixed := make([]int, 3)   // len=3,cap=3,元素已经是 0
room := make([]int, 0, 3) // len=0,cap=3,预留空间

make([]T, length, capacity) 的第二个参数是长度,不是「预估要放多少元素」。这是一个非常常见的坑:

go
wrong := make([]int, 3)
wrong = append(wrong, 1, 2, 3)
fmt.Println(wrong) // [0 0 0 1 2 3]

right := make([]int, 0, 3)
right = append(right, 1, 2, 3)
fmt.Println(right) // [1 2 3]

需要按下标填满结果时,直接创建目标长度:

go
out := make([]Result, len(input))
for i := range input {
    out[i] = transform(input[i])
}

需要逐个追加时,长度为 0,容量用预估值:

go
out := make([]Result, 0, len(input))
for _, item := range input {
    if keep(item) {
        out = append(out, transform(item))
    }
}

容量提示不是硬性合同。runtime 可能因为分配器粒度给出略大的实际容量。业务逻辑不应依赖某次 make 或 append 后 cap 的精确值,只应依赖 cap >= len,以及容量足够时 append 可以复用底层数组这一语义。

预分配也不是越大越好。过小会增加扩容和复制,过大则立刻占用内存,还可能让包含指针的底层数组增加垃圾回收器的扫描工作。比较稳妥的做法是使用可信的上界或近期统计值,而不是为了「一次不扩容」预留一个离谱上限。

四、截取:只改视野,不搬数据

对于数组、数组指针或切片,简单切片表达式是:

go
next := s[low:high]

结果长度是 high - low。若操作数是切片,Go 规范允许 high 到原切片的 cap,不只到 len。这意味着可以先缩短,再在容量范围内扩回来:

go
s := make([]int, 3, 5)
s[0], s[1], s[2] = 10, 20, 30

t := s[:1]
fmt.Println(len(t), cap(t)) // 1 5

t = t[:3]
fmt.Println(t) // [10 20 30]

但是不能直接读取 t[1]。下标检查使用 len,重切片的上界才可以使用 cap。这两个边界服务不同目的:len 决定现在有哪些元素可访问,cap 决定这张视图最多还能向后扩到哪里。

省略规则也很直观:

go
s[2:]  // s[2:len(s)]
s[:3]  // s[0:3]
s[:]   // s[0:len(s)]

截取后起始指针会移动,容量也相应减少。假设 len(s)=5、cap(s)=8,那么 t := s[2:4] 得到 len(t)=2、cap(t)=6。t[0] 与 s[2] 是同一个元素。

三下标切片

完整切片表达式写作:

go
t := s[low:high:max]

它的长度是 high-low,容量是 max-low,且必须满足:

text
0 <= low <= high <= max <= cap(s)

字符串不能使用三下标切片。对切片、数组和数组指针则可以。这个语法最实用的用途是限制子切片继续覆盖原数组:

go
func addFooter(payload []byte) []byte {
    return append(payload, '\n')
}

buf := []byte{'A', 'B', 'C', 'X', 'Y'}

view := buf[:3]       // len=3,cap=5
got := addFooter(view) // 复用 buf,覆盖 buf[3]
fmt.Printf("%q\n", buf) // "ABC\nY"

如果调用者不希望 addFooter 越过 view 的长度修改后续元素,可以把容量钳到长度:

go
buf := []byte{'A', 'B', 'C', 'X', 'Y'}
view := buf[:3:3]      // len=3,cap=3
got := addFooter(view) // 必须扩容,got 使用新数组

fmt.Printf("%q\n", buf) // "ABCXY"
fmt.Printf("%q\n", got) // "ABC\n"

s[:len(s):len(s)] 常被称为 capacity clipping。它不立刻复制,也不让元素只读;现有范围内仍与原切片共享。它只是让下一次增长必然换底层数组。把一个只应观察当前窗口的切片交给不受控函数时,这比口头约定「不要覆盖后面的数据」可靠。

截取背后丢失的内存

截取不会复制,所以一个很小的切片也可能持有一个很大的底层数组:

go
func prefix(path string) ([]byte, error) {
    data, err := ioutil.ReadFile(path)
    if err != nil {
        return nil, err
    }
    return data[:16], nil
}

调用者只拿到 16 字节,但这 16 字节仍指向整块文件数据,垃圾回收器不能释放那块底层数组。若返回值会长期保存,应主动复制:

go
func prefix(path string) ([]byte, error) {
    data, err := ioutil.ReadFile(path)
    if err != nil {
        return nil, err
    }
    if len(data) < 16 {
        return nil, io.ErrUnexpectedEOF
    }

    out := make([]byte, 16)
    copy(out, data[:16])
    return out, nil
}

克隆一个字节切片,可以借助 append:

go
clone := append([]byte(nil), src...)

另一个更明确的版本是 make 加 copy:

go
clone := make([]byte, len(src))
copy(clone, src)

两种方式都是浅拷贝。如果元素是指针、map、切片或含引用的结构体,复制的只是这些引用值,深层对象仍共享。

五、append 的双重人格

append 的规则并不神秘:新长度不超过旧容量时,通常复用原底层数组;超过容量时,分配更大的底层数组,复制旧元素,再写入新元素。麻烦之处在于调用方若只看内容,往往不知道自己正在共享还是分家。

go
base := make([]int, 2, 4)
base[0], base[1] = 1, 2

a := append(base, 3)
b := append(base, 4)

fmt.Println(a) // 很可能是 [1 2 4]
fmt.Println(b) // [1 2 4]

第一次 append 把 3 写到底层数组的第三格;第二次仍从 base 的长度 2 开始追加,又把同一格改成 4。a 和 b 的切片头各自记录长度 3,却共享数组,于是 a 的内容也变了。

如果需要从同一个前缀派生互不影响的结果,先复制:

go
a := append(append([]int(nil), base...), 3)
b := append(append([]int(nil), base...), 4)

或者限制容量,让各次追加都触发分配:

go
prefix := base[:len(base):len(base)]
a := append(prefix, 3)
b := append(prefix, 4)

前一种表达的是「我要独立副本」,后一种表达的是「当前元素仍共享,但增长必须分离」。两者看似都解决例子,语义并不相同。

追加自己与重叠输入

规范保证 append 可以正确处理源和目标重叠,因此下面的写法合法:

go
s := []int{1, 2, 3}
s = append(s, s...)
fmt.Println(s) // [1 2 3 1 2 3]

把字符串追加到字节切片也有专门语法:

go
buf := make([]byte, 0, 32)
buf = append(buf, "hello"...)

这里不需要先写 []byte("hello")。在构造协议、日志行或短文本时,它既简洁,也给编译器更多优化空间。

为什么必须使用返回值

即使容量足够,append 返回的新切片头仍有更大的 len;即使发生扩容,返回值还会携带新的 array 与 cap。丢弃返回值就等于丢弃对这些变化的正式描述:

go
func bad(s []int) {
    append(s, 1) // 编译器会直接报:结果未使用
}

Go 干脆不允许把 append 当作无返回值语句。这个限制很贴心,它挡住了一类「底层也许写了,长度却没有变」的半成功操作。

六、切片怎样扩容

扩容策略是实现细节,不是语言规范。写性能敏感代码时可以理解 runtime 的实现,但不能把某个容量序列当成 API。

runtime.growslice 接收元素类型、旧切片和所需的最小新容量。选择目标容量的核心逻辑可以简化为:

go
newcap := old.cap
doublecap := newcap + newcap

if required > doublecap {
    newcap = required
} else if old.cap < 1024 {
    newcap = doublecap
} else {
    for newcap > 0 && newcap < required {
        newcap += newcap / 4
    }
    if newcap <= 0 {
        newcap = required
    }
}

可以读成三条:

  1. 一次追加所需容量超过旧容量两倍,直接以所需容量为候选值,避免反复扩;
  2. 旧容量小于 1024 时,候选容量通常翻倍;
  3. 旧容量达到 1024 后,每轮候选容量增加约 25%,直到容得下。

这仍不是最终的 cap。runtime 还会计算「元素大小 × 候选容量」所需字节数,并通过 roundupsize 向内存分配器可用的 size class 对齐,再反算元素数量。因此 []byte、[]int64 和一个 24 字节结构体,即便旧容量相同,扩容后的实际容量也可能不同。

runtime 还针对元素大小为 1、指针大小、2 的幂等情况做了计算特化。元素含不含指针也会影响分配与清零:不含指针的内存可以走不需要 GC 扫描的路径;含指针的内存必须正确清零并配合写屏障,不能让垃圾回收器把未初始化位误认成指针。

旧元素随后通过 memmove 复制到新数组。也就是说,扩容不是凭空把房子变大,而是租新房、搬旧货、交回旧钥匙。若切片增长到很大,某一次 append 会承担与旧长度大致成正比的复制成本。摊还分析下连续追加仍通常是 O(1),但单次延迟并非 O(1)。对尾延迟敏感的路径,预分配比背口诀有用。

下面的程序可以观察容量变化,但输出只能用于实验,不能写进业务判断:

go
package main

import "fmt"

func main() {
    var s []int
    last := cap(s)

    for i := 0; i < 3000; i++ {
        s = append(s, i)
        if cap(s) != last {
            fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
            last = cap(s)
        }
    }
}

不要写出这种代码:

go
if cap(s) == 2048 {
    // 假设下一次一定增长到某个固定数字
}

升级 Go、改变元素类型、切换架构,都可能让它失效。真正需要固定块大小时,自己定义块、池或分段结构,不要借 runtime 的扩容策略冒充业务协议。

七、copy 的用途

内建函数 copy(dst, src) 返回复制的元素数量,即 min(len(dst), len(src))。它以长度为边界,不看目标剩余容量:

go
dst := make([]int, 2, 10)
n := copy(dst, []int{1, 2, 3, 4})

fmt.Println(n)   // 2
fmt.Println(dst) // [1 2]

若想复制 4 个元素,先把目标长度设为 4,而不是只给容量:

go
dst := make([]int, 4, 10)
copy(dst, []int{1, 2, 3, 4})

copy 保证正确处理重叠区域,像 Go 版本的 memmove。这使它特别适合在切片内部移动元素。

删除一个元素

不保持顺序,O(1) 删除:

go
func removeUnordered(s []int, i int) []int {
    s[i] = s[len(s)-1]
    return s[:len(s)-1]
}

保持顺序,移动后续元素:

go
func removeOrdered(s []int, i int) []int {
    copy(s[i:], s[i+1:])
    return s[:len(s)-1]
}

如果元素含指针或引用,最好把被丢弃的尾元素清零,否则底层数组仍可能持有对象引用:

go
func removeStringPtr(s []*string, i int) []*string {
    copy(s[i:], s[i+1:])
    s[len(s)-1] = nil
    return s[:len(s)-1]
}

对于结构体切片,可以声明零值后赋回尾部:

go
var zero Item
s[len(s)-1] = zero
s = s[:len(s)-1]

清零不是为了让 len 正确,而是为了断开底层数组中已经无用的引用。当这个大容量切片仍长期存活时,区别可能很大。

删除一段

保持顺序删除 [i:j):

go
copy(s[i:], s[j:])
newLen := len(s) - (j - i)

for k := newLen; k < len(s); k++ {
    s[k] = nil // 仅适用于指针元素;其他类型写对应零值
}
s = s[:newLen]

如果元素是纯数值且不会间接引用大对象,可以省略清零以节省写入。若这段代码会成为热点,再用基准测试判断,别凭感觉把安全清理删掉。

原地插入

在位置 i 插入一个元素:

go
s = append(s, 0)     // 先让长度增加 1,可能扩容
copy(s[i+1:], s[i:]) // 重叠移动由 copy 保证
s[i] = value

插入一段 values:

go
oldLen := len(s)
s = append(s, make([]int, len(values))...)
copy(s[i+len(values):], s[i:oldLen])
copy(s[i:], values)

若 values 与 s 共享底层数组,插入过程中的扩容和移动会让推理复杂起来。通用库代码应明确测试重叠场景,或者先复制 values。业务代码若能证明二者独立,也应写下注释说明前提。

八、过滤、去重与复用底层数组

原地过滤是切片最实用的技巧之一:

go
out := s[:0]
for _, x := range s {
    if keep(x) {
        out = append(out, x)
    }
}
s = out

s[:0] 长度归零但保留容量,随后 append 从原底层数组头部写入。读指针向前走,写指针不会跑到读指针前面,所以普通过滤是安全的。这避免了新分配,代价是输入内容被覆盖,且结果继续占着原数组。

若元素持有引用,过滤后也应清理尾巴:

go
old := s
out := s[:0]
for _, x := range s {
    if keep(x) {
        out = append(out, x)
    }
}
for i := len(out); i < len(old); i++ {
    old[i] = nil
}
s = out

这种写法适合明确拥有该切片的代码。如果其他地方还保留着同一底层数组的视图,原地过滤会改变它看到的数据。所谓「零分配优化」只是把成本从分配器转移到别名管理,不是免费午餐。

有序切片原地去重也类似:

go
func uniqueSorted(s []int) []int {
    if len(s) < 2 {
        return s
    }

    n := 1
    for _, x := range s[1:] {
        if x != s[n-1] {
            s[n] = x
            n++
        }
    }
    return s[:n]
}

它要求输入已经排序,并会修改原数组。若调用者还需要原始数据,先复制再去重。API 的名字和注释最好把 in-place 语义写出来,否则省下的一次分配很可能换来一次排查事故。

九、数组与切片

从可寻址数组得到切片是语言原生操作:

go
a := [5]int{1, 2, 3, 4, 5}
s := a[1:4]

fmt.Println(s)      // [2 3 4]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4

s 与 a 共享元素,s[0] = 99 会修改 a[1]。数组必须可寻址,所以不能直接切一个不可寻址的数组返回值;先赋给变量即可。

从数组指针切片同样合法,p[low:high] 相当于 (*p)[low:high]:

go
p := &[4]byte{'G', 'o', '1', '6'}
s := p[:]

反方向不能直接转换。若要调用要求数组指针的底层代码,可以通过 unsafe.Pointer 强行转换:

go
func first16(s []byte) *[16]byte {
    if len(s) < 16 {
        panic("short slice")
    }
    return (*[16]byte)(unsafe.Pointer(&s[0]))
}

这段代码不复制,返回的数组指针与切片共享内存。长度检查必须自己做;&s[0] 在空切片上会先 panic;数组长度写错会让后续索引进入原切片范围之外,类型系统和边界检查也救不了你。返回指针会让相关内存保持可达,但不要把它指向短命的外部内存、已经释放的 C 缓冲区或可能被别处并发修改的区域。

如果目标只是获得独立数组,最稳妥的写法仍然是复制:

go
func to16(s []byte) ([16]byte, error) {
    var a [16]byte
    if len(s) < len(a) {
        return a, io.ErrUnexpectedEOF
    }
    copy(a[:], s[:len(a)])
    return a, nil
}

十几或几十字节的复制通常比一份脆弱的生命周期契约便宜。只有性能分析证明复制是瓶颈,且 API 边界能严格控制时,unsafe 数组视图才有讨论价值。

用数组指针减少边界检查

固定长度协议常会出现连续下标访问:

go
func decodeHeader(b []byte) uint32 {
    _ = b[3] // 提前证明后续 0..3 都在范围内
    return uint32(b[0])<<24 |
        uint32(b[1])<<16 |
        uint32(b[2])<<8 |
        uint32(b[3])
}

_ = b[3] 是一种边界检查消除(Bounds Check Elimination,BCE)提示:只要索引 3 合法,更小的常量索引也合法。它不依赖 unsafe,长度不足仍会在明确位置 panic。用 go build -gcflags='-d=ssa/check_bce/debug=1' 可以观察编译器没有消除的检查;具体输出属于编译器实现细节,不应作为程序逻辑。

不要为了省几个边界检查就把整个解析器变成数组指针杂技。先写清楚长度验证,再看编译器能否证明;最后才是基准测试。

十、字符串与切片

字符串是只读字节序列,不是 rune 数组。len(str) 返回字节数,str[i] 得到第 i 个字节;range 字符串才会按 UTF-8 解码得到 rune 和该 rune 的起始字节下标。

go
s := "Go语言"
fmt.Println(len(s)) // 8:2 个 ASCII 字节 + 2 个三字节汉字

for i, r := range s {
    fmt.Printf("%d %c\n", i, r)
}

安全转换很直接:

go
b := []byte(s) // 得到可修改的字节切片
r := []rune(s) // UTF-8 解码为 Unicode 码点切片

s1 := string(b)
s2 := string(r)

从语义上看,字符串和 []byte 的转换得到独立、可安全修改的值。编译器可能在能够证明不逃逸且不被修改的特定上下文里消除临时分配,但程序不能依赖这个优化,更不能因为某次基准测试显示 0 allocs/op 就开始修改本应只读的存储。

如果只是比较,不必手动转换:

go
if bytes.Equal(buf, []byte(text)) {
    // 这可能制造临时转换;具体是否分配看上下文和编译器
}

Go 允许直接把 []byte 转成 string 用作 map 查询,编译器也对一些临时场景做优化。无论如何,先写可读代码,再用 go test -bench . -benchmem 量实际热点。不要用一次全局性的危险零拷贝替代局部、可验证的优化。

字符串截取也可能保留大内存

字符串切片 s[low:high] 按字节截取,并且不能使用三下标。实现通常会共享原字符串数据,因此从一个巨大字符串取很小子串并长期保存,也可能让整块数据继续存活。要得到独立字符串,可以借助复制:

go
small := string([]byte(huge[begin:end]))

这会经过字节切片并构造新字符串,表达「我要独立存储」。是否值得复制取决于大小和生命周期:立即使用的子串无需多此一举,缓存数小时的十几个字节则值得切断对百 MB 数据的引用。

[]rune 不是万能的字符数组

把字符串转为 []rune 便于按 Unicode 码点处理,但「用户看到的一个字符」可能由多个码点组成,例如基本字符加组合附加符,或由多个码点构成的 emoji。[]rune 能避免从 UTF-8 中间截断,却不能自动解决字形簇、规范化、区域旗帜和肤色修饰等问题。界面层若按视觉字符计数,需要专门的 Unicode 分段实现,不能把 rune 数量冒充字符数量。

十一、unsafe 零拷贝转换

下面进入真正的高危区。常见的零拷贝做法依赖 reflect.StringHeader 与 reflect.SliceHeader:

go
type StringHeader struct {
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

这两个公开结构是为了帮助理解和与底层交互,不是一份鼓励随便拼装引用的许可证。它们的 Data 是 uintptr,而 uintptr 只是整数,不会让垃圾回收器认为目标对象仍然可达。把 Data 单独保存起来,跨越函数调用、分配或其他安全点再转回指针,可能产生悬空引用。

[]byte 零拷贝转为 string

一种常见写法如下:

go
func bytesToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

切片头的前两个机器字恰好可被当作字符串头:数据指针和长度。转换本身不复制。但返回字符串与 b 共享内存,之后修改 b,字符串内容也会变化:

go
b := []byte("cat")
s := bytesToString(b)
b[0] = 'r'
fmt.Println(s) // 可能打印 rat

这破坏了 Go 程序对字符串不可变性的正常假设。若把 s 用作 map key,再修改 b,map 内部已经按旧内容计算哈希,后续查询行为会变得不可依赖。若 b 来自复用的网络缓冲区或 sync.Pool,下一个请求甚至能悄悄改掉上一个请求保存的字符串。

只有满足下面这些约束,才勉强值得考虑:

  • 转换后底层字节在字符串整个生命周期内绝不修改;
  • 缓冲区不会归还池、不会被复用,也不会来自生命周期更短的外部内存;
  • 调用方明确知道返回值是别名,而不是普通独立字符串;
  • 基准测试证明安全转换的分配确实是瓶颈;
  • 有测试覆盖生命周期,并开启 -race 与 -d=checkptr 做辅助检查。

即便全部满足,我也倾向把函数放在很小的内部包里,命名为 readOnlyBytesToString 一类显眼名字,不导出,不让危险契约扩散。

string 零拷贝为 []byte

字符串头只有数据指针和长度,切片还需要容量。一种常见拼装方式是:

go
func stringToBytes(s string) []byte {
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    bh := reflect.SliceHeader{
        Data: sh.Data,
        Len:  sh.Len,
        Cap:  sh.Len,
    }
    return *(*[]byte)(unsafe.Pointer(&bh))
}

这段代码即使在某些平台上工作,也不代表得到的是普通可写 []byte。字符串字面量可能位于只读内存:

go
b := stringToBytes("hello")
b[0] = 'H' // 可能直接崩溃,而不只是普通 panic

更隐蔽的问题是编译器和库默认字符串不可变。用可写切片偷偷修改字符串底层数据,会破坏哈希、缓存、并发读取以及编译器优化的前提。因此这个转换最多只能产生一个只读字节视图,但 Go 类型系统无法表达「只读 []byte」,任何调用方都能写 b[0]。

如果被调用 API 接收 []byte 只是为了读取,优先修改 API 让它接收 string、io.Reader 或提供两个安全入口。控制不了 API 时,老老实实使用 []byte(s)。一次明确的复制通常比一份无法由类型系统检查的「请勿修改」契约便宜。

SliceHeader 生命周期问题

官方文档特别提醒:reflect.SliceHeader 和 reflect.StringHeader 只能作为实际切片或字符串头的解释,不应作为普通结构体独立声明。下面这种模式尤其危险:

go
// 不推荐:Data 是 uintptr,不能独立维持目标对象的可达性。
var h reflect.SliceHeader
h.Data = uintptr(ptr)
h.Len = n
h.Cap = n
b := *(*[]byte)(unsafe.Pointer(&h))

uintptr 与 unsafe.Pointer 的差别不是拼写。前者可做整数运算,却不受垃圾回收器的指针追踪保证;后者是指针,但也只能按 unsafe 文档列出的合法模式转换。指针转 uintptr、计算、再转回来通常必须出现在同一个表达式里,并且结果仍需指向同一个已分配对象内部。不能把整数地址缓存起来等下次再用。

-d=checkptr 可以在部分场景下动态检查不合法指针运算,还可结合竞态检测运行测试:

bash
go test -race -gcflags=all=-d=checkptr=2 ./...

但是检查通过不代表 unsafe 代码符合所有 GC、生命周期、对齐与并发要求。

runtime.KeepAlive 解决什么,不解决什么

与文件描述符、mmap、C 内存或带终结器对象交互时,编译器可能判断某个 Go 变量在函数末尾之前已经不再使用。runtime.KeepAlive(x) 可以把 x 的可达性延长到该调用位置:

go
usePointer(ptr)
runtime.KeepAlive(owner)

它只控制可达性边界,不会把只读内存变成可写,不会修复越界指针,不会给并发访问加锁,也不会让已经归还池的缓冲区重新属于你。不要把它当成 unsafe 的免死金牌。

十二、二维切片

[][]byte 是「切片的切片」。外层切片存放若干切片头,每一行可以有不同长度,也可以各自单独分配:

go
rows := make([][]byte, 3)
for i := range rows {
    rows[i] = make([]byte, 4)
}

这会产生外层数组和多次行分配。若矩阵尺寸固定、需要更好的局部性,可以一次分配连续数据,再切成行:

go
func matrix(rows, cols int) [][]byte {
    data := make([]byte, rows*cols)
    out := make([][]byte, rows)

    for i := range out {
        begin := i * cols
        out[i] = data[begin : begin+cols : begin+cols]
    }
    return out
}

三下标把每行容量限制为 cols,避免对 out[i] 的追加覆盖下一行。这个技巧同时换来了少量分配和连续布局,代价是所有行共享一个大数组:只要任意一行存活,整块数据都不能回收;调整某一行长度也不能让中间空间自动移动。

还要防止 rows*cols 整数溢出。若尺寸来自不可信输入,应先验证:

go
if rows < 0 || cols < 0 || (rows != 0 && cols > maxInt/rows) {
    return nil, errors.New("matrix too large")
}

代码中没有预声明的 maxInt,可以按目标平台计算 int(^uint(0) >> 1),或更好地在业务层设置一个远小于机器极限的明确上限。能分配不等于应该分配。

十三、range、地址与修改

range 切片时,每次都会把元素值赋给同一个迭代变量。修改迭代变量不会修改原切片:

go
items := []Item{{Name: "a"}, {Name: "b"}}
for _, item := range items {
    item.Name = "changed" // 改的是副本
}

要原地修改,应按下标:

go
for i := range items {
    items[i].Name = "changed"
}

取迭代变量地址则更危险:

go
values := []int{10, 20, 30}
ptrs := make([]*int, 0, len(values))

for _, v := range values {
    ptrs = append(ptrs, &v) // 都指向同一个迭代变量
}

正确做法是取元素地址:

go
for i := range values {
    ptrs = append(ptrs, &values[i])
}

如果只是需要值的独立副本,也可以在循环体里显式创建新变量,但数组元素地址通常更直接。

range 开始前会复制一份切片头。循环次数大体由开始时的长度确定,但循环体和其他别名仍可能修改共享元素:

go
s := []int{1, 2, 3}
for i, v := range s {
    if i == 0 {
        s[1] = 99
        s = append(s, 4)
    }
    fmt.Println(i, v)
}

追加是否换数组会影响后续观察,共享修改也会让代码很难读。除非算法明确依赖这种行为,否则不要一边 range 一边改变被遍历切片的结构;用下标循环和显式工作队列更容易说明边界。

十四、函数参数、可变参数与意外别名

可变参数 func f(xs ...T) 在函数体内就是 []T。调用时逐项传参通常会为参数组织一段切片;传入已有切片 f(s...) 时,函数收到的切片会与 s 共享底层数组:

go
func rewrite(xs ...int) {
    xs[0] = 99
}

s := []int{1, 2, 3}
rewrite(s...)
fmt.Println(s) // [99 2 3]

API 若只想读取,注释应明确,内部也不要为了方便复用参数数组。若函数需要保存参数或异步使用,应复制一份,避免调用者稍后修改:

go
func remember(xs ...int) {
    saved := append([]int(nil), xs...)
    go consume(saved)
}

同理,结构体里保存调用者传入的切片也意味着保存共享视图:

go
type Packet struct {
    payload []byte
}

func NewPacket(payload []byte) *Packet {
    owned := make([]byte, len(payload))
    copy(owned, payload)
    return &Packet{payload: owned}
}

构造函数是否复制,应由所有权决定。只在调用期间读取可以借用;对象要长期保存、跨 goroutine 或进入缓存,通常应该取得自己的副本。可以不复制,但必须把「调用后不得修改或复用」写成明确契约。

十五、切片并发不安全

切片头有三个机器字,修改长度、容量或数据指针不是一个对其他 goroutine 原子的整体事务。一个 goroutine append,另一个同时读取或 append,属于数据竞争;即使底层数组容量足够、两边「看起来写不同下标」,只要还共享切片头或读写同一元素,就需要认真建立同步关系。

最简单的方案是由一个互斥锁同时保护切片头和底层元素:

go
type SafeBuffer struct {
    mu sync.RWMutex
    b  []byte
}

func (s *SafeBuffer) Append(p []byte) {
    s.mu.Lock()
    s.b = append(s.b, p...)
    s.mu.Unlock()
}

func (s *SafeBuffer) Snapshot() []byte {
    s.mu.RLock()
    out := make([]byte, len(s.b))
    copy(out, s.b)
    s.mu.RUnlock()
    return out
}

Snapshot 返回副本,锁释放后调用者不再与内部数组共享。如果直接返回 s.b,调用者可以在锁外修改内部状态,锁就只剩装饰作用。

也可以让单个 goroutine 独占切片,通过 channel 传递命令;或者发布不可变快照,写者每次复制并替换。选择取决于读写比例、数据大小和延迟要求。无论哪种,append 偶尔扩容并不会自动提供同步,cap 也不是线程安全开关。

go test -race ./... 很适合发现切片别名造成的竞态,但它只能覆盖测试实际走到的路径。所有权清晰仍是第一道防线。

十六、内存、逃逸与 GC:别只盯着分配次数

切片头本身很小,可以位于栈上;底层数组放在栈还是堆上由编译器逃逸分析决定。返回切片并不机械等于「一定慢」:编译器会根据大小、生命周期和使用方式决定。可以用下面的命令观察原因:

bash
go build -gcflags='-m=2' ./...

逃逸报告是优化线索,不是成绩单。为了让一个小数组留在栈上而扭曲 API,可能比那次堆分配更糟。应结合 CPU profile、heap profile 和基准测试判断。

切片容易制造三种不同的内存问题:

  1. 扩容抖动:不断增长导致多次分配与复制,可用合理预分配缓解;
  2. 小视图留住大数组:长期保存子切片,可通过复制所需部分切断引用;
  3. 逻辑删除但引用未清理:缩短长度后尾部仍持有指针,可在删除时写零值。

这三者不能用同一招解决。给小视图做 s = s[:len(s):len(s)] 只限制容量,并不会释放大数组;只有复制到新数组并丢弃旧引用,旧数组才有机会被回收。对逻辑删除只调用 runtime.GC() 也没用,只要底层数组仍可达且还保存引用,目标对象仍可能可达。

含指针元素的容量预留尤其要克制。底层数组的分配对象按元素类型管理,GC 需要识别其中的指针槽。大量空闲容量虽然位于 len 之外,仍占实际内存,并可能增加清零和扫描相关成本。纯 []byte 不含指针,扫描压力不同,但内存占用和缓存局部性仍是真成本。

十七、切片还能怎么玩

把切片当队列

用切片实现一个先进先出队列很自然:尾部 append,头部通过重切片弹出。

go
type Queue struct {
    items []*Task
}

func (q *Queue) Push(task *Task) {
    q.items = append(q.items, task)
}

func (q *Queue) Pop() *Task {
    task := q.items[0]
    q.items = q.items[1:]
    return task
}

功能上没错,长期运行却有两个容易忽略的问题。第一,被弹出的指针仍可能留在底层数组已经越过的槽位中;只要同一分配对象仍被后面的切片引用,垃圾回收器对含指针对象的处理就可能让相关对象继续存活。第二,切片头不断向数组尾部移动,前面腾出的空间不会自动变成尾部容量。队列明明只有少量元素,继续 append 仍可能扩容,而那块前部空间暂时用不上。

先解决无用引用,弹出前把槽位置空:

go
func (q *Queue) Pop() *Task {
    task := q.items[0]
    q.items[0] = nil
    q.items = q.items[1:]
    return task
}

还应处理空队列,究竟返回 nil、第二个布尔值还是 panic,由 API 契约决定,不能让偶然的越界 panic 代替设计。一个比较清楚的签名是 Pop() (*Task, bool)。

若队列会长期一进一出,可以记录逻辑头下标,达到阈值后把仍有效的元素搬回数组开头:

go
type Queue struct {
    items []*Task
    head  int
}

func (q *Queue) compact() {
    if q.head == 0 || q.head*2 < len(q.items) {
        return
    }

    n := copy(q.items, q.items[q.head:])
    for i := n; i < len(q.items); i++ {
        q.items[i] = nil
    }
    q.items = q.items[:n]
    q.head = 0
}

阈值不是通用真理。移动过于频繁会浪费 CPU,长期不移动又浪费内存。吞吐敏感、规模较大的队列可以考虑环形缓冲区,用头尾下标循环利用固定数组;需要阻塞与并发协调时,channel 往往比手写并发队列更合适。切片能拼出队列,不代表它天然具备队列的空间回收策略。

复用、释放与缩容是三件事

处理完一批数据后,经常看到下面几种写法:

go
s = s[:0] // 长度归零,保留底层数组,适合很快再次使用
s = nil   // 丢弃当前切片引用,底层数组若无其他引用即可回收

还有一种看似积极、实际无效的写法:

go
s = s[:0:0]

它把长度和容量都设为 0,能保证下一次 append 分配新数组,却没有切断当前切片的数据指针。只要 s 仍存活,旧底层数组仍可能存活。限容控制未来追加,置 nil 才表示放弃当前引用,复制则把需要的数据带到一块大小合适的新数组。 三种动作解决不同问题。

长期复用缓冲区时,还要防止一次异常大请求把常驻容量永久抬高。可以在批次结束时设置保留上限:

go
const maxReusable = 64 << 10

if cap(buf) > maxReusable {
    buf = nil
} else {
    buf = buf[:0]
}

这里的 64 KiB 只是示例,应来自业务数据分布和内存预算。阈值过低会让正常请求反复分配,过高则会让偶发峰值变成常驻内存。最好通过 heap profile 和请求大小分位数选择,而不是从别人的博客复制一个数字。

把自己交给 sync.Pool

sync.Pool 可以缓存临时对象,降低高频分配压力,但池中对象随时可能被 runtime 移除,程序不能依赖它做持久存储。用池复用 []byte 时,常见做法是把指向切片的指针放进去,或存放拥有内部切片的缓冲类型;无论哪种,所有权必须完整移交。

go
var pool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 0, 4096)
        return &b
    },
}

func work() {
    p := pool.Get().(*[]byte)
    buf := (*p)[:0]

    // 使用 buf,结果不能在归还后继续引用它。

    if cap(buf) <= 64<<10 {
        *p = buf[:0]
        pool.Put(p)
    }
}

十八、用基准测试拆穿「零拷贝一定快」

性能讨论最怕只数语法里的 copy。零拷贝可能避免一次分配,也可能延长一个巨型缓冲区的生命周期,让峰值内存更高;原地过滤可能省分配,也可能因为别名迫使上层再复制一次;预分配可能减少扩容,也可能为罕见上限长期浪费空间。

可以写一个最小基准:

go
func BenchmarkCloneCopy(b *testing.B) {
    src := make([]byte, 4096)
    b.ReportAllocs()
    b.SetBytes(int64(len(src)))

    for i := 0; i < b.N; i++ {
        dst := make([]byte, len(src))
        copy(dst, src)
        runtime.KeepAlive(dst)
    }
}

func BenchmarkCloneAppend(b *testing.B) {
    src := make([]byte, 4096)
    b.ReportAllocs()
    b.SetBytes(int64(len(src)))

    for i := 0; i < b.N; i++ {
        dst := append([]byte(nil), src...)
        runtime.KeepAlive(dst)
    }
}

运行:

bash
go test -run '^$' -bench 'BenchmarkClone' -benchmem -count=5

不要预先宣布哪种一定更快。不同 Go 补丁版本、CPU、元素大小和上下文可能得到不同结果。更重要的是,两个版本表达的所有权是否一样清楚。纳秒级差异若落在噪声里,我会选读者一眼能看懂的版本。

测试 unsafe 转换时还要避免基准本身被优化掉,使用全局 sink 或 runtime.KeepAlive,并分别测短数据、长数据、结果逃逸和不逃逸。安全转换在某些临时场景中可能已经被编译器优化,拿一个强制逃逸的微基准去证明所有业务都需要 unsafe,结论并不诚实。

十九、一套更实用的所有权规则

讲了这么多技巧,最后可以压缩成几条工程规则。

借用

函数只在调用期间读取切片,不保存、不异步使用、不修改。签名仍是普通切片类型,因为 Go 没有只读切片,只能靠函数实现和注释守约:

go
// Sum reads values only during the call.
func Sum(values []int) int { /* ... */ }

移交

调用者把缓冲区所有权交给接收者,之后不再使用。适合内部高性能流水线,但必须在 API 上写明;跨团队公共 API 使用这种隐含契约要非常谨慎。

复制

接收者需要长期保存,而调用者仍可继续使用原数据。构造函数、缓存、异步任务和并发边界通常应复制。这是最容易推理的默认策略。

限容

仍允许共享当前元素,但不允许被调用者通过 append 覆盖窗口之后的数据:

go
view := s[low:high:high]

它是增长隔离,不是只读隔离,也不是生命周期隔离。

unsafe 别名

只有测量证明复制成本不可接受、生命周期完全可控,并且危险被封装在很小范围内时才使用。函数名、注释、测试都应说明只读、所有权和有效期。不要把 unsafe 转换包装成一个看似无害的公共 ToBytes。

二十、常见误解速查

误解事实
切片就是动态数组切片是描述底层数组一段区域的头部值
赋值切片会复制元素只复制切片头,元素通常仍共享
append 总会创建新数组容量足够时可以复用旧数组
append 总会修改原切片扩容后返回值指向新数组,且调用者切片头不会自动变化
copy 会用满目标容量只复制到目标当前长度
s[:0] 会释放内存它只把长度设为 0,仍保留容量和底层数组
s[:len(s):len(s)] 会复制它只限制容量,不复制当前元素
小切片只占小内存小视图可能让整个大底层数组保持存活
nil 切片与空切片处处相同长度行为相近,但 == nil 与 JSON 等边界可不同
[]byte(s) 必然堆分配语义上是安全独立值,具体分配可能被编译器优化
stringToBytes 得到普通可写切片unsafe 视图可能指向只读区,修改会破坏字符串不变性
range 中 &v 每次地址不同迭代变量复用,这是经典陷阱
预分配越大越快过度预留会增加内存、清零和潜在 GC 成本
不同 goroutine 追加不同元素就安全切片头和底层数组都可能竞态,需要同步或独占所有权

总结

切片的表面 API 很小,真正需要管理的是四件事:视图、容量、别名和生命周期。

len 告诉你现在能看多远,cap 告诉你还能向后扩多远,array 把所有别名连接到同一块底层存储。截取只调整视图,append 根据容量决定继续合住还是另租新房,copy 明确切断共享,三下标切片限制未来增长,清零尾部断开无用引用。把这些关系想清楚,删除、插入、过滤、二维布局和内存优化都只是组合题。

unsafe 则是把三字经直接摊在桌面上改。它确实能绕过复制,也会同时绕过只读性、边界、垃圾回收可达性和版本兼容保护。依赖 reflect.SliceHeader 的零拷贝代码尤其脆弱。若一次安全转换不是经过 profile 证明的瓶颈,我宁愿复制;如果它真是瓶颈,我也会把危险封进最小边界,让调用者面对的仍然是普通、可推理的 Go。

所谓 slice 奇技淫巧,最后不是让代码变得更玄,而是知道何时共享、何时分家。能用 copy 讲清所有权,就别拿 unsafe.Pointer 猜垃圾回收器的心思。

作者Aoang
发布于2021-03-19
更新于2026-08-28
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。