因为没找到一个合适的中文词来表示slice的确切含义,所以文中将直接使用slice这个单词。
实际上,slice表示的是数组的一部分,可以称为数组片段。
内存中的数组一文学习研究了数组及数组类型在内存中的表现形式。
slice是依赖数组而存在的,本文在 数组 基础上继续学习slice。
slice内存结构示意图
data指针并不一定指向底层数组的起始位置,可以指向数组的任何一个元素地址。
但是对于slice本身来说,data指针指向一个数组的开始。
环境
- OS : Ubuntu 20.04.2 LTS; x86_64
- Go : go version go1.16.2 linux/amd64
声明
操作系统、处理器架构、Go版本不同,均有可能造成相同的源码编译后运行时的内存地址、数据结构不同。
本文仅保证学习过程中的分析数据在当前环境下的准确有效性。
代码清单
- package main
- import "fmt"
- func main() {
- var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
- var s = a[:5]
- PrintInterface(s)
- }
- //go:noinline
- func PrintInterface(v interface{}) {
- fmt.Println("it =", v)
- }
变量a是一个声明并初始化的数组,变量s是通过数组a创建的slice。
深入内存
动态调试,在 main 函数的入口处设置断点,查看程序指令:
数组初始化
从上图中指令可以看出,数组的声明和初始化是分两步实现的。
- var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
数组创建
在分配 main 函数的栈帧之后,立即调用 runtime.newObject 函数分配了一个数组,其参数是0x4a2ae0。
在内存中的数组中我们看到小数组直接分配在栈内存,大数组分配在堆内存。而在这里,小数组也直接通过动态分配的方式创建在堆内存。猜测这应该是与代码执行上下文有关。
数组类型结构定义在reflect/type.go源码文件中,如下所示:
- // arrayType represents a fixed array type.
- type arrayType struct {
- rtype
- elem *rtype // array element type
- slice *rtype // slice type
- len uintptr
- }
我们来看看该数组的类型:
刚刚创建的数组长度是10,占用80个字节的内存,名称是[10]int,与代码清单一致。
数组赋值
代码清单中声明的数组数据,在代码编译之后保存在可执行文件的 .rodata section。程序运行时,数组数据的内存地址是:0x4da948。
在数组创建之后,数组元素的值全部都是零。初始化赋值操作是通过调用 runtime.duffcopy 函数复制0x4da948地址处的数据实现的。
关于达夫设备,稍后详细介绍。
slice结构体
slice的创建是通过 runtime.convTslice 函数实现的。
通过源码可以看出,该函数和之前看到的其他 runtime.convTx 函数类似,复制栈内存一个slice对象到堆内存;不同的是,把slice对象作为[]byte类型的数据进行复制。
同时,源码中可以看到一个 *slice 类型,这很令人兴奋。在 runtime/slice.go 源码文件中,找到了runtime.slice结构体的定义:
- type slice struct {
- array unsafe.Pointer
- len int
- cap int
- }
slice结构体由三部分组成:
- 指向数组的指针:该数组保存着具体的数据
- 长度:也就是slice包含元素的数量
- 容量:也就是数组的长度
从其结构来看,与Java中的java.util.ArrayList非常类似。
在调用 runtime.convTslice 函数的指令处下断点,观察其参数。
从上图可以看出,runtime.convTslice函数的参数,本身就是位于栈顶的一个runtime.slice结构体,该函数会把这个结构体数据复制到堆内存:
- 0x000000c00007a000 // 数组的地址
- 0x0000000000000005 // slice的长度
- 0x000000000000000a // slice的容量(数组的长度)
我们再看runtime.convTslice函数的返回值。
返回值是通过栈内存传递的,保存在紧挨参数的位置,值是0x000000c00000c030;这是一个指针,指向的数据与参数完全相同,最终作为PrintInterface函数的参数,用于打印输出数据。
通过查看Golang源代码,发现有多处定义了slice结构体,它们在内存中是等价的(虽然有细微差别):
- 在 reflect/value.go 源码文件中的SliceHeader结构体
- type SliceHeader struct {
- Data uintptr
- Len int
- Cap int
- }
- 在internal/unsafeheader/unsafeheader.go 源码文件中的Slice结构体
- type Slice struct {
- Data unsafe.Pointer
- Len int
- Cap int
- }
slice类型
slice类型的定义在Golang源码 reflect/type.go 文件中。
- // sliceType represents a slice type.
- type sliceType struct {
- rtype
- elem *rtype // slice element type
- }
在调用PrintInterface函数的指令处下断点,观察slice类型信息。
rtype.size
slice对象占0x18(24)个字节。
- 指针:8字节
- 长度:8字节
- 容量:8字节
rtype.ptrdata
8字节(number of bytes in the type that can contain pointers)。
slice结构体的第一个字段是指针类型,长度和容量字段不是指针类型,所以只有8字节包含指针。
在前面的学习中,研究的都简单数据类型,不包含指针,所以其类型的ptrdata都是零。
rtype.hash
值为 0x1bf9668e 。
rtype.tflag
0x02 = reflect.tflagExtraStar
请看 rtype.str 字段值。
rtype.align
8字节对齐。
rtype.fieldAlign
作为结构体字段时8字节对齐。
rtype.kind
值为0x17(23)。
rtype.equal
值为零。说明slice对象不进行相等性比较。
reflect.Type 接口中声明了一个 Comparable() bool 方法,用于检测判断该类型的数据是否可以进行比较。具体实现如下,二者个关系便一目了然了。
- func (t *rtype) Comparable() bool {
- return t.equal != nil
- }
rtype.str
表示的值为:*[]int。
rtype.ptrToThis
值为零。
sliceType.elem
该指针指向的数据类型是 int 类型(rtype.kind=reflect.Int)。
达夫设备
在计算机科学领域,达夫设备(英文:Duff's device)是串行复制(serial copy)的一种优化实现,通过汇编语言编程时一种常用方法,实现展开循环,进而提高执行效率。
How does Duff's device work?
在Golang中,runtime.duffcopy函数声明如下,实际是通过Golang汇编实现的。
x86_64的具体实现位于源码的 runtime/duff_amd64.s 文件中。
该函数的实现共322行,实在是太长了,我们在这里截取一部分,以便了解其实现细节和学习其优秀的设计思想。
在不了解达夫设备的情况下,看到该函数代码的第一眼,可能会产生两种错觉:
- 实现这个函数的程序员估计是很懒,写个循环不香吗?
- 实现这个函数的程序员这么喜欢复制粘贴代码,是按代码行数领工资的吗?
实际情况是,该函数实现是经过精心设计的,用于优化内存中的数据复制操作。
不过,该函数很可能就是通过复制粘贴实现的,共包含64个这样的代码块:
- MOVUPS (SI), X0
- ADDQ $16, SI
- MOVUPS X0, (DI)
- ADDQ $16, DI
该代码块(以下称为“复制单元”)的作用是:从源地址复制16字节的数据到目的地址。也就是说这四条指令,一次可以复制2个int值。
那么意味着,如果runtime.duffcopy函数从头到尾完整执行下来:
- 一共可以复制1024(64*16)个字节
- 一共可以复制128(64*2)个 int 值
在本文示例中,我们的数组只包含10个 int 元素,共80个字节。
于是一个个疑问冒出来:
- 调用runtime.duffcopy函数岂不是多复制了944个字节?
- 多复制的数据覆盖了附近区域的正常数据岂不是要导致程序混乱?
- 为什么程序没有异常崩溃(segmentation fault)?
- 写个 "for" 循环不像吗?
- 像在内存中的数组遇到的那样使用rep movsq机器指令不香吗?
实际上,在本文示例中,复制数组数据时,并不是从runtime.duffcopy函数的第一行代码开始执行的,而是跳过了59个复制单元,直接从第60个复制单元开始执行,共执行了5个复制单元,复制了10个 int 数组元素,然后返回到 main 函数中。
如果创建一个[20]int数组,复制数据时就会从runtime.duffcopy函数的第55个复制单元开始执行。
如果创建一个[128]int数组,复制数据时就会从runtime.duffcopy函数的第1个复制单元开始执行,也就是从第一行代码开始执行。
当然,到底该从那条指令开始执行,是Golang编译器决定的,并不是调用方自己决定的,也不是runtime.duffcopy函数决定的。
所以,runtime.duffcopy函数在整个的数据复制过程中,没有一处条件判断,没有一处内存跳转,完全是顺序执行。这是非常高效的操作,是很棒的指令优化。
另外还有三处细节优化:
1.在调用runtime.duffcopy函数时,直接使用rdi、rsi寄存器保存两个地址参数;在数据复制过程中,使用ADD指令修改两个寄存器的值实现内存地址递增。
- 这是我在Golang中遇到的第一个完全使用寄存器保存参数的函数。
- 按照常规的编程约定:第一个参数保存在rdi寄存器,第一个参数保存在rsi寄存器。
- 所以可以这样理解其函数声明:func duffcopy(dst [1024]byte, src [1024]byte)。
2.调用方为runtime.duffcopy函数分配8字节的栈帧内存用于保存rbp寄存器的值,并负责销毁该栈帧,使其能够专注于数据复制,不做其他任何事情。(实际也可以不分配该栈帧。)(这让我想起了 red zone。)
3.使用 movups指令和 xmm0寄存器,有效压缩了指令数量,从而提高执行效率。
总而言之,runtime.duffcopy函数是一个高度优化的“达夫设备”。
最后,还有两个问题:
1.如果 int 数组长度是奇数会怎么样?
答案是:先使用movq指令复制第一个元素,剩下偶数个数组元素使用runtime.duffcopy函数复制。
当数组长度为11时,var a = [11]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},机器指令如下:
2.如果 int 数组长度超过128会怎么样?
答案是:使用rep movsq指令代替runtime.duffcopy函数。这个在意料之中。
在本文中,仔细研究了slice类型和slice对象在内存中的存储结构。
本文转载自微信公众号「Golang In Memory」