切片 #
切片是Go语言中最常用的数据类型之一,它类似数组,但相比数组它更加灵活,高效,由于它本身的特性,往往也更容易用错。
不同于数组是值类型,而切片是引用类型。虽然两者作为函数参数传递时候都是值传递(pass by value),但是切片传递的包含数据指针(可以细分为pass by pointer),如果切片使用不当,会产生意想不到的副作用。
初始化 #
切片的初始化方式可以分为三种:
-
使用make函数创建切片
make函数语法格式为:make([]T, length, capacity),capacity可以省略,默认等于length
-
使用字面量创建切片
-
从数组或者切片派生(reslice)出新切片
Go支持从数组、指向数组的指针、切片类型变量再reslice一个新切片。
reslice操作语法可以是[]T[low : high],也可以是[]T[low : high : max]。其中low,high,max都可以省略,low默认值是0,high默认值cap([]T),max默认值cap([]T)。low,hight,max取值范围是
0 <= low <= high <= max <= cap([]T)
,其中high-low是新切片的长度,max-low是新切片的容量。对于[]T[low : high],其包含的元素是[]T中下标low开始,到high结束(不含high所在位置的,相当于左闭右开[low, high))的元素,元素个数是high - low个,容量是cap([]T) - low。
func main() {
slice1 := make([]int, 0)
slice2 := make([]int, 1, 3)
slice3 := []int{}
slice4 := []int{1: 2, 3}
arr := []int{1, 2, 3}
slice5 := arr[1:2]
slice6 := arr[1:2:2]
slice7 := arr[1:]
slice8 := arr[:1]
slice9 := arr[3:]
slice10 := slice2[1:2]
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice1", slice1, len(slice1), cap(slice1))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice2", slice2, len(slice2), cap(slice2))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice3", slice3, len(slice3), cap(slice3))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice4", slice4, len(slice4), cap(slice4))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice5", slice5, len(slice5), cap(slice5))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice6", slice6, len(slice6), cap(slice6))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice7", slice7, len(slice7), cap(slice7))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice8", slice8, len(slice8), cap(slice8))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice9", slice9, len(slice9), cap(slice9))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice10", slice10, len(slice10), cap(slice10))
}
上面代码输出一下内容:
slice1 = [], len = 0, cap = 0
slice2 = [0], len = 1, cap = 3
slice3 = [], len = 0, cap = 0
slice4 = [0 2 3], len = 3, cap = 3
slice5 = [2], len = 1, cap = 2
slice6 = [2], len = 1, cap = 1
slice7 = [2 3], len = 2, cap = 2
slice8 = [1], len = 1, cap = 3
slice9 = [], len = 0, cap = 0
slice10 = [0], len = 1, cap = 2
注意:
我们使用arr[3]访问切片元素时候会报
index out of range [3] with length
错误,而使用arr[3:]来初始化slice9却是可以的。因为这是Go语言故意为之的。具体原因可以参见 Why slice not painc 这个issue。
接下来我们来看看切片的底层数据结构。
数据结构 #
Go语言中切片的底层数据结构是 runtime.slice
(
runtime/slice.go),其中包含了指向数据数组的指针,切片长度以及切片容量:
type slice struct {
array unsafe.Pointer // 底层数据数组的指针
len int // 切片长度
cap int // 切片容量
}
注意:
切片底层数据结构也可以说成是
reflect.SliceHeader
,两者没有冲突。reflect.SliceHeader
是暴露出来的类型,可以被用户程序代码直接使用。
我们来看看下面切片如何共用同一个底层数组的:
func main() {
a := []byte{'h', 'e', 'l', 'l', 'o'}
b := a[2:3]
c := a[2:3:3]
fmt.Println(string(a), string(b), string(c)) // 输出 hello l l
}
在前面 《 基础篇-字符串 》 章节,我们使用了 GDB 工具验证了字符串的数据结构,这一次我们使用另外一种方式验证切片的数据结构。我们通过打印切片的底层结构信息来验证:
func main() {
type sliceHeader struct {
array unsafe.Pointer // 底层数据数组的指针
len int // 切片长度
cap int // 切片容量
}
a := []byte{'h', 'e', 'l', 'l', 'o'}
b := a[2:3]
c := a[2:3:3]
ptrA := (*sliceHeader)(unsafe.Pointer(&a))
ptrB := (*sliceHeader)(unsafe.Pointer(&b))
ptrC := (*sliceHeader)(unsafe.Pointer(&c))
fmt.Printf("切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\n", "a", ptrA.array, ptrA.len, ptrA.cap)
fmt.Printf("切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\n", "b", ptrB.array, ptrB.len, ptrB.cap)
fmt.Printf("切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\n", "c", ptrC.array, ptrC.len, ptrC.cap)
}
上面代码输出以下内容:
切片a: 底层数组地址=0xc00009400b, 长度=5, 容量=5
切片b: 底层数组地址=0xc00009400d, 长度=1, 容量=3
切片c: 底层数组地址=0xc00009400d, 长度=1, 容量=1
从输出内容可以看到切片变量 b
和 c
都指向同一个底层数组地址 0xc00009400d
,它们与切片变量 a
指向的底层数组地址 0xc00009400b
恰好相差2个字节,这两个字节大小的内存空间存在的是 h
和 e
字符。
副作用 #
由于切片底层结构的特殊性,当我们使用切片的时候需要特别留心,防止产生副作用(side effect)。
示例1:append操作产生副作用 #
func main() {
slice1 := []byte{'h', 'e', 'l', 'l', 'o'}
slice2 := slice1[2:3]
slice2 = append(slice2, 'g')
fmt.Println(string(slice2)) // lg
fmt.Println(string(slice1)) // 输出helge,slice1的值也变了。
}
上面代码本意是将切片slice2追加g
字符,却产生副作用,即也修改了slice1的值:
解决append产生的副作用 #
解决由于append产生的副作用,有两种解决办法:
- reslice时候指定max边界
- 使用copy函数拷贝出一个副本
reslice时候指定max边界 #
func main() {
slice1 := []byte{'h', 'e', 'l', 'l', 'o'}
slice2 := slice1[2:3:3]
slice2 = append(slice2, 'g') // 此时slice2容量扩大到8
fmt.Println(string(slice2)) // 输出lg
fmt.Println(string(slice1)) // 输出hello
}
通过slice2 := slice1[2:3:3]
方式进行reslice之后,slice2的长度和容量一样,若对slice2再进行append操作其一定会发送扩容操作,此后slice2和slice1之间就没有任何关系了。
使用copy函数拷贝出一个副本 #
func main() {
slice1 := []byte{'h', 'e', 'l', 'l', 'o'}
slice2 := make([]byte, 1)
copy(slice2, slice1[2:3])
slice2 = append(slice2, 'g')
fmt.Println(string(slice2)) // 输出lg
fmt.Println(string(slice1)) // 输出hello
}
示例2:指针类型变量引用切片产生副作用 #
type User struct {
Likes int
}
func main() {
users := make([]User, 1)
pFirstUser := &users[0]
pFirstUser.Likes++
fmt.Println("所有用户:")
for i := range users {
fmt.Printf("User: %d Likes: %d\n\n", i, users[i].Likes)
}
users = append(users, User{}) // 添加一个新用户到集合中
pFirstUser.Likes++ // 第一个用户的Likes次数加一
fmt.Println("所有用户:")
for i := range users {
fmt.Printf("User: %d Likes: %d\n", i, users[i].Likes)
}
}
指向上面代码输出以下内容:
所有用户:
User: 0 Likes: 1
所有用户:
User: 0 Likes: 1
User: 1 Likes: 0
代码本意是通过User类型指针变量pUsers
进行第一个用户Likes更新操作,没想到切片进行append之后,产生了副作用:pUsers
指向切片已经与切片变量users
不一样了。
避免切片副作用黄金法则 #
- 在边界处拷贝切片,这里面的边界指的是函数接受切片参数或返回切片的时候。
- 永远不要使用一个变量来引用切片数据
扩容策略 #
当对切片进行append操作时候,若切片容量不够时候,会进行扩容处理。当切片进行扩容时候会先调用runtime.growslice
函数,该函数返回一个新的slice底层结构体,该结构体array字段指向新的底层数组地址,cap字段是新切片的容量,len字段是旧切片的长度,旧切片的内容会拷贝到新切片中,最后再把要追加的数据复制到新切片中,并更新切片len长度。
// et是slice元素类型
// old是旧的slice
// cap是新slice最低要求容量大小。是旧的slice的长度加上append函数中追加的元素的个数
// 比如s := []int{1, 2, 3};s = append(s, 4, 5); 此时growslice中的cap参数值为5
func growslice(et *_type, old slice, cap int) slice {
if cap < old.cap {
panic(errorString("growslice: cap out of range"))
}
if et.size == 0 {
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap { // 最小cap要求大于旧slice的cap两倍大小
newcap = cap
} else {
if old.len < 1024 { // 当旧slice的len小于1024, 扩容一倍
newcap = doublecap
} else { // 否则每次扩容25%
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
var overflow bool
var lenmem, newlenmem, capmem uintptr
switch {
case et.size == 1: // 元素大小
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem) // 调整newcap大小
case et.size == sys.PtrSize:
lenmem = uintptr(old.len) * sys.PtrSize
newlenmem = uintptr(cap) * sys.PtrSize
capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
newcap = int(capmem / sys.PtrSize)
case isPowerOfTwo(et.size):
var shift uintptr
if sys.PtrSize == 8 {
// Mask shift for better code generation.
shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
} else {
shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
}
lenmem = uintptr(old.len) << shift
newlenmem = uintptr(cap) << shift
capmem = roundupsize(uintptr(newcap) << shift)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
default:
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
}
if overflow || capmem > maxAlloc {
panic(errorString("growslice: cap out of range"))
}
var p unsafe.Pointer
if et.ptrdata == 0 { // 切片元素中没有指针类型数据,不用考虑写屏障问题
p = mallocgc(capmem, nil, false)
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
}
}
// 涉及到slice扩容都会有内存移动操作
memmove(p, old.array, lenmem)
return slice{p, old.len, newcap}
}
从上面代码中可以总结出切片扩容的策略是:
- 若切片容量小于1024,会扩容一倍
- 若切片容量大于等于1024,会扩容1/4大小,由于考虑内存对齐,最终实际扩容大小可能会大于1/4
从上面代码中可以看到,切片进行扩容时一定会进行内存拷贝,这是成本较大操作。所以切片一大优化点就是在使用之前尽量指定好切片所需容量,避免出现扩容情况。
string类型与[]byte类型如何实现zero-copy互相转换? #
什么是零拷贝(zero-copy) #
零拷贝(zero-copy) 指的是CPU不需要先将数据从某处内存复制到另一个特定区域。当应用程序读取文件,需要从磁盘中加载内核区域,然后将内核区域内容复制到应用内存区域,这就涉及到内存拷贝。若采用mmap技术可以文件映射到特定内存中,只需加载一次,应用程序和内核都可以共享内存中文件数据,这就实现了zero-copy。或者当应用程序需要发送文件给远程时候,可以采用sendfile技术实现零拷贝,若未实现零拷贝,则需要进行四次拷贝过程:
磁盘—(DMA copy)–> 系统内核 –> 应用程序区域 –> 系统内核(socket) —(DMA copy)—> 网卡
使用[]byte(string) 和 string([]byte)方式进行字符串和字节切片互转时候会不会发生内存拷贝? #
package main
func byteArrayToString(b []byte) string {
return string(b)
}
func stringToByteArray(s string) []byte {
return []byte(s)
}
func main() {
}
我们来看下上面代码中的底层实现
go tool compile -N -l -S main.go
执行上面命名,输出以下内容:
|
|
从上面汇编代码可以看到 string([]byte)
底层调用的是 runtime.slicebytetostring
,[]byte(string)
底层调用的是 runtime.stringtoslicebyte
。查看这两个底层函数实现可以看到两者都是先创建一段内存空间,然后使用 memmove
函数拷贝内存,将数据拷贝到新内存空间。这也就是说 []byte(string)
和 string([]byte)
进行转换时候需要内存拷贝。
string类型与[]byte类型 zero-copy转换实现 #
那么能不能实现不需要内存拷贝的字符串和字节切片的转换呢?答案是可以的。
根据前面 《 基础篇-字符串 》 章节和本章节,我们可以看到字符串和字节切片底层结构很相似,它们相同部分都有指向底层数据指针和记录底层数据长度len字段,而字节切片额外多了一个字段cap,记录底层数据的容量。我们只要转换时候让它们共享底层数据就能实现zero-copy。让我们再看看字符串和切片的数组结构:
type StringHeader struct {
Data uintptr
Len int
}
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
我们来看下网上比较常见zero-copy的实现方式,它是有bug的:
func string2bytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}
func bytes2string(b []byte) string{
return *(*string)(unsafe.Pointer(&b))
}
我们来测试一下:
func main() {
a := "hello"
b := string2bytes(a)
fmt.Println(string(b), len(b), cap(b))
}
上面代码输出以下内容:
hello 5 824634122328
从上面输入内容,我们可以看到字符串转换成字节切片后的容量明显是有问题的。让我们来分析下具体原因。
上面两个函数借助 非安全指针类型 强制转换类型实现的。对于字节切片转换字符串使用这种方式是可以的,字节切片多余的cap字段会自动溢出掉;而反过来由于字符串没有记录容量字段,那么将其强制转换成字节切片时候,字节切片的cap字段是未知的,这有可能导致非常严重问题。所以将字符串转换成字节切片时候需要保证字节切片的cap设置正确。
正确的字符串转字节切片实现如下:
func StringToBytes(s string) (b []byte) {
sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
bh.Data, bh.Len, bh.Cap = sh.Data, sh.Len, sh.Len
return b
}
或者
func StringToBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(
&struct {
string
Cap int
}{s, len(s)},
))
}