编写与优化 Go 代码

Posted qcrao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了编写与优化 Go 代码相关的知识,希望对你有一定的参考价值。

这是 go-perfbook 翻译的第一部分,这本书虽然没有写完,但里面的内容还是很有价值的,建议每一个 gopher 都看一看~

编写与优化 Go 代码

本文档概述了编写高性能 Go 代码的最佳实践。

虽然会有一些使用缓存来提升服务速度的案例,但设计高性能的分布式系统超出了本文的范围。因为在监控和分布式系统设计方面已经有了足够多的优秀材料,且优化分布式系统是完全不同的一系列的研究与设计权衡。所以本书内容主要还是聚集在单一服务层面。

这本书被分成了不同的小节:

  1. 编写不太慢的软件的 tips

  • 入门级计算机知识

  • 编写快速的软件的 tips

    • 优化时需要了解的 Go 特性

  • 编写真正快速的软件的进阶 tips

    • 当你优化后的代码还是不够快时怎么办

    何时何地进行优化

    这个放在第一位,因为是最重要的一步。先要确定到底应不应该优化。

    优化都是有成本的。这种成本是以代码复杂度或认知负担呈现的 -- 优化后的代码一般都比原来的版本要更难理解。

    但优化往往能带来经济效益。作为一个程序员,时间宝贵。对于你来说,优化也是个机会成本的问题。因为可能还有项目等着你去做,有 bug 等着你去修,有特性等着你去开发。尽管优化很有意思,但并不一定是当前最应该做的事情。性能是重要的产品特性,但及时地发布和正确的软件也是应该做到的。

    有时 CPU 优化相比用户体验优化优先级没那么高,你应该选择最重要的事情。可能只是增加一个简单的进度条,或者让页面不要在进行计算的时候直接卡住。

    这在我们的工作中显而易见:三小时做的报告有时可能还不如我们几十分钟做的更有用。

    一个问题很容易优化,并不代表这个问题就值得优化。忽略当下不重要的问题,也是软件开发的大智慧。

    可以把上面的想法当成是在优化你的时间。

    你需要选择优化的对象和时机。你可以按照实际情况将优先级在“快速的软件”和“快速的部署”之间来回切换。

    人们经常听别人说,并且自己可能也会无意识地重复“提前优化是万恶之源”,但他们忽略了这几话的上下文。

    程序员浪费了大量的时间来考虑或担心他们程序中非关键部分的速度,这些提高效率的尝试在考虑到调试和维护的时候,实际上产生了很大的负面影响。我们得忽略那些不关键的效率提升,也就是说 97% 的时候我们要说:过早的优化是万恶之源。同时也不放弃那关键的 3% 的机会。-- Knuth

    Add: https://www.youtube.com/watch?time_continue=429&v=3WBaY61c9sE

    • 也不要看不起简单的优化

    • 对数据结构和算法有更多了解,会使更多的优化变的“简单”且“显而易见

    你应该优化么?

    是的,只有当优化问题是非常重要的,这个程序确实非常慢,并且用户除了对程序的正确,稳定和清晰以外,也有对速度的期望时。-- The Practice of Programming, Kernighan and Pike

    过早的优化也会伤害你,把你绑在某些决定上。如果需求发生变化,优化后的代码会更难修改,也更难丢弃(沉没成本谬论)。

    BitFunnel性能估计[1]有一些数字使这种权衡变得明确。想象一下,一个假想的搜索引擎需要在多个数据中心使用 30,000 台机器。这些机器每台成本约为 1,000 美元。如果你能把软件的速度提高一倍,这可以为公司每年节省 1500 万美元。即使是一个开发人员花一整年的时间来提高性能,只需 1% 就可以得到回报。

    在绝大多数情况下,程序的大小和速度并不是一个问题。最简单的优化是不需要这样做。第二简单的优化就是购买更快的硬件。

    一旦你决定要修改你的程序,请继续阅读。

    如何优化

    优化工作流

    在我们讨论具体问题之前,让我们先谈谈优化的一般过程。

    优化是重构的一种形式。只不过这种重构过程不是出于改善源代码的代码重复或清晰这些方面,而是为了改善性能:降低 CPU、内存使用、延迟等。这种改进通常是以可读性为代价的。这意味着,除了一套完整且全面的单元测试(以确保你的改动没有破坏任何逻辑),还需要一套好的基准测试,以确保改动对性能产生预期的影响。必须能够验证代码修改是否真的降低了CPU。有时候,你认为会提高性能的改变实际上会变成零或负的改变。在这种情况下,一定要记得撤消你的修改。

    What is the best comment in source code you have ever encountered? - Stack Overflow[2]:

    //
    // Dear maintainer:
    //
    // Once you are done trying to 'optimize' this routine,
    // and have realized what a terrible mistake that was,
    // please increment the following counter as a warning
    // to the next guy:
    //
    // total_hours_wasted_here = 42
    //
    

    你所使用的基准必须是正确的,并在有代表性的工作负载上提供可重复的数字。如果单个运行的差异太大,会使小的改进更难发现。需要使用benchstat[3]或同类的统计测试工具,而不能只靠单次或者肉眼对比。(注意,无论如何,使用统计测试是一个好主意。)运行基准的步骤应该被记录下来,任何定制的脚本和工具都应该被提交到代码仓库里,并说明如何运行它们。要注意运行时间较长的大型基准套件:这会使你的开发迭代被拖累速度。

    还要注意,任何可以测量的指标都可以被优化。请确保你使用的是正确的指标。

    下一步是决定你的优化目标是什么。如果目标是提高 CPU 使用效率,什么是可接受的速度?你想把当前的性能提高 2 倍?10 倍? 你能把它表述为 "在少于时间 T 的情况下解决一个大小为 N 的问题 "吗?你是想减少内存的使用吗?减少多少?慢多少是可以接受的?你愿意放弃什么来换取更低的空间需求?

    对服务延迟的优化是一个更棘手的问题。关于如何测试网络服务器的书已经写了一整本。主要的问题是,对于一个单一的功能,在给定的问题规模下,性能是相当一致的。对于网络服务,无法用单一的数字表示性能。一个靠谱的网络服务基准测试套件将为给定的 reqs/second 压力提供延迟分布结果。这个讲座对一些问题做了很好的概述。[Gil Tene的"如何不测量延迟"](https://youtu.be/lJ8ydIuPFeU "Gil Tene的 "如何不测量延迟"")

    性能目标必须具体,你一定能够使一些东西更快。但优化经常是一个收益递减的游戏,要知道何时应该停止。你打算投入多少精力来完成最后一点工作。你愿意让代码变得多难看、多难维护?

    Dan Luu之前提到的关于[BitFunnel性能估计]的讲座(http://bitfunnel.org/strangeloop)展示了一个使用粗略计算来确定你的目标性能数字是否合理的例子。

    Simon Eskildsen在SRECon的演讲中更深入地阐述了这个话题。高级餐巾纸数学:从第一原理估算系统性能[4]

    最后,Jon Bentley 的 "Programming Pearls" 中有一章题为 "The Back of the Envelope",涉及费米问题。可悲的是,由于在 20 世纪 90 年代和 21 世纪初微软式的"智力面试题"中使用了这些估计技能,这些技能给人的印象很差。

    对于零起点开发,不应该把基准测试留到最后再搞。尽管说"我们以后再解决"很容易,但如果性能真的很重要,一开始设计阶段就应该纳入考量。在临近收工时发现性能问题而需要架构大改,会给项目带来巨大的风险。请注意,在开发过程中,重点应该放在合理的程序设计、算法和数据结构上。不过较底层的技术栈的优化可以放到项目研发的后期再做,对系统性能有了全面了解之后再去做更合适。当系统还不完整时,难以得到正确的全局性能视角。

    "过早的劣化是指当你写的代码比它需要的速度慢时,通常是在进行不必要的额外工作,而同等复杂的代码会更快,而且应该自然而然地从你的手指中流出来。"

    -- Herb Sutter

    在 CI 过程中进行基准测试是比较难的,因为 CI 过程往往是混部的,这时候你得和同一台机器上的其它 CI 任务一起跑,所以 CI 结果会受其它任务影响,难以获得准确的指标。一个折衷是由开发人员在特定的硬件上自己跑 benchmark,并在 commit message 中将性能的变化数据附带上。如果是普通的功能性的 patch,就需要用肉眼在 code review 过程中捕捉性能衰退情况了。

    在大型系统上一般使用 profiling 采样,局部的独立组件写的则是 benchmark。你需要能在性能测试时,启动合理的上下文环境来模拟真实的情况。

    当前系统的性能和你的目标性能之间存在哪些差异,在找到差异之后,就知道该从哪里着手进行优化了,如果你只需要 10%~20% 的性能提升,可以通过一些实现的调整和较小的修复来达到目标。如果需要 10 倍这样的系数,那么只是用左移来替代乘法运算显然是不可能的。这需要你对代码进行反复分析,甚至需要为了这个性能目标把大部分模块推翻重做。

    性能优化需要了解不同层次的知识,从系统设计、网络、硬件(CPU、缓存、存储)、算法、调整和调试。在时间和资源有限的情况下,要考虑哪个层面带来的改进最大,这个并不一定总是在调整算法和程序、

    通常,优化应该是自顶向下的。系统级的优化肯定比表达式级的优化效果要好。你应该确定自己是在合适的层级解决性能问题。

    这本书大部分是讨论减少 CPU 的使用,减少内存使用和减少延迟。需要指出的是,这三者比较难兼得。可能 CPU 使用率低了,但内存占用上升了。可能内存使用下降了,但程序计算要花的时间更长了。

    阿姆达尔定律[5]告诉我们要关注瓶颈问题。如果你把只占运行时间 5% 的代码速度提高一倍,这只是在总时钟上提高了 2.5%。另一方面,如果将占用 80%时间的代码的速度只提高 10%,将使运行性能提高近 8%。Profile 能够帮助我们确定时间实际花在哪里。

    进行算法优化也可以减少 CPU 占用,比如你用 quicksort,肯定比冒泡排序快,因为它用更少的步骤就可以解决同样的问题。

    程序调整,就像编译器优化一样,通常只会对总的运行时间产生小的影响。大幅性能提升几乎总是来自于算法的改变或数据结构的改变,是你的程序组织方式的根本转变。编译器技术的改进是缓慢的。Proebsting's Law[6]说,编译器的性能每18翻一番,这与摩尔定律的(稍有误解的解释)形成鲜明对比,摩尔定律是每18将处理器性能翻一番。算法的改进对程序的改进更为显著。

    混合整数规划算法在1991年和2008年之间改进了30,000倍[7]。对于一个更具体的例子,考虑一下这个分解[8],将 Uber blog 中描述的蛮力地理空间算法替换为更适合所提出的任务的更特化的算法。没有任何编译器开关可以给你带来同等的性能提升。

    profiler 可能会告诉你,大量的时间花在了某个特定的过程上。这可能是一个昂贵的调用,也可能是一个低消耗的调用,只是被调用了许多次。与其立即尝试加快那个调用的速度,不如看看你是否能减少它被调用的次数或完全消除它。我们将在下一节中讨论更具体的优化策略。

    三个优化问题。

    • 我们有必要这样做吗?最快的代码是从未运行过的代码。

    • 如果是的话,这是不是最好的算法。

    • 如果是的话,这是不是这个算法的最佳实现。

    具体优化手段

    乔恩-本特利(Jon Bentley)1982年的作品《编写高效程序》(Writing Efficient Programs)将程序优化作为工程问题来研究:基准测试,分析,改进,验证,迭代。他的许多建议现在都由编译器自动完成。程序员的工作是使用编译器不能自动进行的那个转换优化。

    书中有总结:

    • http://www.crowl.org/lawrence/programming/Bentley82.html

    • http://www.geoffprewett.com/BookReviews/WritingEfficientPrograms.html

    程序的 tuning 规则:

    • https://web.archive.org/web/20080513070949/http://www.cs.bell-labs.com/cm/cs/pearls/apprules.html

    当对程序进行修改时,一般有两个选项:

    • 要么对数据做修改,要么对代码做修改

    数据修改

    改变你的数据意味着修改你的业务数据字段。从性能的角度来看,这些修改会影响后后续业务逻辑处理数据的时间复杂度。这个过程可能包含对你的数据提前进行一些预处理,以降低后续的数据处理负担。

    扩充你的数据结构的一些可能的做法:

    • 冗余字段

      这方面的典型例子是将一个链表的长度存储在头节点的一个字段中。保持它的更新需要更多的工作,但随后查询长度就变成了一个简单的字段查找,而不是一个O(n)的遍历过程。你的数据结构优化可能是这样的:在一些操作中增加一次额外的记录操作,换取高频使用场景下的更快的性能。

      类似地,存储指向经常访问的节点的指针,而不是执行额外的搜索。这涵盖了像双链接列表中的 "next" 链接,以使节点移除变成 O(1) 时间复杂度。一些跳表(skiplist)保留了一个"search finger",在这个位置保存了你上一次查询到的位置。这种优化是假设了下次查询从这个位置开始更好。

    • 冗余的搜索索引

      大多数数据结构都是为单一类型的查询而设计的。如果你需要两种不同的查询类型,在你的数据上有一个额外的 "视图" 可能就有很大的改进。例如,一个数据结构数组可能有一个主键 ID(整数),可以被用来在切片中查询,但还需要用一个次要的 ID(字符串)来查询。你可以用一个从字符串到 ID 或直接到结构本身的映射来增强你的数据结构,而不是在切片上进行迭代。

    • 额外的元素信息

      例如,保留一个你已经插入的所有元素的 Bloomfilter 可以让你快速返回查询 "不匹配"。bloomfilter 的设计应该是“小而快”,不要超过你主要的数据结构的存储成本。(如果主数据结构中的查找成本较低,bloomfilter 的维护成本可能超过这个查询成本)。

    • 如果查询成本很高,增加 cache 层

      在应用层,增加进程内、进程外(如 memcache) 缓存对提升查询效率有很大帮助。对于单个数据结构来说可能这样可能有点夸张,下面会详细讲讲缓存。

    当你需要的数据存储成本低且容易保持更新时,这类优化就很有用。

    这些都是在数据结构层面上 "少做工作" 的明显例子。它们都要花费空间。大多数时候,如果你对 CPU 进行优化,你的程序会使用更多的内存。这就是典型的[时空权衡](https://en.wikipedia.org/wiki/Space%E2%80%93time_tradeoff)。

    研究这种权衡如何影响你的解决方案是很重要的--它并不总是简单明了的。有时少量的内存可以带来显著的速度,有时权衡是线性的(2 倍的内存使用量==2 倍的性能加速),有时没那么明显:大量的内存只带来很小的速度提升。你需要在这条内存/性能曲线上的达到什么位置,会影响到你需要选择哪些算法。并不总是能简单地调调算法参数,有时不同的内存使用目标可能需要完全不一样的算法实现。

    查表法(lookup tables)也是一种空间换时间的折衷。表就是我们复杂计算过程计算出的结果的一种缓存。

    如果域足够小,那么全部结果都可以预先计算出来并存储在表中。popcount 是这种模式的一个很好的例子,其中字节中的设置位数被存储在一个 256 个条目的表中。一个更大的表可以存储所有 16 位字所需的比特。在这种情况下,他们存储的是精确的结果。

    一些三角函数的算法使用查表作为计算的起点。

    如果你的程序使用了太多的内存,也可以走另一条路。通过消耗更多 CPU 减少内存空间的使用。与其存储内容,不如每次都计算它们。可以在内存中存放压缩后的数据,并在需要时实时解压。

    如果你要处理的数据在磁盘上,你可以为你需要的数据创建索引,并只在内存中存储索引,而不是全量数据,也可以将文件拆分成一个一个的 chunk。

    小内存软件[9]是一本可在网上获得的书,涵盖了减少程序所使用的空间的技术。虽然这本书最初是针对嵌入式开发人员编写的,但其思想也适用于现代硬件上处理大量数据的程序。

    • 重新排列你的数据

      消除结构填充。删除多余的字段。使用一个较小的数据类型。

    • 改为较慢的数据结构

      简单的数据结构经常有较低的内存要求。例如,从一个指针很多的树形结构转为使用切片和线性搜索。

    • 为你的数据定制压缩格式

      压缩算法在很大程度上取决于被压缩的内容。最好选择一种适合你的数据的算法。如果数据是 byte 数组,像 snappy、gzip、lz4 这样的东西表现得很好。对于浮点数据,有 go-tsz 用于时间序列,fpc 用于科学数据。围绕压缩整数已经做了很多研究,通常是为了在搜索引擎中进行信息检索。例子包括 delta 编码和 varints,以及 Huffman 编码 xor-differences 等更复杂的方案。你也可以为你的数据研发特殊的定制压缩格式。

      数据是否可以压缩?随机访问还是流式访问?如果你需要访问单个条目,但又不想解压整个条目,你可以把数据压缩成较小的块,并保留一个索引,表明每个块中的条目范围。对单个条目的访问只需要检查索引和解压较小的数据块。

      如果你的数据不只是处理过程的数据,会被写入磁盘,那么数据迁移或添加/删除字段怎么办。你现在要处理的是原始的 []byte,而不是漂亮的结构化 Go 类型,所以你需要考虑 unsafe 的序列化选项。

    后面会更详细地讨论数据布局。

    现代计算机和内存分层使空间/时间的权衡变得不那么清晰。查表算法中的表很容易在内存中离代码较远(因此访问成本很高),这可能还不如重新计算一次值更快。

    这也意味着基准测试中看起来有改进的代码会经常由于缓存争用而在生产系统中没有改进(例如,在基准测试中查找表在处理器缓存中,但在实际系统中使用时总是 "新数据 "刷新缓存。) Google的Jump Hash论文[10]实际上直接解决了这个问题,比较了有竞争和无竞争的处理器高速缓存的性能。(参见Jump Hash论文中的图表4和5)

    sync.Map 是 Go 语言中针对 cache-contention 场景的解决方案。

    另一个需要考虑的方面是数据传输时间。一般来说,网络和磁盘访问是非常缓慢的,因此能够加载一个压缩块然后解压也比直接从磁盘上加载完整未压缩的内容消耗的 CPU 少得多。同样与往常一样,要有基准。二进制格式通常会比文本格式更小,解析速度更快,但代价是可读性降低。

    对于数据传输来说,可以转向不那么冗余的协议,或者增强 API 以允许局部查询。例如,可以将 API 实现为增量查询,而不是每次都被迫获取整个数据集。

    [1]

    BitFunnel性能估计: http://bitfunnel.org/strangeloop

    [2]

    What is the best comment in source code you have ever encountered? - Stack Overflow: https://stackoverflow.com/questions/184618/what-is-the-best-comment-in-source-code-you-have-ever-encountered

    [3]

    benchstat: https://golang.org/x/perf/benchstat

    [4]

    高级餐巾纸数学:从第一原理估算系统性能: https://www.youtube.com/watch?v=IxkSlnrRFqc

    [5]

    阿姆达尔定律: https://en.wikipedia.org/wiki/Amdahl%27s_law

    [6]

    Proebsting's Law: http://proebsting.cs.arizona.edu/law.html

    [7]

    在1991年和2008年之间改进了30,000倍: https://agtb.wordpress.com/2010/12/23/progress-in-algorithms-beats-moore%E2%80%99s-law/

    [8]

    这个分解: https://medium.com/@buckhx/unwinding-uber-s-most-efficient-service-406413c5871d

    [9]

    小内存软件: http://smallmemory.com/book.html

    [10]

    Jump Hash论文: https://arxiv.org/pdf/1406.2294.pdf

    欢迎关注 TechPaper 和码农桃花源

以上是关于编写与优化 Go 代码的主要内容,如果未能解决你的问题,请参考以下文章

你知道的Go切片扩容机制可能是错的

Go语言性能剖析利器--pprof实战

TDD测试驱动开发:Hello, YOU -- 通过简单的 hello_test.go 程序学会编写测试,学习使用常量来优化并根据需要重构(谁说hello world就只是 hello world?)

详解Go内联优化

[Go] 通过 17 个简短代码片段,切底弄懂 channel 基础

Go使用Go语言打造定时提醒小工具,从基础到优化全方位探索