当前位置: 首页 > 科技观察

带你了解五种加速Go的特性和如何实现它们

时间:2023-03-17 10:54:40 科技观察

AnthonyStarks使用他出色的Deck演示工具重构了我最初基于GoogleSlides的幻灯片。您可以在他的博客mindchunk.blogspot.com.au/2014/06/remixing-with-deck上查看他的混音幻灯片。我最近受邀在Gocon上发言,这是一个令人惊叹的Go会议,每半年在日本东京举行一次。Gocon2014是一个完全由社区驱动的为期一天的活动,包括培训和围绕生产中的Go主题的整个下午的讨论。(LCTT译注:本文发表于2014年)下面是我的讲义。原文的结构让我可以慢慢说清楚,所以我对它进行了编辑以使其更易读。我要感谢BillKennedy和MinuxMa,尤其是JoshBleecherSnyder,感谢他们帮助准备这次演讲。大家下午好。我叫大卫。我很高兴今天能来到Gocon。两年来我一直想参加这个会议,感谢主办方给我这次演讲的机会。Gocon2014我想以一个问题开始我的演讲。为什么去?当人们讨论在生产环境中学习或使用Go的原因时,答案各不相同,但以下三个原因是最常见的。Gocon2014这就是TOP3的原因。***,同时。Go的ConcurrencyPrimitives对来自单线程脚本语言(如Nodejs、Ruby或Python)或来自具有重量级线程模型(如C++或Java)的语言的程序员很有吸引力。易于部署。今天,我们听到了经验丰富的Gophers的意见,他们欣赏部署Go应用程序的简单性。Gocon2014再表现。我相信人们选择Go的一个重要原因是它的速度很快。Gocon2014(4)在今天的演讲中,我想讨论有助于提高Go性能的五个特性。我还将与您详细分享Go是如何实现这些功能的。Gocon2014(5)第一个要讲的特性是Go对值的高效处理和存储。Gocon2014(6)这是Go中一个值的例子。编译时,gocon正好消耗四个字节的内存。Lot'scompareGotosomeotherlanguagesGocon2014(7)由于Python表示变量的方式的开销,使用Python存储相同的值会消耗六倍的内存。Python使用额外的内存来跟踪类型信息,做引用计数等。再看一个例子:Gocon2014(8)与Go类似,Java消耗4字节的内存来存储int类型。但是,要在List或Map等集合中使用此值,编译器必须将其转换为Integer对象。Gocon2014(9)因此,Java中的整数通常会占用16到24字节的内存。为什么这很重要?内存既便宜又充足,那么为什么这种开销很重要呢?Gocon2014(10)这是一张显示CPU时钟速度与内存总线速度的图表。请注意CPU时钟速度和内存总线速度之间的差距如何继续扩大。两者之间的区别实际上是CPU花在等待内存上的时间。Gocon2014(11)CPU设计者从20世纪60年代后期就意识到了这个问题。他们的解决方案是缓存,这是一个位于CPU和主内存之间的更小、更快的内存区域。Gocon2014(12)这是一个Location类型,保存了物体在三维空间中的位置。它是用Go编写的,因此每个Location仅占用24字节的存储空间。我们可以使用这个类型来构造一个数组类型,它可以容纳1000个Locations,它只占用24000字节的内存。在数组内部,Location结构是顺序存储的,而不是随机存储指向1000个Location结构的指针。这很重要,因为现在所有1000个Location结构都按顺序放在缓存中,紧密地打包在一起。Gocon2014(13)Go允许您创建紧凑的数据结构,避免不必要的填充字节。紧凑的数据结构可以更好地利用缓存。更好的缓存利用率会带来更好的性能。Gocon2014(14)函数调用并非无开销。Gocon2014(15)调用函数时会发生三件事。新建一个栈帧StackFrame,记录调用者的详细信息。在函数调用期间可能已被覆盖的任何寄存器都保存到堆栈中。处理器计算函数的地址并执行到该新地址的分支。Gocon2014(16)由于函数调用是一种常见的操作,CPU设计人员一直在努力优化这一过程,但他们无法消除开销。函数调用具有固有的开销,这些开销可能是巨大的,也可能是微不足道的,具体取决于函数的作用。减少函数调用开销的解决方案是内联。Gocon2014(17)Go编译器通过将函数体视为调用者的一部分来内联函数。内联也有成本,它会增加二进制文件的大小。仅当调用开销与函数所做的工作高度相关时,内联才有意义,因此只能将简单的函数用于内联。复杂函数通常不受调用它们的开销的支配,因此不会被内联。Gocon2014(18)本例展示函数Double调用util.Max。为了减少调用util.Max的开销,编译器可以将util.Max内联到Double中,像这样Gocon2014(19)内联后不再调用util.Max,但是Double的行为没有改变。内联并不是Go独有的。几乎所有编译语言或即时编译语言都执行此优化。但是内联在Go中是如何实现的呢?Go的实现非常简单。编译包时,任何适合内联的小函数都会被标记,然后正常编译。然后存储函数的源代码和编译版本。Gocon2014(20)此幻灯片显示util.a的内容。源代码经过了一些改造,使编译器更容易快速处理。当编译器编译Double时,它??看到util.Max是可内联的,并且util.Max的源代码是可用的。替换原始函数中的代码,而不是插入对util.Max编译版本的调用。拥有此功能的源代码可以实现其他优化。Gocon2014(21)在这个例子中,虽然函数Test总是返回false,但是Expensive不执行是无法知道结果的。当Test被内联时,我们会得到这样的结果。Gocon2014(22)编译器现在知道无法访问昂贵的代码。这不仅节省了调用Test的成本,而且还节省了编译或运行现在无法访问的任何昂贵代码的成本。Go编译器可以跨文件甚至跨包自动内联函数。还包括从标准库调用的可内联函数的代码。Gocon2014(23)MandatoryGarbageCollectionMandatoryGarbageCollection让Go成为一门更简单、更安全的语言。这并不意味着垃圾收集会减慢Go的速度,或者垃圾收集是程序速度的瓶颈。这意味着在堆上分配内存是有代价的。每次GC运行都会花费CPU时间,直到内存被释放。Gocon2014(24)不过,还有一个地方可以分配内存,那就是栈。不像C,它强制你选择是通过malloc将值存储在堆上,还是通过在函数范围内声明它们将它们存储在堆栈上;Go实现了一种称为逃逸分析的优化。Gocon2014(25)逃逸分析确定是否有任何对值的引用从声明的函数中逃逸。如果没有引用转义,该值可以安全地存储在堆栈中。存储在栈上的值不需要分配或释放。让我们看一些示例Gocon2014(26)Sum返回从1到100的整数之和。这是一种相当不寻常的做法,但它说明了逃逸分析的工作原理。因为切片编号仅在Sum中引用,所以编译器将存储在堆栈而不是堆上的100个整数排列。不需要回收数字,Sum返回时会自动释放。Gocon2014(27)的第二个例子也有点尴尬。在CenterCursor中,我们创建了一个新的Cursor对象,并在c中存储了一个指向它的指针。然后我们将c传递给Center()函数,它将Cursor移动到屏幕的中心。***我们打印出那个“光标”的X和Y坐标。即使c被新函数分配了空间,它也不会存储在堆上,因为没有引用c的变量逃脱CenterCursor函数。Gocon2014(28)Go的优化始终默认启用。可以使用-gcflags=-m开关查看编译器的逃逸分析和内联决策。因为逃逸分析是在编译时执行的,而不是运行时,栈分配总是比堆分配快,不管垃圾收集的效率如何。我将在本次演讲的其余部分详细讨论堆栈。Gocon2014(29)Go有goroutines。这是Go并发的基石。我想退后一步,探索goroutines的历史。最初,计算机一次运行一个进程。在60年代,多处理或分时的想法开始流行。在分时系统中,操作系统必须通过保护当前进程的上下文,然后恢复另一个进程的上下文,不断地在这些进程之间切换CPU的注意力。这称为进程切换。Gocon2014(30)进程切换有三个主要的开销。首先,内核需要为进程保护所有CPU寄存器的上下文,然后再恢复另一个进程的上下文。内核还需要刷新CPU从虚拟内存到物理内存的映射,因为这些映射只对当前进程有效。***是操作系统上下文切换的开销ContextSwitch,调度函数SchedulerFunction选择下一个占用CPU的进程的开销。Gocon2014(31)现代处理器中的寄存器数量惊人。我很难将它们排列在一张幻灯片上,这让您了解保护和恢复它们需要多少时间。由于进程切换可能发生在进程执行的任何时刻,操作系统需要存储所有寄存器的内容,因为它不知道当前正在使用哪些寄存器。Gocon2014(32)这导致了线程的诞生,线程在概念上与进程相同,但共享相同的内存空间。由于线程共享地址空间,因此它们比进程更轻,因此它们的创建速度更快,切换速度也更快。Gocon2014(33)Goroutine升华了线程的思想。Goroutine是协同调度CooperativeScheduled,而不是依赖内核来调度。当对Go运行时调度程序进行显式调用时,goroutine之间的切换只会在明确定义的点发生。编译器知道哪些寄存器正在使用并自动保存它们。Gocon2014(34)虽然goroutines是协同调度的,但运行时会为你处理。在以下情况下,Goroutines可能会让位给其他协程:阻塞通道发送和接收。不过,Go声明不能保证新的goroutine会立即被安排。阻止文件和网络操作的系统调用。被垃圾回收周期停止后。Gocon2014(35)此示例说明了上一张幻灯片中描述的一些调度点。箭头所指的线程从左边的ReadFile函数开始。遇到os.Open,在等待文件操作完成时会阻塞线程,于是调度器将线程切换到正确的goroutine。继续执行直到从通道c读取,此时os.Open调用完成,所以调度器将线程切换回左侧继续执行file.Read函数,然后再次阻塞文件IO。调度程序将线程切换回右侧以进行另一个通道操作,该通道在左侧运行时已解锁,但在通道发送时再次阻塞。***,当Read操作完成,有数据可用时,线程切换回左边。Gocon2014(36)这张幻灯片展示了用低级语言描述的runtime.Syscall函数,它是os包中所有函数的基础。每当您的代码调用操作系统时,它都会通过此函数。调用entersyscall通知运行时线程即将阻塞。这允许运行时启动一个新线程,该线程将在当前线程被阻塞时为其他goroutine提供服务。这导致每个Go进程的操作系统线程相对较少,而Go运行时负责将可运行的Goroutines分配给空闲的操作系统线程。Gocon2014(37)在上一节中,我讨论了goroutines如何减少管理许多(有时数十万)并发执行线程的开销。Goroutine的故事还有另一面,那就是堆栈管理,这引出了我的最后一个话题。Gocon2014(38)这是一个进程的内存布局图。我们感兴趣的关键是堆和栈的位置。传统上,在进程的地址空间内,堆位于内存底部,程序(代码)之上并向上增长。堆栈位于虚拟地址空间的顶部并向下增长。Gocon2014(39)因为堆和栈相互覆盖的结果可能是灾难性的,操作系统通常会安排在栈和堆之间放置一个不可写的内存区域,以确保如果它们发生碰撞,程序将中止.这称为保护页,可以有效地限制进程的堆栈大小,通常在几兆字节的数量级。Gocon2014(40)我们已经讨论过线程共享同一个地址空间,所以对于每个线程来说,它必须有自己的栈。因为很难预测特定线程的堆栈需求,所以为每个线程的堆栈和保护页保留了大量内存。希望这些区域永远不会被使用,保护页面也永远不会被攻击。缺点是随着程序中线程数的增加,可用地址空间量会减少。Gocon2014(41)我们已经看到Go运行时将大量的goroutine调度到少量的线程上,那么这些goroutine的栈需求呢?Go编译器不使用保护页,而是在每次函数调用时插入检查以查看是否有足够的堆栈来运行该函数。如果不是,运行时可以分配更多堆栈空间。由于这种检查,goroutines的初始堆栈可以变得更小,这反过来又允许Go程序员将goroutines视为廉价资源。Gocon2014(42)这是一张展示Go1.2如何管理堆栈的幻灯片。当G调用H时,没有足够的空间让H运行,所以运行时从堆中分配一个新的栈帧,然后在新的栈段上运行H。H返回时,栈区返回堆,再返回G。Gocon2014(43)这种管理栈的方法一般效果很好,但是对于某些类型的代码,通常是递归代码,可能会导致程序的内部循环跨越这些堆栈边界之一。例如,在一个程序的内层循环中,一个函数G可以在循环中多次调用H,每次都会导致堆栈分裂。这被称为热分裂问题。Gocon2014(44)为了解决热拆分问题,Go1.3采用了全新的栈管理方式。如果goroutine的堆栈太小,则不会添加和删除其他堆栈段,而是分配一个新的更大的堆栈。旧堆栈的内容被复制到新堆栈,goroutine使用新的、更大的堆栈继续运行。第一次调用H后,堆栈将足够大,可用堆栈空间的检查将始终成功。这解决了热拆分问题。Gocon2014(45)值、内联、逃逸分析、Goroutines和分段/重复堆栈。这是我今天选择谈论的五个特性,但它们绝不是使Go成为一门快速语言的唯一因素,就像人们引用的学习Go的三个原因一样。尽管这五个特征非常强大,但它们并不是孤立存在的。例如,如果没有可扩展的堆栈,运行时将goroutine多路复用到线程上的方式几乎没有效率。内联通过将较小的函数组合成较大的函数来降低堆栈大小检查的成本。逃逸分析通过自动将从属实例从堆移动到堆栈来减轻垃圾收集器的压力。逃逸分析还提供了更好的缓存局部性CacheLocality。如果没有栈增长,逃逸分析可能会对栈造成太大的压力。Gocon2014(46)感谢Gocon组织者允许我今天发言twitter/web/emaildetails感谢@offbymany、@billkennedy_go和Minux帮助准备这次演讲。