摘要:
本内容主要讲了GO语言快速的5个特征:
原文链接:https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast
我最近被邀请在Gocon发表演讲,这是一个每年半年在日本东京举行的精彩围棋会议。Gocon 2014是一个完全由社区运营的为期一天的活动,结合了培训和围绕Go in production主题的下午演讲。
以下是我的演讲文字。原始文本的结构使我能够缓慢而清晰地说话,因此我可以自由编辑它以使其更具可读性。
我要感谢Bill Kennedy,Minux Ma,尤其是Josh Bleecher Snyder,感谢他们帮助我们准备这次演讲。
下午好。
我叫大卫。
我很高兴今天能来到Gocon。我想参加这个会议两年,我非常感谢组织者给我今天提供给你的机会。
我想用一个问题开始我的谈话。
为什么人们选择使用Go?
当人们谈论他们决定学习Go或在他们的产品中使用它时,他们有各种各样的答案,但总有三个在他们的列表的顶部
Go的并发原语对于来自Nodejs,Ruby或Python等单线程脚本语言的程序员,或者来自C ++或Java等语言的重量级线程模型都很有吸引力。
易于部署。
我们今天从经验丰富的Gophers那里听说过,他们非常欣赏部署Go应用程序的简单性。
这留下了性能。
我相信人们选择使用Go的一个重要原因是因为它很快。
对于我今天的演讲,我想讨论有助于Go的表现的五个功能。
我还将与您分享Go如何实现这些功能的详细信息。
1.值,Go的有效处理和值存储。
这是Go中值的示例。编译时,只gocon
消耗四个字节的内存。
让我们将Go与其他语言进行比较
由于Python表示变量的方式的开销,使用Python存储相同的值会消耗六倍的内存。
Python使用这个额外的内存来跟踪类型信息,引用计数等
让我们看另一个例子:
与Go类似,Java int
类型消耗4个字节的内存来存储该值。
但是,要在像List
或的集合中使用此值Map
,编译器必须将其转换为Integer
对象。
因此,Java中的整数通常看起来更像这样,并且消耗16到24个字节的内存。
为什么这很重要?记忆力便宜且充足,为什么这个开销很重要?
这是显示CPU时钟速度与内存总线速度的图表。
请注意CPU时钟速度和内存总线速度之间的差距如何继续扩大。
两者之间的区别实际上是CPU花费多少时间等待内存。
自1960年代后期以来,CPU设计师已经理解了这个问题。
他们的解决方案是缓存,一个更小,更快的内存区域,插入CPU和主内存之间。
这是一种Location
在三维空间中保存某个对象的位置的类型。它是用Go编写的,因此每个Location
消耗恰好占用24个字节的存储空间。
我们可以使用这种类型来构造一个1000 Location
秒的数组类型,它只消耗24,000字节的内存。
在数组内部,Location
结构按顺序存储,而不是作为指向随机存储的1,000个位置结构的指针。
这很重要,因为现在所有1,000个Location
结构都按顺序放在缓存中,紧密排列在一起。
Go允许您创建紧凑的数据结构,避免不必要的间接。
紧凑的数据结构更好地利用缓存。
更好的缓存利用率可带来更好的性能
函数调用不是免费的。
调用函数时会发生三件事。
创建一个新的堆栈帧,并记录调用者的详细信息。
在函数调用期间可能被覆盖的任何寄存器都将保存到堆栈中。
处理器计算函数的地址并执行到该新地址的分支。
由于函数调用是非常常见的操作,因此CPU设计人员一直在努力优化此过程,但它们无法消除开销。
根据功能的作用,这种开销可能很小或很重要。
减少函数调用开销的解决方案是称为Inlining的优化技术。
Go编译器通过将函数体视为调用者的一部分来内联函数。
内联有成本; 它增加了二进制大小。
只有当函数调用函数的开销相对于函数的工作量很大时内联才有意义,因此只有简单的函数才能用于内联。
复杂的函数通常不受调用它们的开销所支配,因此不会内联。
此示例显示函数Double
调用util.Max
。
为了减少调用的开销util.Max
,编译器可以内联util.Max
成Double
,导致这样的事情
内联后不再调用util.Max
,但行为Double
不变。
内联不是Go独有的。几乎每种编译或JITed语言都执行此优化。但是Go中的内联工作如何?
Go实现非常简单。编译包时,会标记任何适合内联的小函数,然后照常编译。
然后存储函数的源和编译的版本。
此幻灯片显示了util.a的内容。源代码已经过一些转换,以便编译器更容易快速处理。
当编译器编译Double时,它看到它util.Max
是无法使用的,并且源util.Max
是可用的。
它不是插入对编译版本的调用util.Max
,而是可以替换原始函数的源代码。
拥有该函数的源可以实现其他优化。
在这个例子中,虽然函数Test总是返回false,但是Expensive在不执行它的情况下无法知道。
当Test
内联时,我们得到这样的东西
编译器现在知道昂贵的代码是无法访问的。
这不仅节省了调用Test的成本,还节省了编译或运行任何现在无法访问的昂贵代码。
Go编译器可以跨文件甚至跨包自动内联函数。这包括从标准库调用inlinable函数的代码。
强制垃圾收集使Go成为一种更简单,更安全的语言。
这并不意味着垃圾收集会使Go变慢,或者垃圾收集是程序速度的最终仲裁者。
它的意思是在堆上分配的内存是有代价的。每次GC运行时都会花费CPU时间,直到释放内存为止。
然而,有另一个地方分配内存,那就是堆栈。
与C不同,它强制您选择是将值存储在堆上,通过malloc
堆栈还是堆栈上,通过在函数范围内声明它,Go实现了一个名为转义分析的优化。
转义分析确定对值的任何引用是否会转义声明值的函数。
如果没有引用转义,则该值可以安全地存储在堆栈中。
存储在堆栈中的值不需要分配或释放。
让我们看一些例子
Sum
添加1到100之间的数字并返回结果。这是一种相当不寻常的方法,但它说明了Escape Analysis的工作原理。
因为数字切片仅在内部引用Sum
,所以编译器将安排在堆栈上存储该切片的100个整数,而不是堆。
不需要垃圾回收numbers
,Sum
返回时会自动释放。
第二个例子也有点人为。在CenterCursor
我们创建一个新的Cursor
并在c中存储指向它的指针。
然后我们传递c
给Center()
移动Cursor
到屏幕中心的功能。
然后我们最后打印出X和Y的位置Cursor
。
即使c
分配了该new
函数,它也不会存储在堆上,因为没有引用会c
转义该CenterCursor
函数。
默认情况下,Go的优化始终处于启用状态。您可以通过-gcflags=-m
交换机查看编译器的转义分析和内联决策。
因为转义分析是在编译时执行的,而不是运行时,所以无论垃圾收集器的效率如何,堆栈分配总是比堆分配快。
我将在本演讲的其余部分详细讨论堆栈。
Go有goroutines。这些是Go中并发性的基础。
我想退一步,探索引领我们走向goroutines的历史。
一开始,计算机一次运行一个进程。然后在60年代,多处理或时间共享的想法变得流行起来。
在分时系统中,操作系统必须通过记录当前进程的状态,然后恢复另一个进程的状态,不断地在这些进程之间切换CPU的注意力。
这称为过程切换。
过程切换有三个主要成本。
首先,内核需要存储该进程的所有CPU寄存器的内容,然后恢复另一个进程的值。
内核还需要将CPU的映射从虚拟内存刷新到物理内存,因为这些映射仅对当前进程有效。
最后是操作系统上下文切换的成本,以及调度程序功能的开销,以选择占用CPU的下一个进程。
现代处理器中有数量惊人的寄存器。我很难在一张幻灯片上安装它们,这可以让你知道保存和恢复它们需要多少时间。
由于进程切换可以在进程执行的任何时刻发生,操作系统需要存储所有这些寄存器的内容,因为它不知道当前正在使用哪些寄存器。
这导致了线程的开发,这些线程在概念上与进程相同,但共享相同的内存空间。
由于线程共享地址空间,因此它们比进程更轻,因此创建速度更快,切换速度更快。
Goroutines将线程的思想更进一步。
Goroutines是合作安排的,而不是依靠内核来管理他们的时间共享。
当对Go运行时调度程序进行显式调用时,goroutine之间的切换仅发生在明确定义的点上。
编译器知道正在使用的寄存器并自动保存它们。
虽然goroutine是协同安排的,但运行时会为您处理此调度。
Goroutines可能会给其他人带来的地方是:
这个例子说明了上一张幻灯片中描述的一些调度点。
箭头所示的线程从ReadFile
函数的左侧开始。它遇到了os.Open
,它在等待文件操作完成时阻塞线程,因此调度程序将线程切换到右侧的goroutine。
执行继续,直到从c
chan块读取,此时os.Open
调用已完成,因此调度程序将线程切换回左侧并继续执行该file.Read
函数,该函数再次阻止文件IO。
调度程序将线程切换回右侧以进行另一个通道操作,该操作在左侧运行期间已解锁,但在通道发送时再次阻塞。
最后,当Read
操作完成并且数据可用时,线程切换回左侧。
此幻灯片显示了低级runtime.Syscall
功能,它是os包中所有功能的基础。
只要您的代码调用操作系统,它就会通过此功能。
调用entersyscall
通知运行时该线程即将阻塞。
这允许运行时启动一个新线程,该线程将在当前线程被阻塞时为其他goroutine提供服务。
这导致每个Go进程的操作系统线程相对较少,Go运行时负责将可运行的Goroutine分配给自由操作系统线程。
在上一节中,我讨论了goroutines如何减少管理许多(有时是数十万个并发执行线程)的开销。
goroutine故事还有另一面,那就是堆栈管理,它引导我进入我的最后一个主题。
这是一个进程的内存布局图。我们感兴趣的关键是堆和堆栈的位置。
传统上,在进程的地址空间内,堆位于内存的底部,位于程序(文本)的上方并向上增长。
堆栈位于虚拟地址空间的顶部,并向下增长。
因为堆和堆栈相互覆盖将是灾难性的,操作系统通常会安排在堆栈和堆之间放置一个不可写内存区域,以确保如果它们确实发生冲突,程序将中止。
这称为保护页面,有效地限制了进程的堆栈大小,通常大约为几兆字节。
我们已经讨论过线程共享相同的地址空间,因此对于每个线程,它必须有自己的堆栈。
因为很难预测特定线程的堆栈需求,所以为每个线程的堆栈和保护页面保留了大量内存。
希望是这不仅仅是需要的,而且防护页面永远不会被击中。
缺点是随着程序中线程数的增加,可用地址空间的数量会减少。
我们已经看到Go运行时将大量的goroutine调度到少量线程上,但那些goroutines的堆栈需求呢?
Go编译器不是使用保护页,而是在每个函数调用的一部分插入一个检查,以检查是否有足够的堆栈来运行该函数。如果没有,运行时可以分配更多的堆栈空间。
由于这种检查,goroutines初始堆栈可以做得更小,这反过来允许Go程序员将goroutines视为廉价资源。
这是一张幻灯片,显示了如何在Go 1.2中管理堆栈。
当G
对H
那里的调用没有足够的空间H
来运行时,运行时从堆中分配一个新的堆栈帧,然后H
在该新的堆栈段上运行。当H
返回时,堆栈区返回之前返回到堆G
。
这种管理堆栈的方法通常很好用,但对于某些类型的代码,通常是递归代码,它可能导致程序的内部循环跨越这些堆栈边界之一。
例如,在程序的内部循环中,函数G
可能H
在循环中多次调用,
每次这都会导致堆栈拆分。这被称为热分裂问题。
为解决热分裂问题,Go 1.3采用了一种新的堆栈管理方法。
如果goroutine的堆栈太小,则不会添加和删除其他堆栈段,而是分配新的更大的堆栈。
旧堆栈的内容被复制到新堆栈,然后goroutine继续其新的更大的堆栈。
在第一次调用H
堆栈之后,对于可用堆栈空间的检查将始终成功。
这解决了热分裂问题。
值,内联,转义分析,Goroutines和分段/复制堆栈。
这些是我今天选择谈论的五个特征,但它们绝不是使Go成为快速编程语言的唯一因素,正如人们引用他们学习Go的理由的三个原因一样。
这五个功能分别具有强大的功能,它们不是孤立存在的。
例如,如果没有可扩展的堆栈,运行时将goroutine复用到线程上的方式就不那么有效了。
内联通过将较小的函数组合成较大的函数来降低堆栈大小检查的成本。
转义分析通过自动将分配从堆移动到堆栈来减少垃圾收集器的压力。
转义分析还提供了更好的缓存局部性。
如果没有可堆叠的堆栈,逃逸分析可能会对堆栈施加太大的压力。