Go 进阶 · 分布式爬虫实战day06 -性能优化

313 阅读19分钟

在这节课,我将更进一步,分析一下影响高性能程序的底层基石:代码实施、操作系统与硬件。分析如何在这些层面进行设计和调优,最终实现高性能的目标。

让我们先从代码实施阶段讲起。

代码实施级别

编写代码时的性能优化有三层境界:

  • 合理的代码;
  • 刻意的优化;
  • 危险的尝试。

合理的代码

这些规范涉及程序开发的方方面面,包括目录结构规范、测试规范、版本规范、目录结构规范、代码评审规范、开发规范等等。这部分你可以参考一下UBER 开源的 Go 语言开发规范。有时只需要遵守一些简单的规则,就能够大幅度减少未来在性能方面的困扰(我在后面将会给出一版更详细的 Go 语言开发规范)。不合理的代码是什么样的呢?我们来看看下面这段程序,它的目的是往切片中循环添加元素。


func createSlice(n int) (slice []string) { 
   for i := 0; i < n; i++ {
      slice = append(slice, "I", "love", "go") 
   }
   return slice
}

从功能上来看,这段代码没有问题。但是,这种写法忽略了一个事实,如下图所示,往切片中添加数据时,切片会自动扩容,Go 运行时会创建新的内存空间并执行拷贝。

自动扩容显然不是没有成本的。在循环操作中执行这样的代码会放大性能损失,减慢程序的运行。 我们可以改写一下上面这段程序,在初始化时指定合适的切片容量:


func createSlice(n int) []string {
   slice := make([]string, 0, n*3)
   for i := 0; i < n; i++ {
      slice = append(slice, "I", "love", "go") 
   }
   return slice
}

这段代码在一开始就指定了需要的容量,最大程度上减少了内存的浪费。同时,运行时不需要再执行自动扩容的操作,加速了程序的运行。

除了切片,哈希表也比较容易犯类似的错误。一叶知秋,这个案例启发我们,即便是一开始不太理解底层的实现逻辑,也需要遵守一些基本的规则规避常见的性能陷阱,从小处开始优化代码编写习惯。

除了遵守常见的规范,要写出合理的代码,还需要对算法和数据结构进行设计改造。有人说“程序 = 算法 + 数据结构”,可见这两者的重要性。对于算法来说,关键算法的调整常常能够给性能带来数倍的提升。 例如,将冒泡排序(o(n^2))替换为快速排序 (o(n*logn)),将线性查找 (o(n)) 替换为二分查找(o(log n)),甚至到哈希表的方式(o(1)),都是常见的算法升级。

对数据结构的优化指的是添加或更改正在处理的数据的表示。 数据结构在很大程度上决定了数据的处理方式,进而决定了时间与空间复杂度。举一个简单的例子,如下图所示,如果在链表的头部节点添加一个表明链表长度的字段,就不必遍历链表才能得到链表的总长度了。

有时候,我们需要做一些空间换时间的 trade-off。 缓存就是一种提高性能,减少数据库访问和防止全局结构锁的机制。缓存这种空间换时间的策略,在高并发程序中的应用非常广泛。不管是 CPU 多级缓存、Go 的运行时调度器,还是 Go 内存分配管理,甚至标准库中 sync.Pool 的设计都包含了利用局部缓存提高整体并发速度的设计。

在设计算法与数据结构时要考虑的另一个重要因素是实现的复杂度。 如下图所示,Go 1.13 之前,内存分配使用了 Treap 平衡树,Treap 是一种引入了随机数的二叉树搜索树,它的实现简单,并且引入的随机数和必要的旋转保证了比较好的平衡特性。又如,Redis 中选择跳表这样的数据结构是考虑到了红黑树等结构在实现上的复杂性。

当完成重要过程的优化之后,你应该对修改的功能进行 Benckmark 性能测试,并通过benchstat 工具对比两次 Benckmark 的差别,做到心中有数。

刻意的优化

写出合理的代码是一个优秀系统的第一步,但这还不够,为了达到性能目标,有时候我们需要对代码进行刻意的优化。例如,虽然 Go 语言适用于大多数通用场景,但是有时候我们业务面临的场景比较特殊,这时候就需要单独进行调优。刻意优化的点有很多,细化一点可以是:

  • 放入接口中的数据会进行内存逃逸,需不需要优化?
  • 字节数组与 String 互转导致的性能损失需不需要优化?
  • 无用的内存需不需要复用? 当前,我们不用考虑这种细节问题, 因为这些优化点带来的性能损失很微小,很少成为程序真正的瓶颈,至少在项目开发的初期是这样。这里我想要讨论的刻意优化,是当前面临的最核心、最急需解决的问题。

优化的前提是定位瓶颈问题。 能够发现程序哪里有问题并想办法解决,对开发者来说比写出合理的程序更难。因为排查问题时的不确定性更多,需要掌握的知识也更多。在后面的课程中,我会通过具体的案例为你演示如何通过 pprof、trace、dlv、gdb 等工具定位瓶颈问题。瓶颈问题需对症下药,工具暴露出来的瓶颈通常就是我们要优化的目标。有时这种瓶颈是不明显的,需要开发者做一些假设并验证自己的猜想。有时数据结构和算法在之前是合理的,但是随着并发越来越大,就开始变得不合理了。

举一个例子,程序中常常使用 JSON 进行结构体的序列化,但是由于标准 JSON 库使用了大量反射,当并发量大幅度增加时,JSON 标准库的耗时就可能变为瓶颈,这时需要考虑将标准库替换为更快的第三方库,甚至需要使用 protobuf 等更快的序列化方式。

还有一些优化涉及到 Go 语言的编译时和运行时。例如,之前介绍过的将环境变量 GOMAXPROC 调整为更合适的大小,本质上就是在修改运行时可并行的线程数量。此外,当并发量上来之后,垃圾回收(GC)也可能成为系统的瓶颈所在。GC 有一段 STW 的时长完全不能执行用户协程,并且在并行标记期间会占用 25% 的 CPU 时间。如果 STW 时间过长,或者并发标记阶段由于频繁的内存分配触发了辅助标记,都会导致程序无法有效处理用户协程,产生严重的响应超时问题。

一般这类 GC 问题可以通过修改代码逻辑减少内存分配频繁,或是借助 sync.pool 等内存池复用内存来解决。另外,运行时也暴露了一些有限的 API 能够干预垃圾回收的运行,在特殊情况下调整这些参数能够提高程序运行效率:

  • 运行时环境变量 GOGC 可以调整 GC 的内存触发水位,当 GOGC=off 时,它甚至能够关闭 GC 的执行;
  • Runtime.GC() 可以手动强制执行 GC。 另外,设置运行时环境变量 GODEBUG=gctrace=1 可以让运行时打印 GC 的相关日志。还有一些刻意的优化与 Go 的版本有关,需要具体版本具体分析。例如,Go1.14 之前的版本,死循环没有办法被抢占,因此经常出现程序被卡死的现象。那在使用这些版本时,就不得不做一些特殊的判断和处理了。

危险的尝试

最后,代码实施阶段,由于迫不得已的原因,可能还需要进行一些特别的“危险的尝试”。例如,由于很多机器学习库是用 C 或者 C++ 完成的,因此需要使用 CGO 的技术。我曾经深度写过 CGO 相关的项目,可以说是苦不堪言。你可以看看下面这段代码,在这里,开发会面临各种问题:没有编辑器的提示,语法繁琐,难以调试,内存不受到 Go 运行时的管理。所以说不到万不得已,不要使用 CGO。

另外,Go 语言语法本身屏蔽了指针的操作,但有些场景为了提高性能,或为了使用某些高级能力会用到 unsafe 库操作指针。然而,想要正确地使用 unsafe 是很难的。

  • 首先,Go 语言中的 unsafe 库本身不是向后兼容的,这意味着在当前版本中有效的代码在之后的版本中的行为是未知的。
  • 另外,对指针进行运算的 uintptr 类型本质上是一个整数,Go 内置的垃圾回收无法对它进行管理。操作指针时,由于 Go 运行时栈的自动扩容,可能导致之前指针指向的内容无效。这些危险的操作,需要开发者摸透使用规则并进行正确的权衡(unsafe 包的具体用法可以参考这篇文章)。

有些底层操作,为了获得更高的性能或者使用语言级别未暴露的功能(例如特殊的 CPU 指令),甚至需要书写汇编代码。举一个例子,TiDB 数据库为了提高浮点数计算性能就使用了汇编代码。刚才,我把代码实施分成了三层境界:合理的优化,刻意的优化,危险的优化。当我们能写出合理的代码时,才算是入了门,成为了一个合格的开发者。而当我们可以精细化地调优,驾驭整个程序时,我们的技术功夫才算是走向了成熟。代码实施就像盖房子,这座房子是由一块块砖砌成的,在码好每块砖的同时,还需要发挥创造力,这是更加考验开发者功力的时刻。接下来我们继续看看程序的基座,也就是操作系统对性能的影响。

操作系统级别

Linux 操作系统位于硬件与用户应用程序之间。它一方面托管了与硬件的交互,另一方面提供了与应用程序交互的 API。具体来说,Linux 操作系统提供了下面几个重要的功能:

  • 进程管理,例如进程启动与管理;
  • 内存管理,例如为进程分配内存或将文件映射到内存;
  • 网络管理,例如提供网络编程的 API 以及处理 TCP 协议栈;文件管理,例如文件系统的组织、创建和删除;
  • 设备管理。

一个程序通常会用到操作系统提供的多种服务,要在操作系统层面解决瓶颈问题,我们要做的第一步就是明确我们需要优化哪一个方向。 例如,我们希望排查系统 CPU 利用率高的问题,主要关注的是操作系统对进程的管理与调度;如果要排查内存问题,则主要关注内存分配与缓存等问题;如果网络耗时过长,我们主要关注的则是操作系统的网络协议栈。当然,有一些问题可能是交叉的,例如,频繁的内存与磁盘的交换(swap)也会导致 CPU 利用率的飙升。

要在操作系统层面解决瓶颈问题,我们要做的第二步是熟悉我们希望优化的操作系统的核心功能流程与架构,从而才能有针对性地使用相关工具进行验证。例如,要排查 CPU 利用率过低的问题,需要明确操作系统如何将程序调度到 CPU 中执行、哪些问题可能导致 CPU 陷入等待或者发生切换、中断。

要解决这些问题,我们需要掌握相应的概念和知识,其中就包括了操作系统调度的原理。操作系统将程序分为了多个线程,并将线程调度到 CPU 上运行。为了公平地调度每一个线程,Linux2.6 之后引入了 CFS 调度器,如下图所示。线程按照运行的优先级存储在红黑树结构中,红黑树中最左侧的线程会优先被调度执行。

image.png 下面几种情况都可能导致应用程序的 CPU 利用率低:

  • 从线程可以运行到线程实际运行仍然有一定延时,当运行的程序越来越多,这种延迟会更加明显;
  • 线程在执行过程中,可能会陷入到等待磁盘 I/O 的数据返回,I/O 等待时间越长,程序运行越慢;
  • 线程在执行过程中,调度器会定时检查当前线程是否需要被抢占,执行线程的上下文,上下文切换越频繁,实际执行有用代码的时间就越短;
  • 除此之外,影响 CPU 运行的原因可能来自硬 / 软中断信号,这时 CPU 会暂停当前的任务并执行中断处理程序

在操作系统层面解决瓶颈问题的第三步,是要用对应的工具验证和排查瓶颈问题。

例如我们要排查系统 CPU 利用率异常的问题,其实就是要查看 CPU 在一段时间内更多的在做哪一部分的工作。我们期望 CPU 能够更多的执行应用程序交代的工作,而不是陷入到执行内核线程或者是大量时间堵塞在与硬件设备的 I/O 交互中。

我们有多种工具可以观察当前操作系统的资源利用情况。以 CPU 利用率为例,最常见的 TOP 命令可以查看用户 CPU 使用率、系统 CPU 使用率、IO WAIT 率。而 mpstat 命令可以查看 CPU 软中断和硬中断使用率。不同指标的使用率上升对应的常见原因如图所示。从而我们可以更进一步明确 CPU 利用率异常的原因

如果你想要了解更多在操作系统级别分析 CPU、内存、文件、网络、磁盘等资源的现状、瓶颈、方法和工具,可以参考《Systems Performance, 2nd Edition》这本书。像 perf 这类工具甚至可以查看到操作系统的线程堆栈信息,输出 CPU 火焰图,快速寻找最可能的代码瓶颈。在某一个程序卡死导致无法响应的时候,或者在排查程序调用耗时过长问题时尤其有用。

容器化时代对分析和排查性能问题又提出了新的挑战。例如,Linux 通过 Cgroup 和 Namespace 技术构建了轻量级的虚拟容器把资源隔离开,这样容器只能够使用限制好的资源,而不能使用宿主机的全部资源。

由于操作系统的很多观测手段还不成熟,一些初学者很容易被误导。例如在容器中 top 命令获得的 cpu idle 和 load average 信息实际上是宿主机的信息,而进程的利用率等信息只是容器内部的信息。如果不清楚信息实际的含义,将导致我们得出错误的结论。这时候我们就可以使用像cadvisor这种第三方库获取容器的相应指标。

操作系统为我们屏蔽掉了不同硬件处理的细节,但操作系统也是一个特殊的软件,仍然是运行在硬件之上的,硬件的性能决定了处理速度的上限,硬件的设计值得我们在设计软件时参考,有效利用硬件的特性也能加速软件的运行,下面让我们看看性能优化的硬件级别。

硬件级别

操作系统的底座是硬件,上层开发者很少涉及硬件层面的内容,这是因为操作系统已经为我们屏蔽了硬件的细节。但是了解现代处理器的架构对于理解程序运行,书写高质量代码甚至解决一些棘手问题来说仍然意义重大。大多数现代通用计算机的架构(个人电脑、笔记本电脑和服务器)都是基于冯·诺依曼的体系搭建的。它们的架构是由多个核心组成的中央处理器 (CPU)。每个内核都可以使用保存在随机存储器(RAM)或任何其他存储器(如寄存器或高速缓存)中的数据来执行所需要的指令。下图是具有多核 CPU 和统一内存访问(UMA)的计算机体系结构,CPU 通过总线与外部组件连接。当处理器数量增加时,由于对共享总线资源的争用,使用系统总线会出现可伸缩性问题,因此现代多核计算机通常采用了优化后的 NUMA 架构。

和之前提到的操作系统的优化思路一样,硬件级别的优化也需要明确某一个优化的方向并熟悉其内部的架构。CPU、内存、磁盘的架构都可谓别有洞天。以 CPU 为例,其内部包含了 L1、L2 和 L3 级缓存,了解这些缓存特性将有助于我们加快程序速度。由于 CPU 高速缓存的特性,访问之前获取过的数据及其相邻数据的速度会更快。CPU 高速缓存的特性影响了程序数据结构的设计。例如 Go 语言哈希表在解决哈希冲突时就考虑了 CPU 高速缓存的特性,使用了优化后的拉链法,每一个桶中存储了 8 个元素,加快了哈希表的访问速度。CPU 缓存的特性还涉及到伪共享(False Sharing)。当多线程修改看似互相独⽴的变量时,如果这些变量共享同⼀个缓存⾏,就会在⽆意中影响彼此的性能,在 Go 源码中就常常看到这样的设计。除此之外,现代 CPU 还有另一个特性:分支预测。

CPU 应用了各种算法和启发式方法来猜测程序在未来的分支,以便将执行的指令提前预取到 CPU 的缓存中,加快执行速度。分支的预测是怎么实现的呢?下面我用两个函数来说明一下。请你思考一下,下面的两个程序有区别吗?从表面看,它们都执行了 10000×1000×100 次操作,但是它们实际运行的时间却相差很大。


func fast(){
  for i:=0;i<100;i++{
    for j:=0;j<1000;j++{
      for k:=0;k<10000;k++{
      }
    }
  }
}
func slow(){
  for i:=0;i<10000;i++{
    for j:=0;j<1000;j++{
      for k:=0;k<100;k++{
      }
    }
  }
}

对程序执行简单的性能测试,从输出的结果可以看出,fast 函数比 slow 函数快了大约 40%。这是什么原因呢?

原来,CPU 会根据 PC 寄存器里的地址,从内存中把需要执行的指令读取到指令寄存器中执行,然后根据指令长度自增,开始从内存中顺序读取下一条指令。而循环或者 if else 语句会根据判断条件产生两个分支,其中一个分支成立时对应着特殊的跳转指令,它会修改 PC 寄存器中的地址。这样,下一条要执行的指令就不是从内存中顺序加载了。而另一个分支仍然会顺序读取内存中的指令。

最简单的一种分支预测策略是假定跳转不会发生。如果 CPU 执行了这种策略,那么对应到上面的循环代码就会始终循环下去。

我们仔细算一下。上面的 fast 函数中,内层 k 每循环一万次才会发生一次预测上的错误。而同样的错误在外层 i、j 循环上则每次都会发生。在这个运行周期内,j 循环发生了 1000 次预测错误,最外层的 i 循环发生了 100 次预测错误,所以一共会发生 100 × 1000 = 10 万次预测错误。而对于 slow 函数来说,内部 k 每循环 100 次,就会发生一次预测错误。而同样的错误,外层 i、j 每次循环都会发生。也就是说,第二层 j 循环发生了 1000 次,最外层 i 循环发生了 10000 次,所以一共会发生 1000×10000 = 1000 万次预测错误。这是导致 slow 函数性能更差的原因。

硬件级别的优化的最后一步,涉及到用工具检测相关的指标并验证相关的结论,这些工具包括了 mpstat、vmstat、perf、turboboost 等诸多工具,具体你仍然可以参考《Systems Performance, 2nd Edition》这本书。