基于几种基本数据类型的内部实现,讨论 Go 里有哪些新手容易掉进去的 “坑“。

原文:How to avoid Go gotchas

TL;DR

维基百科对「坑」的定义(原文中叫 Gotcha):

a gotcha is a valid construct in a system, program or programming language that works as documented but is counter-intuitive and almost invites mistakes because it is both easy to invoke and unexpected or unreasonable in its outcome
(source: wikipedia))

Go 语言有一些我们常说的「坑」,有不少优秀的文章讨论过这些「坑」。这些文章所讨论的东西非常重要,尤其对 Go 的初学者来说,一不小心就掉进了这些「坑」里。

但有个问题让我困惑了很久,为什么我几乎没碰到过这些文章里讨论的大部分「坑」?真的,大多数比较知名的比如 “nil interface” 或者 “slice append” 等我从来就没觉得困惑过。我从开始使用 Go 一直到现在总是以某种方式避开了这些形形色色的问题。

后来发现,我足够幸运的读了不少解释 Go 数据结构内部实现的文章并且学习了一些 Go 内部运行原理的基础知识。这些知识足够让我对 Go 有了深刻的认识,同时也避免了掉进各种各样的坑里。

记住维基百科的定义,「坑 是…有效的构造…但同时是反直觉的」
所以,你只有两个选项:

  • “fix” 这门语言
  • fix 自己的直觉

第二种显然是更好的选择,一旦你脑中有了一副清晰的图像描绘了切片或者接口在底层是如何运作的,根本不可能再掉进那些陷阱里。

这样的学习方式对我而言是有用的,我想对其他人也同样适用。这也是为什么我决定在这篇文章里整理一些关于 Go 内部实现的基础知识,希望能帮助其他人对各种数据结构在内存中的表示建立起清晰的直觉。

让我们从一些基础的开始:

指针 (Pointers)

Go 实际上是一门在层次上非常接近硬件的语言。当你创建一个 64 位的整形变量(int64)你就精确的知道它占用了多少内存,而且可以用 unsafe.Sizeof() 方法来计算每种类型的内存占用量。

我经常用可视化的内存块来「看」这些变量、数组和数据结构的大小。视觉上的展示可以让人更直观的理解这些类型,也便于解释一些行为和性能上的问题。

作为热身,我们先对 Go 里最基础的类型做可视化:

基础类型

假设你在一台 32 位的机器上 (我知道现在你可能只有 64 位的机器了…), 可以清楚的看到 int64 的内存占用是 int32 的两倍。

指针的内部表示稍微复杂一点,它占用一块内存,包含了一个指向其他内存块的内存地址,这个地址存储着实际的数据。有个术语叫 『引用指针』 ,实际上它指的是 『通过存储在指针变量里的地址取到实际指向的内存块』。可以想象一下指针在内存中的表示:

指针

地址在内存里通常用十六进制表示,像图中标识的 “0x…” 这样。先记住,『指针的值』存放在一个地方,『指针指向的数据』存放在另一个地方,这一点会有助于我们后面的理解。

对于没有指针相关知识的 Go 新手来说,很容混淆值函数参数的『值传递』。你可能已经知道,Go 里所有的传参都是『按值』传递,也就是通过复制来实现传参。
图示如下:

函数参数

在第一个例子里,复制了所有的内存块 - 但实际情况里用到的变量基本都会超过 2 个甚至 200 万个内存块,如果全部都复制一份的话将会是成本非常高的操作。而在第二个例子里,只需要复制包含了实际数据内存地址的那一块内存,这样做非常高效而且成本很低。

显然,第一个例子里如果改变 Foo() 方法中的 p 变量并不会修改原始数据的内容,但在第二个例子里则肯定会修改 p 所指向的原始数据的内存块。

理解了关键的内部实现将会帮你避开大多数的坑,接下来让我们再深入一点。

数组和切片 (Arrays and Slices)

初学的时候一般都会对切片和数组感到混淆和困惑。所以我们先来看看数组。

数组 (Arrays)

1
2
3
var arr [5]int
var arr [5]int{1,2,3,4,5}
var arr [...]int{1,2,3,4,5}

数组只是连续的内存块,如果你去阅读 Go 运行时的源码(src/runtime/malloc.go),你会发现创建一个数组本质上就是分配了一块指定大小的内存。是不是想到了经典的 malloc?在 Go 里只是更加智能了。

Old good malloc, just smarter :)

1
2
3
4
5
6
7
// newarray allocates an array of n elements of type typ.
func newarray(typ *_type, n int) unsafe.Pointer {
if n < 0 || uintptr(n) > maxSliceCap(typ.size) {
panic(plainError("runtime: allocation size out of range"))
}
return mallocgc(typ.size*uintptr(n), typ, true)
}

这意味着我们可以简单的用一组通过指针连接起来的内存块来表示一个数组:

数组

数组元素总是会初始化为指定类型的 零值,在我们的例子里,[5]int 的初始化值为 0。我们可以通过索引下标取到数组里的每个元素,也可以通过内置函数 len() 来得到数组长度。

当你通过下标索引到数组里的某个元素并且做下面这样的操作时:

1
2
var arr [5]int
arr[4] = 42

你会取到第五个(4+1)元素并且改变它的值:

数组

现在,我们已经准备好来探索一下切片。

Slices

切片一眼看上去和数组很像,就连声明的语法也差不多:

1
var foo []int

但如果我们阅读一下 Go 的源码就会发现(src/runtime/slice.go)实际上切片的数据结构包括 3 个部分 - 指向数组的指针、切片的长度和切片的容量:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

当创建一个新的切片时,Go 运行时会在内存里创建这样一个包含 3 块区域的对象,并且会把数组指针初始化为 nillencap 初始化为 0。让我们来看看它的可视化表示:

切片-1

可以用 make 来初始化一个指定大小的切片:

1
foo = make([]int, 5)

这段代码会创建一个切片,包含了一个 5 个元素的数组,每个元素的初值为 0,lencap 的初值则为 5。
Cap 是指切片大小可以达到的上限,以便为未来可能的增长留出空间。可以用 make([]int, len, cap) 语法来指定容量。实际使用中你很可能永远不用去特别在意它,但这对我们理解容量的概念来说很重要。

1
foo = make([]int, 3, 5)

下面是两个例子:

切片-2

如果你要更改切片中某些元素的值,实际上是在改变切片指向的数组元素的值。

1
2
3
foo = make([]int, 5)
foo[3] = 42
foo[4] = 100
切片-3

这很好理解。让我们把情况弄得稍微复杂一点,在原切片的基础上创建一个子切片,然后改变子切片里元素的值?

1
2
3
4
5
foo = make([]int, 5)
foo[3] = 42
foo[4] = 100
bar := foo[1:4]
bar[1] = 99
切片-4

通过图示可以看到,我们更改了 bar 的元素值,实际上是改变了它所指向数组里的元素值,也就是 foo 也同时指向的那个数组。真实的情况也的确是这样,你可能会写出下面这样的代码:

1
2
3
4
5
var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
return digitRegexp.Find(b)
}

假设我们读取了 10MB 的数据放到切片中,但只在其中查找 3 个数字,直觉上我们可能会觉得这个操作只会返回 3 个字节 的数据,但恰恰相反,切片指向的数组不管容量多大都会整个保存在内存中。

切片-5

这关于 Go 切片的一个很常见的坑,你很可能无法准确的预料为了使用这个切片到底耗费了多少内存。但一旦你脑海里有了关于切片内部实现的可视化表示,我敢打赌几乎下次遇到这样的场景时你会信心十足。

Append

聊完切片本身,接下来我们看看切片的内置函数 append() 。它本质上的功能就是把一个元素值添加到切片中,但在内部实现里,为了在必要的时候做到智能高效的分配内存,它还做了不少复杂的操作。

看看下面这段代码:

1
2
a := make([]int, 32)
a = append(a, 1)

还记得 cap - 切片的容量么?容量代表着 切片可以达到的最大容量append 会检查切片的容量是否还允许扩展,如果可以则为切片分配更多的内存。分配内存是一个开销非常大的操作,所以当你使用 append 向切片添加一个 1 字节大小的元素,实际上 append 会尝试一次分配 32 字节,且每次扩容都会是原有容量的两倍。这是因为一次分配更多的内存通常都比多次分配少量内存的开销更小且速度更快。

这里令人困惑的是,由于各种原因,通常情况下分配更多的内存意味着首先在一个不同的内存地址申请一块新的足够大的内存空间,然后从当前的内存地址把数据复制到新的内存块中。也就是说切片所指向的数组的地址也会被改变。可视化表示如下:

append.png

很显然这样就会存在两个被指向的数组,原有的和新分配的。是不是嗅到了一丝「坑」的味道?原来的数组如果没有被其他的切片指向的话稍后就会被垃圾回收机制释放掉。在这个例子里,实际上就存在一个 append 操作引发的坑。如果我们创建了一个子切片 b,然后对 a 切片 append 一个值,这两个切片还会共同指向同一个数组么?

1
2
3
4
a := make([]int, 32)
b := a[1:16]
a = append(a, 1)
a[2] = 42
append2.png

(译者注:图中 bcap 值应为 31,子切片的 cap 应该等于起始位置到父切片末尾的容量)

通过图示结合我们上面所说的,切片 ab 会分别指向两个不同的数组,这对初学者来说可能有点反直觉。所以,使用子切片的时候要格外小心,尤其是伴随着 append 操作的时候,这算是一条经验之谈。


append 对切片扩容时,如果大小在 1024 字节以内,每次都会以双倍的大小来申请内存,但如果超过了 1024 字节则会使用所谓的 memory size classes 来保证增长的容量不会大于当前容量的 12.5%。因为对于大小为 32 字节的数组一次请求 64 字节的内存是没什么问题的,但如果切片的容量为 4GB 或更多,这时候添加一个新元素如果直接多分配出 4GB 的内存则显得代价太大,上面的规则就是考虑到了这样的情况。

接口(Interfaces)

这是对很多人来说最容易困惑的部分。需要花费不少时间来掌握和理解如何在 Go 里正确的使用接口,尤其是对在其他面向对象语言里有着惨痛经验的程序员来说。造成这种困惑的一个根源就是 nil 这个关键字在接口的上下文里会总是有着不同的含义。

为了理解这一部分,让我们再来看看源码。
接口的底层实现里到底有什么?这里是一段源码 src/runtime/runtime2.go 的摘抄:

1
2
3
4
type iface struct {
tab *itab
data unsafe.Pointer
}

itab 表示 interface table,它是一个保存接口和底层类型所需元数据的数据结构:

1
2
3
4
5
6
7
8
type itab struct {
inter *interfacetype
_type *_type
link *itab
bad int32
unused int32
fun [1]uintptr // 变量大小
}

我们并不打算深究接口类型断言的实现逻辑,但重要的是要理解 interface 是接口和静态类型信息加上指向实际变量的指针的复合体(iface 中的 data 字段)。我们来创建一个接口类型 error 的变量 err 并把它的结构可视化:

1
var err error
iface1.png

这张图里所展示的东西实际上就是传说中的 nil interface。当你在方法里返回 error 类型时,你返回的就是这个对象。它包含了接口的信息(itab.inter),但 data 字段和 itab.type 的值为 nil。当你使用 if err == nil {} 做判断时,这个对象会被判定为 true

1
2
3
4
5
6
func foo() error {
var err error // nil
return err
}
err := foo()
if err == nil {...} // true

一个广为人知的「坑」就是当你返回一个值为 nil*os.PathError 类型变量时。

1
2
3
4
5
6
func foo() error {
var err *os.PathError // nil
return err
}
err := foo()
if err == nil {...} // false

除非清楚的知道内存里接口的内部结构是什么样,否则上面这两段代码看起来几乎没有区别。现在来看看 nil 值的 *os.PathError 类型变量是如何被包裹在 error 接口里的。

iface2.png

可以清楚的看到 *os.PathError - 只是一块存放了 nil 值的内存块,因为指针的零值就是 nil。但实际上 foo() 返回的 error 是一个包含了关于接口、接口类型、以及存放了 nil 值的内存地址等信息的更复杂的结构。发现不一样的地方了么?

在上面两个例子里,我们都创建了 nil,但在 包含了一个值为 nil 的变量的接口不包含变量的接口 间存在着巨大的区别。有了这样对接口内部结构的认识,再来看看这两个例子容易混淆的例子:

iface3.png

现在应该对类似的问题不会再感到困惑了。

空接口(Empty interface)

接下来我们来说说 空接口(empty interface) - interface{}。在 Go 的源码中 (src/runtime/malloc.go 用了一个自有的结构 eface 来实现:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

它和 iface 很像,不过缺少接口表 interface table。因为从定义上讲空接口由任意静态类型实现,所以 eface 并不需要接口表。当你尝试显示或隐式地(比如当做方法参数传递)封装一些东西到 interface{} 时,内存里存储的实际上是这样的结构:

1
2
3
4
func foo() interface{} {
foo := int64(42)
return foo
}
eface.png

空接口 interface{} 有个比较蛋疼的问题是,不能方便的把接口切片赋值给混合类型的切片,反之亦然。比如:

1
2
3
func foo() []interface{} {
return []int{1,2,3}
}

这段代码将导致一个编译错误:

1
2
$ go build
cannot use []int literal (type []int) as type []interface {} in return argument

一开始这会很令人困惑。为什么我们可以在单个变量时直接做转换,而在切片类型里却不行?一旦我们知道了空接口本质上是什么(再看一眼上面的图示),就会十分清楚缘由,这样的『转换』实在是一个成本非常高的操作,涉及到分配大量的内存以及 O(n) 左右的时间和空间复杂度。而且 Go 的设计原则中有一条就是 如果需要做一些开销很大的操作 - 光明正大的做(显式而非隐式的做)

eface_slice.png

结论

不是每个坑都需要通过学习 Go 的内部实现来了解透彻。有一些仅仅只是因为过去的经验和 Go 的玩法有些不一样,毕竟我们每个人或多或少都有着不同的背景和经验。不过,只要稍微深入的理解 Go 的内部工作原理,就能避免掉进绝大多数陷阱里。
希望本篇文章里的各种解释可以帮大家建立起对 Go 程序内部运行机制的直觉印象,相信这些知识可以帮助我们成为更棒的开发者。Go 是人类的好朋友,了解更多一点更深一点并不会有什么损失 :)

如果还想更多的了解 Go 的内部实现,我精选了一些文章列在这里:

当然,怎么也不能忘了这些只传有缘人的宝典 :)

Happy hacking!

另外,作者 11 月 16 号做了一个相关的分享 Golang BCN

有兴趣的可以看看这次分享的幻灯片: How To Avoid Go Gotchas.pdf

关注 NewtonIO - 创造者们的技术与工具