这儿几个字节，那里几个字节，我们说的是真正的内存
今天的帖子来自于最近的 Go 语言的一次小测试，观察下面的测试基础片段 [1] ：func BenchmarkSortStrings(b *testing.B) { s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} b.ReportAllocs() for i := 0; i < b.N; i++ { sort.Strings(s) } } sort.Strings 是 sort.StringSlice(s) 的便捷包装器，sort.Strings 在原地对输入进行排序，因此不会分配内存（或至少 43% 回答此问题的 Twitter 用户是这么认为的）。然而，至少在 Go 的最近版本中，基准测试的每次迭代都会导致一次堆分配。为什么会是这种情况？正如所有 Go 程序员应该知道的那样，接口是以 双词结构 实现的。每个接口值包含一个字段，其中保存接口内容的类型，以及指向接口内容的指针。 [2] 在 Go 语言伪代码中，一个接口可能是这样的：type interface struct { // the ordinal number for the type of the value // assigned to the interface type uintptr // (usually) a pointer to the value assigned to // the interface data uintptr } interface.data 可以容纳一个机器字（在大多数情况下为 8 个字节），但一个 []string 却需要 24 个字节：一个字用于指向切片的底层数组；一个字用于存储切片的长度；另一个字用于存储底层数组的剩余容量。那么，Go 是如何将 24 个字节装入个 8 个字节的呢？通过编程中最古老的技巧，即间接引用。一个 []string，即 s，需要 24 个字节；但 *[]string —— 即指向字符串切片的指针，只需要 8 个字节。逃逸到堆为了让示例更加明确，以下是重新编写的基准测试，不使用 sort.Strings 辅助函数：func BenchmarkSortStrings(b *testing.B) { s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} b.ReportAllocs() for i := 0; i < b.N; i++ { var ss sort.StringSlice = s var si sort.Interface = ss // allocation sort.Sort(si) } } 为了让接口正常运行，编译器将赋值重写为 var si sort.Interface = &ss，即 ss 的地址分配给接口值。 [3] 我们现在有这么一种情况：出现一个持有指向 ss 的指针的接口值。它指向哪里？还有 ss 存储在哪个内存位置？似乎 ss 被移动到了堆上，这也同时导致了基准测试报告中的分配：Total: 296.01MB 296.01MB (flat, cum) 99.66% 8 . . func BenchmarkSortStrings(b *testing.B) { 9 . . s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} 10 . . b.ReportAllocs() 11 . . for i := 0; i < b.N; i++ { 12 . . var ss sort.StringSlice = s 13 296.01MB 296.01MB var si sort.Interface = ss // allocation 14 . . sort.Sort(si) 15 . . } 16 . . } 发生这种分配是因为编译器当前无法确认 ss 比 si 生存期更长。Go 编译器开发人员对此的普遍态度是，觉得 这个问题改进的余地，不过我们另找时间再议。事实上，ss 就是被分配到了堆上。因此，问题变成了：每次迭代会分配多少个字节？为什么不去询问 testing 包呢？% go test -bench=. sort_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz BenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/op PASS ok command-line-arguments 1.260s 可以看到，在 amd 64 平台的 Go 1.16 beta1 版本上，每次操作会分配 24 字节。 [4] 然而，在同一平台先前的 Go 版本中，每次操作则消耗了 32 字节。% go1.15 test -bench=. sort_test.go goos: darwin goarch: amd64 BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/op PASS ok command-line-arguments 1.225s 这引出了本文的主题，即 Go 1.16 版本中即将推出的一项便利改进。不过在讨论这个内容之前，我需要聊聊 “ 尺寸类别 size class ”。尺寸类别在解释什么是 “ 尺寸类别 size class ” 之前，我们先考虑个问题，理论上的 Go 语言在运行时是如何在其堆上分配 24 字节的。有一个简单的方法：追踪目前为止已分配到的所有内存的动向——利用指向堆上最后分配的字节的指针。分配 24 字节，堆指针就会增加 24，然后将前一个值返回给调用函数。只要写入的请求 24 字节的代码不超出该标记的范围，这种机制就没有额外开销。不过，现实情况下，内存分配器不仅要分配内存，有时还得释放内存。最终，Go 语言程序在运行时将释放这些 24 字节，但从运行的视角来看，它只知道它给调用者的开始地址。它不知道从该地址起始之后又分配了多少字节。为了允许释放内存，我们假设的 Go 语言程序运行时分配器必须记录堆上每个分配的长度值。那么这些长度值的分配存储在何处？当然是在堆上。在我们的设想中，当程序运行需要分配内存的时候，它可以请求稍微多一点，并把它用来存储请求的数量。而对于我们的切片示例而言，当我们请求 24 字节时，实际上会消耗 24 字节加上存储数字 24 的一些开销。这些开销有多大？事实上，实际上的最小开销量是一个字。 [5] 用来记录 24 字节分配的开销将是 8 字节。25% 不是很大，但也不算糟糕，随着分配的大小增加，开销将变得微不足道。然而，如果我们只想在堆上存储一个字节，会发生什么？开销将是请求数据量的 8 倍！是否有一种更高效的方式在堆上分配少量内存？与其在每个分配旁边存储长度，不如将相同大小的内容存储在一起，这个主意如何？如果所有的 24 字节的内容都存储在一起，那么运行时会自动获取它们的大小。运行时所需要的是一个单一的位，指示 24 字节区域是否在使用中。在 Go 语言中，这些区域被称为 Size Classes，因为相同大小的所有内容都会存储在一起（类似学校班级，所有学生都按同一年级分班，而不是 C++ 中的类）。当运行时需要分配少量内存时，它会使用能够容纳该分配的最小的尺寸类别。无限制的尺寸类别现在我们知道尺寸类别是如何工作的了，那么问题又来了，它们存储在哪里？和我们想的一样，尺寸类别的内存来自堆。为了最小化开销，运行时会从堆上分配较大的内存块（通常是系统页面大小的倍数），然后将该空间用于单个大小的分配。不过，这里存在一个问题————将大块区域用于存储同一大小的事物的模式很好用 [6] ，如果分配大小的数量是固定的，最好是少数几个。那么在通用语言中，程序可以要求运行时以任何大小分配内存 [7] 。例如，想象一下向运行时请求 9 字节。9 字节是一个不常见的大小，因此可能需要一个新的尺寸类别来存储 9 字节大小的物品。因为 9 字节大小的物品不常见，所以分配的其余部分（通常为 4KB 或更多）可能会被浪费。由于尺寸类别的集合是固定的，如果没有精确匹配的 size class 可用，分配将并入到下一个尺寸类别。在我们的示例中，9 字节可能会在 12 字节的尺寸类别中分配。未使用的 3 字节的开销要比几乎未使用的整个尺寸类别分配好。总结一下这是谜题的最后一块拼图。Go 1.15 版本没有 24 字节的尺寸类别，因此 ss 的堆分配是在 32 字节的尺寸类别中分配的。由于 Martin Möhrmann 的工作，Go 1.16 版本有一个 24 字节的尺寸类别，非常适合分配给接口的切片值。相关文章我在 Devfest 2017年西伯利亚大会谈 Go 语言如果对齐的内存写操作是原子性的，为什么我们还需要 sync/atomic 包呢？为你的树莓派创建一个真实的串行控制台为什么 Go 语言线程的栈是无限制的？（题图：MJ/01d5fe46-778f-48fe-9481-162f4d0289dc）这不是正确的对排序函数进行基准测试的方式，因为在第一次迭代之后，输入已经排序。但这又是另外一个话题了。 ↩︎此语句的准确性取决于所使用的 Go 版本。例如，Go 1.15 版本添加了直接将一些 整数存储在接口值 中的功能，从而节省了分配和间接性。然而，对于大多数值来说，如果它不是指针类型，它的地址将被取出并存储在接口值中。 ↩︎编译器在接口值的类型字段中跟踪了这种手法，因此它记住了分配给 si 的类型是 sort.StringSlice 而不是 *sort.StringSlice。 ↩︎在 32 位平台上，这个数字减半，但我们不再关注它。 ↩︎如果你准备限制分配为 4G 或者可能是 64KB，你可以使用较少内存来存储分配的尺寸，但实际上使用小于一个字来存储长度标头的节省会受到填充的影响。 ↩︎将相同大小的物品存储在一起也是一种有效的对抗碎片化的策略。 ↩︎这并不是一个不切实际的设想，字符串有各种形状和大小，生成以前未见过的大小的字符串可能就像附加空格一样简单。 ↩︎via: https://dave.cheney.net/2021/01/05/a-few-bytes-here-a-few-there-pretty-soon-youre-talking-real-memory作者：Dave Cheney 选题：lujun9972 译者：Drwhooooo 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
Go 编译器的 SSA 后端包含一种工具，可以生成编译阶段的 HTML 调试输出。这篇文章介绍了如何为函数和方法打印 SSA 输出。让我们从一个包含函数、值方法和指针方法的示例程序开始：package main import ( "fmt" ) type Numbers struct { vals []int } func (n *Numbers) Add(v int) { n.vals = append(n.vals, v) } func (n Numbers) Average() float64 { sum := 0.0 for _, num := range n.vals { sum += float64(num) } return sum / float64(len(n.vals)) } func main() { var numbers Numbers numbers.Add(200) numbers.Add(43) numbers.Add(-6) fmt.Println(numbers.Average()) } 通过 GOSSAFUNC 环境变量控制 SSA 调试输出。此变量含有要转储的函数的名称。这不是函数的完全限定名。对于上面的 func main，函数名称为 main 而不是 main.main。% env GOSSAFUNC=main go build runtime dumped SSA to ../../go/src/runtime/ssa.html t dumped SSA to ./ssa.html 在这个例子中，GOSSAFUNC=main 同时匹配了 main.main 和一个名为 runtime.main 的函数。 [1] 这有点不走运，但是实际上可能没什么大不了的，因为如果你要对代码进行性能调整，它就不会出现在 func main 中的巨大的意大利面块中。你的代码更有可能在方法中，你可能已经看到这篇文章，并寻找能够转储方法的 SSA 输出。要为指针方法 func (n *Numbers) Add 打印 SSA 调试，等效函数名为 (*Numbers).Add： [2] % env "GOSSAFUNC=(*Numbers).Add" go build t dumped SSA to ./ssa.html 要为值方法 func (n Numbers) Average 打印 SSA 调试，等效函数名为 (*Numbers).Average，即使这是一个值方法：% env "GOSSAFUNC=(*Numbers).Average" go build t dumped SSA to ./ssa.html 如果你没有从源码构建 Go，那么 runtime 软件包的路径可能是只读的，并且可能会收到错误消息。请不要使用 sudo 来解决此问题。 ↩︎请注意 shell 引用 ↩︎via: https://dave.cheney.net/2020/06/19/how-to-dump-the-gossafunc-graph-for-a-method作者：Dave Cheney 选题：lujun9972 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
给开源项目贡献代码时：先讨论，再编码
我所参与的开源项目遵循的是一种这样的理念，我把它描述为 “先讨论，再编码”。我认为一般来说这是开发软件的好方法，我想花一点时间来谈谈这种方法的好处。避免伤害感情先讨论你想做的改变最重要的原因是避免伤害感情。我经常看到一个贡献者闭门造车地提交了一个 PR，却发现他的努力工作被拒绝了。这可能有一堆原因：PR 太大了，PR 没有遵循本地风格，PR 修复了一个对项目不重要的问题或者最近间接修复了的问题，等等。这些问题的根本原因都是缺乏沟通。“先讨论，再编码” 理念的目标不是阻碍你或给你带来挫折，而是确保一个功能第一次就能正确落地，而不至于产生大量的维护债务。无论是改动的作者，还是审查者，当一个改动突然出现时，并暗示说 “好吧，我已经做完了，你要做的就是合并它，对吧？”，先讨论可以让他们不必背负伤害感情的情绪负担。讨论应该如何进行？每一个新功能或错误修复都应该在工作开始前与项目的维护者讨论。私下试验是可以的，但不要在没有讨论之前就发送修改。对于简单的改动，“讨论” 的定义可以只是 GitHub 议题中的一个设计草图。如果你的 PR 修复了一个 bug，你应该链接到它修复的 bug。如果没有，你应该先提出一个 bug，等待维护者确认后再发送 PR。这可能看起来有点落后 —— 谁不希望一个 bug 被修复呢 —— 但考虑到这个 bug 可能是对软件工作方式的误解，也可能是一个更大问题的症状，这需要进一步调查。对于比较复杂的改动，尤其是功能请求，我建议在发送代码之前，先分发一份设计文档并达成一致。这不一定是一个完整的文档，发一个议题，带个草图可能就足够了，但关键是在用代码搞定之前，先用文字达成一致。在任何情况下，你都不应该继续发送你的代码，直到维护者同意你的方法是他们所满意的。拉取请求是日常生活，而不仅仅是为了赶着过节。代码审查，而不是由委员会设计代码审查不是争论设计的地方。这有两个原因。首先，大多数代码审查工具都不适合长长的评论会话，GitHub 的 PR 界面在这方面非常糟糕，Gerrit 比较好，但很少有管理员团队会维护 Gerrit 实例。更重要的是，在代码审查阶段就出现了分歧，说明大家对如何实现这个变化并没有达成一致。讨论你想写的代码，然后再写你所讨论的代码。请不要反其道而行之。via: https://dave.cheney.net/2019/02/18/talk-then-code作者：Dave Cheney 选题：lujun9972 译者：wxy 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
大家常规的认知是，Go 程序中声明的类型越多，生成的二进制文件就越大。这个符合直觉，毕竟如果你写的代码不去操作定义的类型，那么定义一堆类型就没有意义了。然而，链接器的部分工作就是检测没有被程序引用的函数（比如说它们是一个库的一部分，其中只有一个子集的功能被使用），然后把它们从最后的编译产出中删除。常言道，“类型越多，二进制文件越大”，对于多数 Go 程序还是正确的。本文中我会深入讲解在 Go 程序的上下文中“相等”的意义，以及为什么像这样的修改会对 Go 程序的大小有重大的影响。定义两个值相等Go 的语法定义了“赋值”和“相等”的概念。赋值是把一个值赋给一个标识符的行为。并不是所有声明的标识符都可以被赋值，如常量和函数就不可以。相等是通过检查标识符的内容是否相等来比较两个标识符的行为。作为强类型语言，“相同”的概念从根源上被植入标识符的类型中。两个标识符只有是相同类型的前提下，才有可能相同。除此之外，值的类型定义了如何比较该类型的两个值。例如，整型是用算数方法进行比较的。对于指针类型，是否相等是指它们指向的地址是否相同。映射和通道等引用类型，跟指针类似，如果它们指向相同的地址，那么就认为它们是相同的。上面都是按位比较相等的例子，即值占用的内存的位模式是相同的，那么这些值就相等。这就是所谓的 memcmp，即内存比较，相等是通过比较两个内存区域的内容来定义的。记住这个思路，我过会儿再来谈。结构体相等除了整型、浮点型和指针等标量类型，还有复合类型：结构体。所有的结构体以程序中的顺序被排列在内存中。因此下面这个声明：type S struct { a, b, c, d int64 }会占用 32 字节的内存空间；a 占用 8 个字节，b 占用 8 个字节，以此类推。Go 的规则说如果结构体所有的字段都是可以比较的，那么结构体的值就是可以比较的。因此如果两个结构体所有的字段都相等，那么它们就相等。a := S{1, 2, 3, 4} b := S{1, 2, 3, 4} fmt.Println(a == b) // 输出 true编译器在底层使用 memcmp 来比较 a 的 32 个字节和 b 的 32 个字节。填充和对齐然而，在下面的场景下过分简单化的按位比较的策略会返回错误的结果：type S struct { a byte b uint64 c int16 d uint32 } func main() a := S{1, 2, 3, 4} b := S{1, 2, 3, 4} fmt.Println(a == b) // 输出 true }编译代码后，这个比较表达式的结果还是 true，但是编译器在底层并不能仅依赖比较 a 和 b 的位模式，因为结构体有填充。Go 要求结构体的所有字段都对齐。2 字节的值必须从偶数地址开始，4 字节的值必须从 4 的倍数地址开始，以此类推 1 。编译器根据字段的类型和底层平台加入了填充来确保字段都对齐。在填充之后，编译器实际上看到的是 2 ：type S struct { a byte _ [7]byte // 填充 b uint64 c int16 _ [2]int16 // 填充 d uint32 }填充的存在保证了字段正确对齐，而填充确实占用了内存空间，但是填充字节的内容是未知的。你可能会认为在 Go 中 填充字节都是 0，但实际上并不是 — 填充字节的内容是未定义的。由于它们并不是被定义为某个确定的值，因此按位比较会因为分布在 s 的 24 字节中的 9 个填充字节不一样而返回错误结果。Go 通过生成所谓的相等函数来解决这个问题。在这个例子中，s 的相等函数只比较函数中的字段略过填充部分，这样就能正确比较类型 s 的两个值。类型算法呵，这是个很大的设置，说明了为什么，对于 Go 程序中定义的每种类型，编译器都会生成几个支持函数，编译器内部把它们称作类型的算法。如果类型是一个映射的键，那么除相等函数外，编译器还会生成一个哈希函数。为了维持稳定，哈希函数在计算结果时也会像相等函数一样考虑诸如填充等因素。凭直觉判断编译器什么时候生成这些函数实际上很难，有时并不明显，（因为）这超出了你的预期，而且链接器也很难消除没有被使用的函数，因为反射往往导致链接器在裁剪类型时变得更保守。通过禁止比较来减小二进制文件的大小现在，我们来解释一下 Brad 的修改。向类型添加一个不可比较的字段 3 ，结构体也随之变成不可比较的，从而强制编译器不再生成相等函数和哈希函数，规避了链接器对那些类型的消除，在实际应用中减小了生成的二进制文件的大小。作为这项技术的一个例子，下面的程序：package main import "fmt" func main() { type t struct { // _ [0][]byte // 取消注释以阻止比较 a byte b uint16 c int32 d uint64 } var a t fmt.Println(a) }用 Go 1.14.2（darwin/amd64）编译，大小从 2174088 降到了 2174056，节省了 32 字节。单独看节省的这 32 字节似乎微不足道，但是考虑到你的程序中每个类型及其传递闭包都会生成相等和哈希函数，还有它们的依赖，这些函数的大小随类型大小和复杂度的不同而不同，禁止它们会大大减小最终的二进制文件的大小，效果比之前使用 -ldflags="-s -w" 还要好。最后总结一下，如果你不想把类型定义为可比较的，可以在源码层级强制实现像这样的奇技淫巧，会使生成的二进制文件变小。附录：在 Brad 的推动下，Cherry Zhang 和 Keith Randall 已经在 Go 1.15 做了大量的改进，修复了最严重的故障，消除了无用的相等和哈希函数（虽然我猜想这也是为了避免这类 CL 的扩散）。相关文章：Go 运行时如何高效地实现映射（不使用泛型）")空结构体填充很难Go 中有类型的 nil（2）在 32 位平台上 int64 和 unit64 的值可能不是按 8 字节对齐的，因为平台原生的是以 4 字节对齐的。查看 议题 599 了解内部详细信息。 ↩32 位平台会在 a 和 b 的声明中填充 _ [3]byte。参见前一条。 ↩Brad 使用的是[0]func()，但是所有能限制和禁止比较的类型都可以。添加了一个有 0 个元素的数组的声明后，结构体的大小和对齐不会受影响。 ↩via: https://dave.cheney.net/2020/05/09/ensmallening-go-binaries-by-prohibiting-comparisons作者：Dave Cheney 选题：lujun9972 译者：lxbwolf 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
上一篇文章中我论述了 叶子内联 leaf inlining 是怎样让 Go 编译器减少函数调用的开销的，以及延伸出了跨函数边界的优化的机会。本文中，我要论述内联的限制以及叶子内联与 栈中内联 mid-stack inlining 的对比。内联的限制把函数内联到它的调用处消除了调用的开销，为编译器进行其他的优化提供了更好的机会，那么问题来了，既然内联这么好，内联得越多开销就越少，为什么不尽可能多地内联呢？内联可能会以增加程序大小换来更快的执行时间。限制内联的最主要原因是，创建许多函数的内联副本会增加编译时间，并导致生成更大的二进制文件的边际效应。即使把内联带来的进一步的优化机会考虑在内，太激进的内联也可能会增加生成的二进制文件的大小和编译时间。内联收益最大的是小函数，相对于调用它们的开销来说，这些函数做很少的工作。随着函数大小的增长，函数内部做的工作与函数调用的开销相比省下的时间越来越少。函数越大通常越复杂，因此优化其内联形式相对于原地优化的好处会减少。内联预算在编译过程中，每个函数的内联能力是用内联预算计算的 1 。开销的计算过程可以巧妙地内化，像一元和二元等简单操作，在 抽象语法数 Abstract Syntax Tree （AST）中通常是每个节点一个单位，更复杂的操作如 make 可能单位更多。考虑下面的例子：package main func small() string { s := "hello, " + "world!" return s } func large() string { s := "a" s += "b" s += "c" s += "d" s += "e" s += "f" s += "g" s += "h" s += "i" s += "j" s += "k" s += "l" s += "m" s += "n" s += "o" s += "p" s += "q" s += "r" s += "s" s += "t" s += "u" s += "v" s += "w" s += "x" s += "y" s += "z" return s } func main() { small() large() }使用 -gcflags=-m=2 参数编译这个函数能让我们看到编译器分配给每个函数的开销：% go build -gcflags=-m=2 inl.go # command-line-arguments ./inl.go:3:6: can inline small with cost 7 as: func() string { s := "hello, world!"; return s } ./inl.go:8:6: cannot inline large: function too complex: cost 82 exceeds budget 80 ./inl.go:38:6: can inline main with cost 68 as: func() { small(); large() } ./inl.go:39:7: inlining call to small func() string { s := "hello, world!"; return s }编译器根据函数 func small() 的开销（7）决定可以对它内联，而 func large() 的开销太大，编译器决定不进行内联。func main() 被标记为适合内联的，分配了 68 的开销；其中 small 占用 7，调用 small 函数占用 57，剩余的（4）是它自己的开销。可以用 -gcflag=-l 参数控制内联预算的等级。下面是可使用的值：-gcflags=-l=0 默认的内联等级。-gcflags=-l（或 -gcflags=-l=1）取消内联。-gcflags=-l=2 和 -gcflags=-l=3 现在已经不使用了。和 -gcflags=-l=0 相比没有区别。-gcflags=-l=4 减少非叶子函数和通过接口调用的函数的开销。 2 不确定语句的优化一些函数虽然内联的开销很小，但由于太复杂它们仍不适合进行内联。这就是函数的不确定性，因为一些操作的语义在内联后很难去推导，如 recover、break。其他的操作，如 select 和 go 涉及运行时的协调，因此内联后引入的额外的开销不能抵消内联带来的收益。不确定的语句也包括 for 和 range，这些语句不一定开销很大，但目前为止还没有对它们进行优化。栈中函数优化在过去，Go 编译器只对叶子函数进行内联 —— 只有那些不调用其他函数的函数才有资格。在上一段不确定的语句的探讨内容中，一次函数调用就会让这个函数失去内联的资格。进入栈中进行内联，就像它的名字一样，能内联在函数调用栈中间的函数，不需要先让它下面的所有的函数都被标记为有资格内联的。栈中内联是 David Lazar 在 Go 1.9 中引入的，并在随后的版本中做了改进。这篇文稿深入探究了保留栈追踪行为和被深度内联后的代码路径里的 runtime.Callers 的难点。在前面的例子中我们看到了栈中函数内联。内联后，func main() 包含了 func small() 的函数体和对 func large() 的一次调用，因此它被判定为非叶子函数。在过去，这会阻止它被继续内联，虽然它的联合开销小于内联预算。栈中内联的最主要的应用案例就是减少贯穿函数调用栈的开销。考虑下面的例子：package main import ( "fmt" "strconv" ) type Rectangle struct {} //go:noinline func (r *Rectangle) Height() int { h, _ := strconv.ParseInt("7", 10, 0) return int(h) } func (r *Rectangle) Width() int { return 6 } func (r *Rectangle) Area() int { return r.Height() * r.Width() } func main() { var r Rectangle fmt.Println(r.Area()) }在这个例子中， r.Area() 是个简单的函数，调用了两个函数。r.Width() 可以被内联，r.Height() 这里用 //go:noinline 指令标注了，不能被内联。 3 % go build -gcflags='-m=2' square.go # command-line-arguments ./square.go:12:6: cannot inline (*Rectangle).Height: marked go:noinline ./square.go:17:6: can inline (*Rectangle).Width with cost 2 as: method(*Rectangle) func() int { return 6 } ./square.go:21:6: can inline (*Rectangle).Area with cost 67 as: method(*Rectangle) func() int { return r.Height() * r.Width() } ./square.go:21:61: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 } ./square.go:23:6: cannot inline main: function too complex: cost 150 exceeds budget 80 ./square.go:25:20: inlining call to (*Rectangle).Area method(*Rectangle) func() int { return r.Height() * r.Width() } ./square.go:25:20: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 }由于 r.Area() 中的乘法与调用它的开销相比并不大，因此内联它的表达式是纯收益，即使它的调用的下游 r.Height() 仍是没有内联资格的。快速路径内联关于栈中内联的效果最令人吃惊的例子是 2019 年 Carlo Alberto Ferraris 通过允许把 sync.Mutex.Lock() 的快速路径（非竞争的情况）内联到它的调用方来提升它的性能。在这个修改之前，sync.Mutex.Lock() 是个很大的函数，包含很多难以理解的条件，使得它没有资格被内联。即使锁可用时，调用者也要付出调用 sync.Mutex.Lock() 的代价。Carlo 把 sync.Mutex.Lock() 分成了两个函数（他自己称为 外联 outlining ）。外部的 sync.Mutex.Lock() 方法现在调用 sync/atomic.CompareAndSwapInt32() 且如果 CAS（ 比较并交换 Compare and Swap ）成功了之后立即返回给调用者。如果 CAS 失败，函数会走到 sync.Mutex.lockSlow() 慢速路径，需要对锁进行注册，暂停 goroutine。 4 % go build -gcflags='-m=2 -l=0' sync 2>&1 | grep '(*Mutex).Lock' ../go/src/sync/mutex.go:72:6: can inline (*Mutex).Lock with cost 69 as: method(*Mutex) func() { if "sync/atomic".CompareAndSwapInt32(&m.state, 0, mutexLocked) { if race.Enabled { }; return }; m.lockSlow() }通过把函数分割成一个简单的不能再被分割的外部函数，和（如果没走到外部函数就走到的）一个处理慢速路径的复杂的内部函数，Carlo 组合了栈中函数内联和编译器对基础操作的支持，减少了非竞争锁 14% 的开销。之后他在 sync.RWMutex.Unlock() 重复这个技巧，节省了另外 9% 的开销。相关文章：Go 中的内联优化goroutine 的栈为什么会无限增长？栈追踪和 errors 包零值是什么，为什么它很有用？不同发布版本中，在考虑该函数是否适合内联时，Go 编译器对同一函数的预算是不同的。 ↩时刻记着编译器的作者警告过“更高的内联等级（比 -l 更高）可能导致错误或不被支持”。 Caveat emptor。 ↩编译器有足够的能力来内联像 strconv.ParseInt 的复杂函数。作为一个实验，你可以尝试去掉 //go:noinline 注释，使用 -gcflags=-m=2 编译后观察。 ↩race.Enable 表达式是通过传递给 go 工具的 -race 参数控制的一个常量。对于普通编译，它的值是 false，此时编译器可以完全省略代码路径。 ↩via: https://dave.cheney.net/2020/05/02/mid-stack-inlining-in-go作者：Dave Cheney 选题：lujun9972 译者：lxbwolf 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
本文讨论 Go 编译器是如何实现内联的，以及这种优化方法如何影响你的 Go 代码。请注意：本文重点讨论 gc，这是来自 golang.org 的事实标准的 Go 编译器。讨论到的概念可以广泛适用于其它 Go 编译器，如 gccgo 和 llgo，但它们在实现方式和功效上可能有所差异。内联是什么？ 内联 inlining 就是把简短的函数在调用它的地方展开。在计算机发展历程的早期，这个优化是由程序员手动实现的。现在，内联已经成为编译过程中自动实现的基本优化过程的其中一步。为什么内联很重要？有两个原因。第一个是它消除了函数调用本身的开销。第二个是它使得编译器能更高效地执行其他的优化策略。函数调用的开销在任何语言中，调用一个函数 1 都会有消耗。把参数编组进寄存器或放入栈中（取决于 ABI），在返回结果时的逆反过程都会有开销。引入一次函数调用会导致程序计数器从指令流的一点跳到另一点，这可能导致管道滞后。函数内部通常有 前置处理 preamble ，需要为函数执行准备新的栈帧，还有与前置相似的 后续处理 epilogue ，需要在返回给调用方之前释放栈帧空间。在 Go 中函数调用会消耗额外的资源来支持栈的动态增长。在进入函数时，goroutine 可用的栈空间与函数需要的空间大小进行比较。如果可用空间不同，前置处理就会跳到 运行时 runtime 的逻辑中，通过把数据复制到一块新的、更大的空间的来增长栈空间。当这个复制完成后，运行时就会跳回到原来的函数入口，再执行栈空间检查，现在通过了检查，函数调用继续执行。这种方式下，goroutine 开始时可以申请很小的栈空间，在有需要时再申请更大的空间。 2 这个检查消耗很小，只有几个指令，而且由于 goroutine 的栈是成几何级数增长的，因此这个检查很少失败。这样，现代处理器的分支预测单元可以通过假定检查肯定会成功来隐藏栈空间检查的消耗。当处理器预测错了栈空间检查，不得不放弃它在推测性执行所做的操作时，与为了增加 goroutine 的栈空间运行时所需的操作消耗的资源相比，管道滞后的代价更小。虽然现代处理器可以用预测性执行技术优化每次函数调用中的泛型和 Go 特定的元素的开销，但那些开销不能被完全消除，因此在每次函数调用执行必要的工作过程中都会有性能消耗。一次函数调用本身的开销是固定的，与更大的函数相比，调用小函数的代价更大，因为在每次调用过程中它们做的有用的工作更少。因此，消除这些开销的方法必须是要消除函数调用本身，Go 的编译器就是这么做的，在某些条件下通过用函数的内容来替换函数调用来实现。这个过程被称为内联，因为它在函数调用处把函数体展开了。改进的优化机会Cliff Click 博士把内联描述为现代编译器做的优化措施，像常量传播（LCTT 译注：此处作者笔误，原文为 constant proportion，修正为 constant propagation）和死代码消除一样，都是编译器的基本优化方法。实际上，内联可以让编译器看得更深，使编译器可以观察调用的特定函数的上下文内容，可以看到能继续简化或彻底消除的逻辑。由于可以递归地执行内联，因此不仅可以在每个独立的函数上下文处进行这种优化决策，也可以在整个函数调用链中进行。实践中的内联下面这个例子可以演示内联的影响：package main import "testing" //go:noinline func max(a, b int) int { if a > b { return a } return b } var Result int func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = max(-1, i) } Result = r }运行这个基准，会得到如下结果： 3 % go test -bench=. BenchmarkMax-4 530687617 2.24 ns/op在我的 2015 MacBook Air 上 max(-1, i) 的耗时约为 2.24 纳秒。现在去掉 //go:noinline 编译指令，再看下结果：% go test -bench=. BenchmarkMax-4 1000000000 0.514 ns/op从 2.24 纳秒降到了 0.51 纳秒，或者从 benchstat 的结果可以看出，有 78% 的提升。% benchstat {old,new}.txt name old time/op new time/op delta Max-4 2.21ns ± 1% 0.49ns ± 6% -77.96% (p=0.000 n=18+19)这个提升是从哪儿来的呢？首先，移除掉函数调用以及与之关联的前置处理 4 是主要因素。把 max 函数的函数体在调用处展开，减少了处理器执行的指令数量并且消除了一些分支。现在由于编译器优化了 BenchmarkMax，因此它可以看到 max 函数的内容，进而可以做更多的提升。当 max 被内联后，BenchmarkMax 呈现给编译器的样子，看起来是这样的：func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { if -1 > i { r = -1 } else { r = i } } Result = r }再运行一次基准，我们看一下手动内联的版本和编译器内联的版本的表现：% benchstat {old,new}.txt name old time/op new time/op delta Max-4 2.21ns ± 1% 0.48ns ± 3% -78.14% (p=0.000 n=18+18)现在编译器能看到在 BenchmarkMax 里内联 max 的结果，可以执行以前不能执行的优化措施。例如，编译器注意到 i 初始值为 0，仅做自增操作，因此所有与 i 的比较都可以假定 i 不是负值。这样条件表达式 -1 > i 永远不是 true。 5 证明了 -1 > i 永远不为 true 后，编译器可以把代码简化为：func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { if false { r = -1 } else { r = i } } Result = r }并且因为分支里是个常量，编译器可以通过下面的方式移除不会走到的分支：func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = i } Result = r }这样，通过内联和由内联解锁的优化过程，编译器把表达式 r = max(-1, i)) 简化为 r = i。内联的限制本文中我论述的内联称作 叶子内联 leaf inlining ：把函数调用栈中最底层的函数在调用它的函数处展开的行为。内联是个递归的过程，当把函数内联到调用它的函数 A 处后，编译器会把内联后的结果代码再内联到 A 的调用方，这样持续内联下去。例如，下面的代码：func BenchmarkMaxMaxMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = max(max(-1, i), max(0, i)) } Result = r }与之前的例子中的代码运行速度一样快，因为编译器可以对上面的代码重复地进行内联，也把代码简化到 r = i 表达式。下一篇文章中，我会论述当 Go 编译器想要内联函数调用栈中间的某个函数时选用的另一种内联策略。最后我会论述编译器为了内联代码准备好要达到的极限，这个极限 Go 现在的能力还达不到。相关文章：使 Go 变快的 5 件事为什么 Goroutine 的栈空间会无限增长？Go 中怎么写基准测试Go 中隐藏的编译指令在 Go 中，一个方法就是一个有预先定义的形参和接受者的函数。假设这个方法不是通过接口调用的，调用一个无消耗的函数所消耗的代价与引入一个方法是相同的。 ↩在 Go 1.14 以前，栈检查的前置处理也被垃圾回收器用于 STW，通过把所有活跃的 goroutine 栈空间设为 0，来强制它们切换为下一次函数调用时的运行时状态。这个机制最近被替换为一种新机制，新机制下运行时可以不用等 goroutine 进行函数调用就可以暂停 goroutine。 ↩我用 //go:noinline 编译指令来阻止编译器内联 max。这是因为我想把内联 max 的影响与其他影响隔离开，而不是用 -gcflags='-l -N' 选项在全局范围内禁止优化。关于 //go: 注释在这篇文章中详细论述。 ↩你可以自己通过比较 go test -bench=. -gcflags=-S 有无 //go:noinline 注释时的不同结果来验证一下。 ↩你可以用 -gcflags=-d=ssa/prove/debug=on 选项来自己验证一下。 ↩via: https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go作者：Dave Cheney 选题：lujun9972 译者：lxbwolf 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
Anthony Starks 使用他出色的 Deck 演示工具重构了我原来的基于 Google Slides 的幻灯片。你可以在他的博客上查看他重构后的幻灯片, mindchunk.blogspot.com.au/2014/06/remixing-with-deck。我最近被邀请在 Gocon 发表演讲，这是一个每半年在日本东京举行的 Go 的精彩大会。Gocon 2014 是一个完全由社区驱动的为期一天的活动，由培训和一整个下午的围绕着生产环境中的 Go 这个主题的演讲组成.（LCTT 译注：本文发表于 2014 年）以下是我的讲义。原文的结构能让我缓慢而清晰的演讲，因此我已经编辑了它使其更可读。我要感谢 Bill Kennedy 和 Minux Ma，特别是 Josh Bleecher Snyder，感谢他们在我准备这次演讲中的帮助。大家下午好。我叫 David.我很高兴今天能来到 Gocon。我想参加这个会议已经两年了，我很感谢主办方能提供给我向你们演讲的机会。我想以一个问题开始我的演讲。为什么选择 Go？当大家讨论学习或在生产环境中使用 Go 的原因时，答案不一而足，但因为以下三个原因的最多。这就是 TOP3 的原因。第一，并发。Go 的 并发原语 Concurrency Primitives 对于来自 Nodejs，Ruby 或 Python 等单线程脚本语言的程序员，或者来自 C++ 或 Java 等重量级线程模型的语言都很有吸引力。易于部署。我们今天从经验丰富的 Gophers 那里听说过，他们非常欣赏部署 Go 应用的简单性。然后是性能。我相信人们选择 Go 的一个重要原因是它 快。在今天的演讲中，我想讨论五个有助于提高 Go 性能的特性。我还将与大家分享 Go 如何实现这些特性的细节。我要谈的第一个特性是 Go 对于值的高效处理和存储。这是 Go 中一个值的例子。编译时，gocon 正好消耗四个字节的内存。让我们将 Go 与其他一些语言进行比较由于 Python 表示变量的方式的开销，使用 Python 存储相同的值会消耗六倍的内存。Python 使用额外的内存来跟踪类型信息，进行 引用计数 Reference Counting 等。让我们看另一个例子：与 Go 类似，Java 消耗 4 个字节的内存来存储 int 型。但是，要在像 List 或 Map 这样的集合中使用此值，编译器必须将其转换为 Integer 对象。因此，Java 中的整数通常消耗 16 到 24 个字节的内存。为什么这很重要？ 内存便宜且充足，为什么这个开销很重要？这是一张显示 CPU 时钟速度与内存总线速度的图表。请注意 CPU 时钟速度和内存总线速度之间的差距如何继续扩大。两者之间的差异实际上是 CPU 花费多少时间等待内存。自 1960 年代后期以来，CPU 设计师已经意识到了这个问题。他们的解决方案是一个缓存，一个更小、更快的内存区域，介入 CPU 和主存之间。这是一个 Location 类型，它保存物体在三维空间中的位置。它是用 Go 编写的，因此每个 Location 只消耗 24 个字节的存储空间。我们可以使用这种类型来构造一个容纳 1000 个 Location 的数组类型，它只消耗 24000 字节的内存。在数组内部，Location 结构体是顺序存储的，而不是随机存储的 1000 个 Location 结构体的指针。这很重要，因为现在所有 1000 个 Location 结构体都按顺序放在缓存中，紧密排列在一起。Go 允许您创建紧凑的数据结构，避免不必要的填充字节。紧凑的数据结构能更好地利用缓存。更好的缓存利用率可带来更好的性能。函数调用不是无开销的。调用函数时会发生三件事。创建一个新的 栈帧 Stack Frame ，并记录调用者的详细信息。在函数调用期间可能被覆盖的任何寄存器都将保存到栈中。处理器计算函数的地址并执行到该新地址的分支。由于函数调用是非常常见的操作，因此 CPU 设计师一直在努力优化此过程，但他们无法消除开销。函调固有开销，或重于泰山，或轻于鸿毛，这取决于函数做了什么。减少函数调用开销的解决方案是 内联 Inlining 。Go 编译器通过将函数体视为调用者的一部分来内联函数。内联也有成本，它增加了二进制文件大小。只有当调用开销与函数所做工作关联度的很大时内联才有意义，因此只有简单的函数才能用于内联。复杂的函数通常不受调用它们的开销所支配，因此不会内联。这个例子显示函数 Double 调用 util.Max。为了减少调用 util.Max 的开销，编译器可以将 util.Max 内联到 Double 中，就象这样内联后不再调用 util.Max，但是 Double 的行为没有改变。内联并不是 Go 独有的。几乎每种编译或及时编译的语言都执行此优化。但是 Go 的内联是如何实现的？Go 实现非常简单。编译包时，会标记任何适合内联的小函数，然后照常编译。然后函数的源代码和编译后版本都会被存储。此幻灯片显示了 util.a 的内容。源代码已经过一些转换，以便编译器更容易快速处理。当编译器编译 Double 时，它看到 util.Max 可内联的，并且 util.Max 的源代码是可用的。就会替换原函数中的代码，而不是插入对 util.Max 的编译版本的调用。拥有该函数的源代码可以实现其他优化。在这个例子中，尽管函数 Test 总是返回 false，但 Expensive 在不执行它的情况下无法知道结果。当 Test 被内联时，我们得到这样的东西。编译器现在知道 Expensive 的代码无法访问。这不仅节省了调用 Test 的成本，还节省了编译或运行任何现在无法访问的 Expensive 代码。Go 编译器可以跨文件甚至跨包自动内联函数。还包括从标准库调用的可内联函数的代码。 强制垃圾回收 Mandatory Garbage Collection 使 Go 成为一种更简单，更安全的语言。这并不意味着垃圾回收会使 Go 变慢，或者垃圾回收是程序速度的瓶颈。这意味着在堆上分配的内存是有代价的。每次 GC 运行时都会花费 CPU 时间，直到释放内存为止。然而，有另一个地方分配内存，那就是栈。与 C 不同，它强制您选择是否将值通过 malloc 将其存储在堆上，还是通过在函数范围内声明将其储存在栈上；Go 实现了一个名为 逃逸分析 Escape Analysis 的优化。逃逸分析决定了对一个值的任何引用是否会从被声明的函数中逃逸。如果没有引用逃逸，则该值可以安全地存储在栈中。存储在栈中的值不需要分配或释放。让我们看一些例子Sum 返回 1 到 100 的整数的和。这是一种相当不寻常的做法，但它说明了逃逸分析的工作原理。因为切片 numbers 仅在 Sum 内引用，所以编译器将安排到栈上来存储的 100 个整数，而不是安排到堆上。没有必要回收 numbers，它会在 Sum 返回时自动释放。第二个例子也有点尬。在 CenterCursor 中，我们创建一个新的 Cursor 对象并在 c 中存储指向它的指针。然后我们将 c 传递给 Center() 函数，它将 Cursor 移动到屏幕的中心。最后我们打印出那个 ‘Cursor` 的 X 和 Y 坐标。即使 c 被 new 函数分配了空间，它也不会存储在堆上，因为没有引用 c 的变量逃逸 CenterCursor 函数。默认情况下，Go 的优化始终处于启用状态。可以使用 -gcflags = -m 开关查看编译器的逃逸分析和内联决策。因为逃逸分析是在编译时执行的，而不是运行时，所以无论垃圾回收的效率如何，栈分配总是比堆分配快。我将在本演讲的其余部分详细讨论栈。Go 有 goroutine。 这是 Go 并发的基石。我想退一步，探索 goroutine 的历史。最初，计算机一次运行一个进程。在 60 年代，多进程或 分时 Time Sharing 的想法变得流行起来。在分时系统中，操作系统必须通过保护当前进程的现场，然后恢复另一个进程的现场，不断地在这些进程之间切换 CPU 的注意力。这称为 进程切换。进程切换有三个主要开销。首先，内核需要保护该进程的所有 CPU 寄存器的现场，然后恢复另一个进程的现场。内核还需要将 CPU 的映射从虚拟内存刷新到物理内存，因为这些映射仅对当前进程有效。最后是操作系统 上下文切换 Context Switch 的成本，以及 调度函数 Scheduler Function 选择占用 CPU 的下一个进程的开销。现代处理器中有数量惊人的寄存器。我很难在一张幻灯片上排开它们，这可以让你知道保护和恢复它们需要多少时间。由于进程切换可以在进程执行的任何时刻发生，因此操作系统需要存储所有寄存器的内容，因为它不知道当前正在使用哪些寄存器。这导致了线程的出生，这些线程在概念上与进程相同，但共享相同的内存空间。由于线程共享地址空间，因此它们比进程更轻，因此创建速度更快，切换速度更快。Goroutine 升华了线程的思想。Goroutine 是 协作式调度 Cooperative Scheduled 的，而不是依靠内核来调度。当对 Go 运行时调度器 Runtime Scheduler 进行显式调用时，goroutine 之间的切换仅发生在明确定义的点上。编译器知道正在使用的寄存器并自动保存它们。虽然 goroutine 是协作式调度的，但运行时会为你处理。Goroutine 可能会给禅让给其他协程时刻是：阻塞式通道发送和接收。Go 声明，虽然不能保证会立即调度新的 goroutine。文件和网络操作式的阻塞式系统调用。在被垃圾回收循环停止后。这个例子说明了上一张幻灯片中描述的一些调度点。箭头所示的线程从左侧的 ReadFile 函数开始。遇到 os.Open，它在等待文件操作完成时阻塞线程，因此调度器将线程切换到右侧的 goroutine。继续执行直到从通道 c 中读，并且此时 os.Open 调用已完成，因此调度器将线程切换回左侧并继续执行 file.Read 函数，然后又被文件 IO 阻塞。调度器将线程切换回右侧以进行另一个通道操作，该操作在左侧运行期间已解锁，但在通道发送时再次阻塞。最后，当 Read 操作完成并且数据可用时，线程切换回左侧。这张幻灯片显示了低级语言描述的 runtime.Syscall 函数，它是 os 包中所有函数的基础。只要你的代码调用操作系统，就会通过此函数。对 entersyscall 的调用通知运行时该线程即将阻塞。这允许运行时启动一个新线程，该线程将在当前线程被阻塞时为其他 goroutine 提供服务。这导致每 Go 进程的操作系统线程相对较少，Go 运行时负责将可运行的 Goroutine 分配给空闲的操作系统线程。在上一节中，我讨论了 goroutine 如何减少管理许多（有时是数十万个并发执行线程）的开销。Goroutine故事还有另一面，那就是栈管理，它引导我进入我的最后一个话题。这是一个进程的内存布局图。我们感兴趣的关键是堆和栈的位置。传统上，在进程的地址空间内，堆位于内存的底部，位于程序（代码）的上方并向上增长。栈位于虚拟地址空间的顶部，并向下增长。因为堆和栈相互覆盖的结果会是灾难性的，操作系统通常会安排在栈和堆之间放置一个不可写内存区域，以确保如果它们发生碰撞，程序将中止。这称为保护页，有效地限制了进程的栈大小，通常大约为几兆字节。我们已经讨论过线程共享相同的地址空间，因此对于每个线程，它必须有自己的栈。由于很难预测特定线程的栈需求，因此为每个线程的栈和保护页面保留了大量内存。希望是这些区域永远不被使用，而且防护页永远不会被击中。缺点是随着程序中线程数的增加，可用地址空间的数量会减少。我们已经看到 Go 运行时将大量的 goroutine 调度到少量线程上，但那些 goroutines 的栈需求呢？Go 编译器不使用保护页，而是在每个函数调用时插入一个检查，以检查是否有足够的栈来运行该函数。如果没有，运行时可以分配更多的栈空间。由于这种检查，goroutines 初始栈可以做得更小，这反过来允许 Go 程序员将 goroutines 视为廉价资源。这是一张显示了 Go 1.2 如何管理栈的幻灯片。当 G 调用 H 时，没有足够的空间让 H 运行，所以运行时从堆中分配一个新的栈帧，然后在新的栈段上运行 H。当 H 返回时，栈区域返回到堆，然后返回到 G。这种管理栈的方法通常很好用，但对于某些类型的代码，通常是递归代码，它可能导致程序的内部循环跨越这些栈边界之一。例如，在程序的内部循环中，函数 G 可以在循环中多次调用 H，每次都会导致栈拆分。 这被称为 热分裂 Hot Split 问题。为了解决热分裂问题，Go 1.3 采用了一种新的栈管理方法。如果 goroutine 的栈太小，则不会添加和删除其他栈段，而是分配新的更大的栈。旧栈的内容被复制到新栈，然后 goroutine 使用新的更大的栈继续运行。在第一次调用 H 之后，栈将足够大，对可用栈空间的检查将始终成功。这解决了热分裂问题。值，内联，逃逸分析，Goroutines 和分段/复制栈。这些是我今天选择谈论的五个特性，但它们绝不是使 Go 成为快速的语言的唯一因素，就像人们引用他们学习 Go 的理由的三个原因一样。这五个特性一样强大，它们不是孤立存在的。例如，运行时将 goroutine 复用到线程上的方式在没有可扩展栈的情况下几乎没有效率。内联通过将较小的函数组合成较大的函数来降低栈大小检查的成本。逃逸分析通过自动将从实例从堆移动到栈来减少垃圾回收器的压力。逃逸分析还提供了更好的 缓存局部性 Cache Locality 。如果没有可增长的栈，逃逸分析可能会对栈施加太大的压力。感谢 Gocon 主办方允许我今天发言twitter / web / email details感谢 @offbymany，@billkennedy\_go 和 Minux 在准备这个演讲的过程中所提供的帮助。相关文章：听我在 OSCON 上关于 Go 性能的演讲为什么 Goroutine 的栈是无限大的？Go 的运行时环境变量的旋风之旅没有事件循环的性能作者简介：David 是来自澳大利亚悉尼的程序员和作者。自 2011 年 2 月起成为 Go 的 contributor，自 2012 年 4 月起成为 committer。联系信息dave@cheney.nettwitter: @davecheneyvia: https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast作者：Dave Cheney 译者：houbaron 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
这篇文章受到了我与同事讨论使用 切片 slice 作为 栈 stack 的一次聊天的启发。后来话题聊到了 Go 语言中的切片是如何工作的。我认为这些信息对别人也有用，所以就把它记录了下来。数组任何关于 Go 语言切片的讨论都要从另一个数据结构也就是 数组 array 开始。Go 的数组有两个特性：数组的长度是固定的；[5]int 是由 5 个 int 构成的数组，和 [3]int 不同。数组是值类型。看下面这个示例：package main import "fmt" func main() { var a [5]int b := a b[2] = 7 fmt.Println(a, b) // prints [0 0 0 0 0] [0 0 7 0 0] }语句 b := a 定义了一个类型是 [5]int 的新变量 b，然后把 a 中的内容 复制 到 b 中。改变 b 对 a 中的内容没有影响，因为 a 和 b 是相互独立的值。 1 切片Go 语言的切片和数组的主要有如下两个区别：切片没有一个固定的长度。切片的长度不是它类型定义的一部分，而是由切片内部自己维护的。我们可以使用内置的 len 函数知道它的长度。 2 将一个切片赋值给另一个切片时 不会 对切片内容进行复制操作。这是因为切片没有直接持有其内部数据，而是保留了一个指向 底层数组 3 的指针。数据都保留在底层数组里。基于第二个特性，两个切片可以享有共同的底层数组。看下面的示例：对切片取切片package main import "fmt" func main() { var a = []int{1,2,3,4,5} b := a[2:] b[0] = 0 fmt.Println(a, b) // prints [1 2 0 4 5] [0 4 5] }在这个例子里，a 和 b 享有共同的底层数组 —— 尽管 b 在数组里的起始偏移量不同，两者的长度也不同。通过 b 修改底层数组的值也会导致 a 里的值的改变。将切片传进函数package main import "fmt" func negate(s []int) { for i := range s { s[i] = -s[i] } } func main() { var a = []int{1, 2, 3, 4, 5} negate(a) fmt.Println(a) // prints [-1 -2 -3 -4 -5] }在这个例子里，a 作为形参 s 的实参传进了 negate 函数，这个函数遍历 s 内的元素并改变其符号。尽管 nagate 没有返回值，且没有访问到 main 函数里的 a。但是当将之传进 negate 函数内时，a 里面的值却被改变了。大多数程序员都能直观地了解 Go 语言切片的底层数组是如何工作的，因为它与其它语言中类似数组的工作方式类似。比如下面就是使用 Python 重写的这一小节的第一个示例：Python 2.7.10 (default, Feb 7 2017, 00:08:15) [GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.34)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> a = [1,2,3,4,5] >>> b = a >>> b[2] = 0 >>> a [1, 2, 0, 4, 5]以及使用 Ruby 重写的版本:irb(main):001:0> a = [1,2,3,4,5] => [1, 2, 3, 4, 5] irb(main):002:0> b = a => [1, 2, 3, 4, 5] irb(main):003:0> b[2] = 0 => 0 irb(main):004:0> a => [1, 2, 0, 4, 5]在大多数将数组视为对象或者是引用类型的语言也是如此。 4 切片头切片同时拥有值和指针特性的神奇之处在于理解切片实际上是一个 结构体 struct 类型。通常在 反射 reflect 包内相应部分之后的这个结构体被称作 切片头 slice header 。切片头的定义大致如下：package runtime type slice struct { ptr unsafe.Pointer len int cap int }这很重要，因为和 map 以及 chan 这两个类型不同，切片是值类型，当被赋值或者被作为参数传入函数时候会被复制过去。程序员们都能理解 square 的形参 v 和 main 中声明的 v 的是相互独立的。请看下面的例子：package main import "fmt" func square(v int) { v = v * v } func main() { v := 3 square(v) fmt.Println(v) // prints 3, not 9 }因此 square 对自己的形参 v 的操作没有影响到 main 中的 v。下面这个示例中的 s 也是 main 中声明的切片 s 的独立副本， 而不是 指向 main 的 s 的指针。package main import "fmt" func double(s []int) { s = append(s, s...) } func main() { s := []int{1, 2, 3} double(s) fmt.Println(s, len(s)) // prints [1 2 3] 3 }Go 的切片是作为值传递而不是指针这一点不太寻常。当你在 Go 内定义一个结构体时，90% 的时间里传递的都是这个结构体的指针 5 。切片的传递方式真的很不寻常，我能想到的唯一与之相同的例子只有 time.Time。切片作为值传递而不是作为指针传递这一特殊行为会让很多想要理解切片的工作原理的 Go 程序员感到困惑。你只需要记住，当你对切片进行赋值、取切片、传参或者作为返回值等操作时，你是在复制切片头结构的三个字段：指向底层数组的指针、长度，以及容量。总结我们来用引出这一话题的切片作为栈的例子来总结下本文的内容：package main import "fmt" func f(s []string, level int) { if level > 5 { return } s = append(s, fmt.Sprint(level)) f(s, level+1) fmt.Println("level:", level, "slice:", s) } func main() { f(nil, 0) }在 main 函数的最开始我们把一个 nil 切片传给了函数 f 作为 level 0 。在函数 f 里我们把当前的 level 添加到切片的后面，之后增加 level 的值并进行递归。一旦 level 大于 5，函数返回，打印出当前的 level 以及它们复制到的 s 的内容。level: 5 slice: [0 1 2 3 4 5] level: 4 slice: [0 1 2 3 4] level: 3 slice: [0 1 2 3] level: 2 slice: [0 1 2] level: 1 slice: [0 1] level: 0 slice: [0]你可以注意到在每一个 level 内 s 的值没有被别的 f 的调用影响，尽管当计算更高的 level 时作为 append 的副产品，调用栈内的四个 f 函数创建了四个底层数组 6 ，但是没有影响到当前各自的切片。扩展阅读如果你想要了解更多 Go 语言内切片运行的原理，我建议看看 Go 博客里的这些文章：Go Slices: usage and internals (blog.golang.org)Arrays, slices (and strings): The mechanics of 'append' (blog.golang.org)相关文章：If a map isn't a reference variable, what is it?What is the zero value, and why is it useful?The empty structShould methods be declared on T or *T这不是数组才有的特性，在 Go 语言里中 一切 赋值都是复制过去的。 ↩你也可以在对数组使用 len 函数，但是其结果本来就人尽皆知。 ↩有时也叫做 后台数组 backing array ，以及更不严谨的说法是后台切片。 ↩Go 语言里我们倾向于说值类型以及指针类型，因为 C++ 的 引用 reference 类型这个词产生误会。但在这里我认为调用数组作为引用类型是没有问题的。 ↩如果你的结构体有定义在其上的方法或者用于满足某个接口，那么你传入结构体指针的比率可以飙升到接近 100%。 ↩证明留做习题。 ↩via: https://dave.cheney.net/2018/07/12/slices-from-the-ground-up 作者：Dave Cheney 译者：name1e5s 校对：pityonline本文由 LCTT 原创编译，Linux中国 荣誉推出
几个星期前，我一个朋友问我：“为什么要关心 Go 语言”？ 因为他们知道我热衷于 Go 语言，但他们想知道为什么我认为其他人也应该关心。本文包含三个我认为 Go 是重要的编程语言的原因。安全个人而言，你和我或许完全有能力在 C 中编写程序，既不会泄漏内存，也不会不安全地重复使用内存。然而，整体上，即使有超过 40 年)的经验，用 C 的程序员也无法可靠地这样做。尽管静态代码分析、valgrind、tsan 以及 “-Werror” 已经存在了几十年，却很少有证据表明这些工具被广泛认可，更不用说广泛采用。总而言之，事实表明，程序员根本无法安全地管理自己的内存。现在是离开 C 的时候了。Go 不需要程序员直接管理内存，所有内存分配都由语言运行时自行管理，使用前初始化，必要时检查边界。它肯定不是提供这些安全保障的第一个主流语言，Java（1995）可能是该冠军的竞争者。关键是，世界对不安全的编程语言没有胃口，所以人们默认认为，Go 是内存安全的。开发人员生产力从 20 世纪 70 年代末，开发人员的时间变得比硬件所耗费的时间更昂贵了。开发人员的生产力是一个不断扩展的话题，但它归结为这一点：你花了多少时间做有用的工作，又有多少时间等待编译器或者失望地迷失在外部代码库中。有个笑话说 Go 是在等待 C ++ 程序编译时开发的。快速编译是 Go 的一个重要功能，也是吸引新开发人员的关键工具。虽然编译速度仍然是一个永久的战场，但公平地说，在其他语言中需要几分钟的编译，在 Go 中只需要几秒钟。Go 程序员意识到生产力的更根本的问题是代码是为了读而写的，所以将代码的阅读行为放在编写之上。Go 通过工具和自定义来强制所有代码格式化成特定的样式。这消除了学习项目特定语言的方言时的困难，并有助于发现错误，因为它们看上去就是不正确。由于专注于分析和机器辅助，Go 开发人员开始采用越来越多的工具来发现常见的编码错误，这种工具从来没有在 C 语言开发者中产生共鸣 - Go 开发人员希望工具帮助他们保持代码清洁。并发性十多年来，芯片设计师一直在警告免费午餐将会结束。从最低端的手机到最耗电的服务器，硬件的并行性以更多、更慢、堆砌 cpu 内核的形式出现，但只有当你的语言可以利用它们才有意义。因此，并发特性需要内置到我们编写的要在今天的硬件上运行的软件中。通过提供一种基于协程的轻量级并发模型，或者是 Go 中已知的 goroutines，Go 超越了那些暴露操作系统的多进程或多线程并行模型的语言。goroutines 允许程序员避开复杂的回调，而语言运行时确保有足够的线程来保持你的内核的活跃。总结我给朋友推荐 Go 有三个原因：安全性、生产力和并发性。有些语言可以涵盖一个也有可能是两个方面，但是这三个方面的结合使得 Go 成为主流程序员的绝佳选择。相关文章：为什么 Go 和 Rust 不是竞争对手听听我在 OSCON 上谈 Go 语言性能我在 GopherChina 和 GopherCon Singapore 中的演讲压力测试你 Go 包via: https://dave.cheney.net/2017/03/20/why-go作者：Dave Cheney 译者：geekpi 校对：jasminepeng本文由 LCTT 原创编译，Linux中国 荣誉推出