Go日记——你想让Go快一点吗?

译自>https://bravenewgeek.com/so-you-wanna-go-fast/

编写高性能Go的技巧

到目前为止,我已经忘记了我在写什么,但是我保证这篇文章是关于Go的。确实如此,并且很大程度上取决于性能的提高,而不是交付的速度—两者常常彼此矛盾。到目前为止,一切都只是无用的上下文和抱怨。但这也向您表明,我们正在解决一些难题,以及为什么我们要保持现状。总有历史。

我和很多聪明人一起工作。我们中的许多人几乎都对性能抱有痴迷 ,但我早些时候试图指出的一点是,我们正在努力超越云软件的预期范围。App Engine有一些严格的界限,因此我们进行了更改。自从采用Go以来,我们已经学到了很多关于如何使事情变得更快以及如何使Go在系统编程领域工作的知识。

Go的简单性和并发模型使其成为后端系统的诱人选择,但更大的问题是它对延迟敏感的应用程序有何好处?有必要牺牲语言的简单性来使其更快吗?让我们逐步了解Go中的性能优化的几个方面,即语言功能,内存管理和并发性,并尝试做出决定。此处提供的所有基准测试代码都可以在GitHub上找到

Channel

Go中的Channel吸引了很多注意力,因为它们是方便的并发原语,但是了解它们的性能影响很重要。通常,在大多数情况下,性能都“足够好”,但是在某些对延迟有严格要求的情况下,它们可能会造成瓶颈。Channel不是魔术。在引擎盖下,他们只是在做锁定。这在没有锁争用的单线程应用程序中效果很好,但是在多线程环境中,性能会大大降低。我们可以使用无锁环形缓冲区很容易地模仿Channel的语义。

第一个基准测试着眼于具有单个生产者和单个消费者的单项缓冲Channel和环形缓冲区的性能。首先,我们看一下单线程情况下的性能(GOMAXPROCS = 1)。

BenchmarkChannel 3000000 512 ns / op
BenchmarkRingBuffer 20000000 80.9 ns / op

如您所见,环形缓冲区大约快六倍(如果您不熟悉Go的基准测试工具,基准测试名称旁边的第一个数字表示基准测试在给出稳定结果之前的运行次数)。接下来,我们看一下具有GOMAXPROCS = 8的相同基准 。

BenchmarkChannel-8 3000000 542 ns / op
BenchmarkRingBuffer-8 10000000 182 ns / op

环形缓冲区快了将近三倍。

渠道通常用于在一群工人之间分配工作。在此基准测试中,我们着眼于在缓冲Channel和环形缓冲区上具有高读取争用的性能。所述GOMAXPROCS = 1 试验表明信道如何可用于单线程系统决然更好。

BenchmarkChannelReadContention 10000000 148 ns / op
BenchmarkRingBufferReadContention 10000 390195 ns / op

但是,在多线程情况下,环形缓冲区更快:

BenchmarkChannelReadContention-8 1000000 3105 ns / op
BenchmarkRingBufferReadContention-8 3000000 411 ns / op

最后,我们在阅读器和书写器上都对性能进行了研究。同样,环形缓冲区的性能 在单线程情况下要差得多,而在多线程情况下要好。

BenchmarkChannelContention 10000 160892 ns / op
BenchmarkRingBufferContention 2 806834344 ns / op
BenchmarkChannelContention-8 5000 314428 ns / op
BenchmarkRingBufferContention-8 10000 182557 ns / op

无锁环形缓冲区仅使用CAS操作即可实现线程安全。我们可以看到,决定在Channel上使用它很大程度上取决于程序可用的OS线程数。对于大多数系统, GOMAXPROCS> 1,因此在性能很重要时,无锁环形缓冲区往往是更好的选择。对于在多线程系统中执行对共享状态的高性能访问而言,Channel是一个相当差的选择。

Defer

Defer是Go中有用的语言功能,可提高可读性并避免与释放资源有关的错误。例如,当我们打开一个文件进行读取时,我们需要注意在完成后将其关闭。如果没有defer,我们需要确保在函数的每个退出点关闭文件。

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        file.Close()
                        return nil
                }
        }

        file.Close()
        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

这真的很容易出错,因为很容易错过返回点。Defer通过将清除代码有效地添加到堆栈中并在封闭函数返回时调用它来解决此问题。

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        defer file.Close()
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        return nil
                }
        }

        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

乍一看,人们会认为编译器可以完全优化defer语句。如果我在函数的开头推迟了一些操作,只需在函数返回的每个点插入闭包即可。但是,它比这更复杂。例如,我们可以 在条件语句或循环中推迟调用。第一种情况可能要求编译器跟踪导致defer的条件。编译器还需要能够确定语句是否可能出现紧急情况,因为这是函数的另一个退出点。至少从表面上静态证明这似乎是一个无法确定的问题。

关键是Defer不是零成本的抽象。我们可以对其进行基准测试以显示性能开销。在此基准测试中,我们将锁定互斥锁并在循环中使用defer进行解锁与 锁定互斥锁并在没有defer的情况下进行解锁进行比较 。

BenchmarkMutexDeferUnlock-8 20000000 96.6 ns / op
BenchmarkMutexUnlock-8 100000000 19.5 ns / op

在此测试中,使用Defer几乎慢了五倍。公平地讲,我们希望相差77 纳秒,但是在关键路径上的紧密循环中,这相加。您会注意到这些优化的一种趋势,通常是由开发人员在性能和可读性之间做出权衡。优化很少是免费的。

反射和JSON

反射通常较慢,对于延迟敏感的应用程序应避免反射。JSON是一种常见的数据交换格式,但是Go的encoding / json包依赖于对编组和解组结构的反射。使用ffjson,我们可以使用代码生成来避免反射并确定差异。

BenchmarkJSONReflectionMarshal-8 200000 7063 ns / op
BenchmarkJSONMarshal-8 500000 3981 ns / op

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / op
BenchmarkJSONUnmarshal-8 300000 5839 ns / op

代码生成的JSON比标准库基于反射的实现快38%。当然,如果我们担心性能,则应该完全避免使用JSON。MessagePack 是更好的选择,还可以生成序列化代码。在此基准测试中,我们使用msgp库并将其性能与JSON进行比较。

BenchmarkMsgpackMarshal-8 3000000 555 ns / op
BenchmarkJSONReflectionMarshal-8 200000 7063 ns / op
BenchmarkJSONMarshal-8 500000 3981 ns / op

BenchmarkMsgpackUnmarshal-8 20000000 94.6 ns / op
BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / op
BenchmarkJSONUnmarshal-8 300000 5839 ns / op

这里的区别是巨大的。即使与生成的JSON序列化代码相比,MessagePack的速度也明显更快。

如果我们确实在尝试进行微优化,则还应注意避免使用接口,因为接口不仅会封送处理,而且还会带来方法调用的开销。与其他类型的动态分派一样,在运行时对方法调用执行查找时,存在间接开销。编译器无法内联这些调用。

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / op
BenchmarkJSONReflectionUnmarshalIface-8 200000 10099 ns / op

我们还可以查看调用查找I2T的开销,该调用将接口转换为支持的具体类型。此基准在相同的结构上调用相同的方法。不同之处是第二个引用了该结构所实现的接口。

BenchmarkStructMethodCall-8 2000000000 0.44 ns / op
BenchmarkIfaceMethodCall-8 1000000000 2.97 ns / op

排序是一个更实际的示例,它显示了性能差异。在此基准测试中,我们比较了对1,000,000个结构的切片和1,000,000个由相同结构支持的接口的排序。对结构进行排序的速度比对接口进行排序的速度快将近92%。

BenchmarkSortStruct-8 10 105276994 ns / op
BenchmarkSortIface-8 5 286123558 ns / op

总而言之,请尽可能避免使用JSON。如果需要,请生成marshaling和 unmarshaling代码。通常,最好避免使用依赖于反射和接口的代码,而应编写使用具体类型的代码。不幸的是,这通常会导致很多重复的代码,因此最好通过代码生成来抽象它。权衡再次显现。

内存管理

Go实际上不会直接向用户公开堆或堆栈分配。实际上,单词“ heap”和“ stack”在语言规范中没有出现。这意味着与堆栈和堆有关的所有内容在技术上都依赖于实现。当然,实际上,Go确实每个goroutine有一个堆栈和一个堆。编译器会进行转义分析,以确定对象是否可以存在于堆栈中或需要在堆中分配。

毫不奇怪,避免堆分配可能是优化的主要领域。通过在堆栈上进行分配,我们避免了昂贵的malloc调用,如下面的基准所示。

BenchmarkAllocateHeap-8 20000000 62.3 ns / op 96 B / op 1分配/操作
BenchmarkAllocateStack-8 100000000 11.6 ns / op 0 B / op 0 allocs / op

自然,按引用传递比按值传递更快,因为前者仅需要复制一个指针,而后者则需要复制值。尽管这些差异主要取决于必须复制的内容,但是与这些基准中使用的结构的差异可以忽略不计。请记住,在此综合基准中可能还会执行一些编译器优化。

BenchmarkPassByReference-8 1000000000 2.35 ns / op
BenchmarkPassByValue-8 200000000 6.36 ns / op

但是,堆分配的更大问题是垃圾回收。如果要创建许多短期对象,则会导致GC崩溃。在这些情况下,对象池变得非常重要。在此基准测试中,我们比较了在堆中与 在同一目的中使用sync.Pool的 10个并发goroutine中的分配结构。合并可将性能提高5倍。

BenchmarkConcurrentStructAllocate-8 5000000 337 ns / op
BenchmarkConcurrentStructPool-8 20000000 65.5 ns / op

需要指出的是Go的sync.Pool在垃圾回收期间被耗尽了。sync.Pool目的是在垃圾回收之间重用内存。一个人可以维护自己的空闲对象列表,以在整个垃圾回收周期中保存到内存中,尽管这可以颠覆垃圾回收器的用途。Go的pprof工具对于分析内存使用情况非常有用。在盲目地进行内存优化之前使用它。

伪共享

当性能真的很重要时,您必须开始在硬件级别上进行思考。一级方程式赛车手杰基·斯图尔特(Jackie Stewart)曾说过一句著名的话:“您不必成为一名工程师就可以成为赛车手,但是您必须具有了解机械的思维。”对汽车内部构造的深刻理解使您是更好的司机。同样,了解计算机的实际运行方式会使您成为更好的程序员。例如,如何布置内存?CPU缓存如何工作?硬盘如何工作?

内存带宽仍然是现代CPU架构中有限的资源,因此缓存对于防止性能瓶颈非常重要。现代多处理器CPU以较小的行缓存数据,通常为64字节大小,以避免昂贵的主内存访问。写入内存将导致CPU高速缓存逐出该行以保持高速缓存的一致性。随后对该地址进行读取需要刷新高速缓存行。这是一种称为错误共享的现象 ,当多个处理器在同一高速缓存行中访问独立数据时,这尤其成问题。

想象一下Go中的结构及其在内存中的布局。让我们以较早的环形缓冲区为例。该结构通常如下所示:

type RingBuffer struct {
	queue          uint64
	dequeue        uint64
	mask, disposed uint64
	nodes          nodes
}

队列和出队字段分别用于确定生产者和消费者的位置。这些字段均为8个字节,由多个线程同时访问和修改,以从队列中添加和删除项目。由于这两个字段连续放置在内存中,并且仅占用16个字节的内存,因此它们很可能存储在单个CPU高速缓存行中。因此,写入一个将导致驱逐另一个,这意味着随后的读取将停止。更具体地说,在环形缓冲区中添加或删除内容会导致后续操作变慢,并导致CPU缓存大量混乱。

我们可以通过在字段之间添加填充来修改结构。每个填充都是单个CPU缓存行的宽度,以确保字段以不同的行结尾。我们最终得到以下内容:

type RingBuffer struct {
	_padding0      [8]uint64
	queue          uint64
	_padding1      [8]uint64
	dequeue        uint64
	_padding2      [8]uint64
	mask, disposed uint64
	_padding3      [8]uint64
	nodes          nodes
}

填充CPU缓存行实际上有多大差异?与任何东西一样,这取决于。这取决于多处理量。这取决于争用的数量。这取决于内存布局。有许多因素需要考虑,但是我们应该始终使用数据来支持我们的决策。我们可以对带或不带填充的环形缓冲区进行基准测试,以了解实际的区别。

首先,我们对单个生产者和单个消费者进行基准测试,每个生产者和单个消费者都在goroutine中运行。通过此测试,填充和未填充之间的改进非常小,大约为15%。

BenchmarkRingBufferSPSC-8 10000000 156 ns / op
BenchmarkRingBufferPaddedSPSC-8 10000000 132 ns / op

但是,当我们有多个生产者和多个消费者(例如说每个100个)时,差异会变得更加明显。在这种情况下,填充版本的速度提高了约36%。

BenchmarkRingBufferMPMC-8 100000 27763 ns / op
BenchmarkRingBufferPaddedMPMC-8 100000 17860 ns / op

虚假共享是一个非常现实的问题。根据并发和内存争用的数量,可能有必要引入填充以帮助减轻其影响。这些数字看似微不足道,但它们开始加起来,尤其是在每个时钟周期都很重要的情况下。

无锁

无锁数据结构对于充分利用多核至关重要。考虑到Go是针对高度并发的用例的,因此它并没有提供很多无锁的方式。鼓励似乎主要针对渠道,较小程度上是互斥。

就是说,标准库的确提供了带有atomic包的通常的低级内存原语。比较和交换,原子指针访问—一切都在那里。但是,强烈建议不要使用atomic包 :

我们通常根本不希望使用同步/原子…经验一再向我们展示,很少有人能够编写使用原子操作的正确代码…如果我们在添加同步时想到了内部软件包/ atomic包,也许我们会使用它。现在,由于Go 1保证,我们无法删除该软件包。

到底无锁到底有多难?只需在上面擦一些CAS,然后将其命名为天吧?经过足够的自负后,我开始了解到这绝对是一把双刃剑。无锁代码可能会很快变得复杂。原子和不安全的软件包不容易使用,至少在一开始并不容易。后者之所以命名是有原因的。轻轻踩一下-这是危险的领域。更重要的是,编写无锁算法可能很棘手且容易出错。简单的无锁数据结构(如环形缓冲区)非常易于管理,但除此之外,其他所有事情都开始变得繁琐。

Ctrie,我详细地写,是我涉足无锁数据结构的超越世界的队列和列表的标准收费。尽管该理论是可以合理理解的,但其实现却非常复杂。实际上,复杂性很大程度上是由于缺少本机的双重比较和交换,它需要原子地比较间接节点(以检测树上的突变)和节点代(以检测树的快照)。由于没有硬件提供这种操作,因此必须使用标准原语对其进行仿真(并且可以)。

实际上,第一个Ctrie实现被严重破坏了,甚至不是因为我没有正确使用Go的同步原语。相反,我对语言做了错误的假设。Ctrie中的每个节点都有一个与其关联的世代。拍摄树的快照时,其根节点将复制到新一代。当访问树中的节点时,它们会被懒惰地复制到新一代(称为持久数据结构),从而可以进行恒定时间的快照。为了避免整数溢出,我们使用在堆上分配的对象来划分世代。在Go中,这是使用空结构完成的。在Java中,两个新构造的对象在比较时并不等效,因为它们的内存地址会有所不同。我盲目地认为Go中也是如此,但实际上并非如此。从字面上看,Go语言规范如下:

如果结构或数组类型不包含大小大于零的字段(或元素),则其大小为零。两个不同的零大小变量在内存中可能具有相同的地址。

哎呀。结果是两个不同的世代被认为是等效的,因此双重比较和交换总是成功的。这使快照有可能使树处于不一致状态。这是一个有趣的错误,值得追踪。调试高度并发,无锁的代码真是麻烦。如果您第一次没有做对,您将 花费大量时间进行修复,但前提是会出现一些非常细微的错误。而且不太可能您第一次就正确。这次您赢了,伊恩·兰斯·泰勒(Ian Lance Taylor)。

可是等等!显然,使用复杂的无锁算法会有所收获,或者为什么还要有人对此进行约束?使用Ctrie,查找性能可与同步映射或并发跳过列表媲美。插入由于增加了间接性而更加昂贵。Ctrie的真正好处在于它在内存消耗方面的可伸缩性,这与大多数哈希表不同,它始终是树中当前键的数量的函数。另一个优点是它可以执行恒定时间的线性化快照。我们可以比较使用Ctrie使用相同的测试在100个不同的goroutine中在同步映射上同时执行“快照”:

BenchmarkConcurrentSnapshotMap-8 1000 9941784 ns / op
BenchmarkConcurrentSnapshotCtrie-8 20000 90412 ns / op

根据访问模式,无锁数据结构可以在多线程系统中提供更好的性能。例如,NATS消息总线使用基于同步地图的结构来执行订阅匹配。与Ctrie启发的无锁结构相比,吞吐量可扩展得多。蓝线是基于锁的数据结构,红线是无锁的实现。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K72NtSbt-1570527660635)(leanote://file/getImage?fileId=5d9c4bb066be486e4c000004)]

视情况而定,避免锁定可能会带来好处。将环形缓冲区与Channel进行比较时,优势显而易见。尽管如此,重要的是权衡任何好处与代码的复杂性。实际上,有时无锁根本无法提供任何明显的好处!

关于优化的注意事项

正如我们在整个文章中看到的那样,性能优化几乎总是要付出代价的。识别和理解优化本身只是第一步。更重要的是了解何时何地应用它们。由唐纳德·努斯(Donald Knuth)推广的CAR Hoare著名语录已成为程序员的长期箴言:

真正的问题是程序员花了太多时间来担心在错误的地方和错误的时间效率。过早的优化是编程中所有邪恶(或至少是大多数邪恶)的根源。

尽管这句话的重点不是完全消除优化,而是要学习如何在速度之间达成平衡,这些速度包括算法速度,交付速度,维护速度,系统速度。这是一个非常主观的话题,没有一个经验法则。过早的优化是万恶之源吗?我应该让它工作,然后使其快速吗?它是否需要很快?这些不是二元决策。例如,如果设计中存在根本性问题,有时使其工作然后再使其快速运行是不可能的。

但是,专注于沿着关键路径进行优化,并在必要时从该路径向外延伸。你离关键路径走得越远,您的投资回报就越有可能减少,最终浪费的时间也就越多。确定什么是足够的性能很重要。不要花时间超过那一点。在这一领域中,数据驱动的决策至关重要—是经验性的,而非冲动性的。更重要的是,要务实。如果没关系,将操作减少十亿分之一秒是没有用的。快速执行比快速执行代码要多。

总的来说

恭喜,如果您到目前为止已经做到了,那么您可能有问题。我们已经了解到,软件的速度实际上有两种:交付速度和性能。客户需要第一,开发人员需要第二,而CTO都需要。到目前为止,第一个是 最重要的,至少在您尝试上市时。第二个是您需要计划和迭代的东西。两者通常彼此矛盾。

也许更有趣的是,我们研究了几种方法,可以在Go中获得额外的性能,并使其在低延迟系统中可行。语言设计得很简单,但有时有时要付出代价。就像两个斋戒之间的权衡一样,代码生命周期和代码性能之间也存在类似的权衡。速度以简化为代价,以开发时间为代价,并以持续维护为代价。做出明智的选择。