掘金 后端 ( ) • 2024-04-29 16:56

引言

对《Efficient Go》一书第四章How Go Uses the CPU Resource读后的摘要和补充。

本文从CPU和汇编开始,理解CPU是如何执行指令的;然后探究Go编译器编译的全过程,了解当执行go build时发生了什么;最后从内存墙问题入手,探讨现代CPU设计,并发和调度器。

现代计算机体系结构中的CPU

image.png

在现代的计算机体系结构中,计算机的硬件都执行相同的基本功能:输入数据、输出数据、处理数据和存储数据。在冯诺依曼架构中,CPU是处理数据的核心。一个CPU由多个核心组成。每个核心都可以执行所需的指令,与此同时某些数据被保存在随机访问存储器(RAM)或其他内存层次如寄存器或L-缓存中。CPU与RAM、磁盘和网络接口等I/O设备构成了计算机最基础的部分,这些是在“效率要求应该正式化”中提到的“资源”,也是在软件开发中通常优化的对象。

在具体的操作CPU等硬件设备时,需要发送电信号,通过高电平或低电平来操纵元件开或关。将高电平和低电平抽象成符号后是数字0和1,我们通常将计算机语言视为基数为2的数字,即二进制数。我们将每个数字称为一个二进制位或比特。

但是直接编写二进制指令对人类来说过于繁琐且没有可读性,使用add A,B类似的符号指令更加友好。于是便有了汇编器和来将这种符号指令“翻译”成计算机可读的二进制指令,如 add A,B 会被转换为 1000110010100000 这条指令告诉计算机将两个数字A和B相加。这种符号语言的名字即为汇编语言。相比之下,机器理解的二进制语言被称为机器语言。

汇编

汇编语言可以视为机器语言的一种“包装”,使得编写和阅读指令更加简单直观。可以使用汇编语言来控制硬件操作,同时避免了直接编写复杂的二进制代码的困难。

汇编语言的每条指令都由一个数字(操作码)表示,后面可能跟着一个常数值或主存储器地址形式的可选操作数,还可以引使一些 CPU 的寄存器,这些寄存器是 CPU 芯片上的小型插槽,可用于存储中间结果。例如,在 AMD64 CPU 上,有十六个 64 位通用寄存器,称为 RAX、RBX、RDX、RBP、RSI、RDI、RSP 和 R8-R15。

虽然汇编语言提供了高于机器语言的抽象,但是对于构建现代的大型程序来说还是过于底层了。大多数情况下并不需要我们自身去阅读和编写汇编语言或机器语言,除非是在排查效率问题:检查编译器汇编器是否基于某些效率模式改变我们的代码从而没有满足我们的期望;或者在没有源代码的情况下需要对程序进行逆向工程。有很多方法可以将机器语言反汇编成汇编语言:

  • 使用标准 Linux 工具 objdump -d -M intel <binary> 转换为 Intel 语法。
  • 使用类似的命令 objdump -d -M att <binary> 转换为 AT&T 语法。
  • 使用 Go 工具 go tool objdump -s <binary> 转换为 Go “伪”汇编语言。

其中,Go汇编(Go Assembly)是Go语言中的一种低级语言表示形式,用于更精确地控制程序的行为。因为它不直接对应任何特定的硬件指令集,而是一种更接近于Go语言本身的中间表示(IR),在编译时最终会被转换成目标机器的实际指令集,所以也被称之为伪汇编。

Go汇编使用的寄存器命名与传统汇编中的不同。例如,在Go汇编中,寄存器可能会被标记为SP(栈指针)、BP(基指针)等,这些都是在Go运行时环境中定义的抽象寄存器,而不是实际的硬件寄存器。

Go汇编设计时考虑到了Go运行时的特性,如垃圾回收、协程调度等。这使得在Go汇编中可以直接操作Go的数据结构和调用运行时功能。

理解Go构建

编译过程

Go的编译过程与C语言的编译过程相似,使用编译器来编译,然后使用链接器将不同的对象文件链接在一起,包括潜在的共享库。这些编译和链接过程,通常称为构建,会产生操作系统可以执行的可执行文件(二进制文件)。在最初的启动过程中,称为加载,其他共享库也可以动态加载(Go plug-ins)。

针对不同目标环境,有不同的Go代码构建方法。例如,Tiny Go专门优化以生成用于微控制器的二进制文件,gopherjs 生成用于浏览器内执行的 JavaScript 和默认的且最受欢迎的Go编译器 gc ,也是go build命令中实际使用的。

编译器本身是用Go编写的(最初是用C编写的,在Go完成了自举)。go build可以将我们的代码构建成多种不同的输出,可以构建需要在启动时动态链接系统库的可执行文件,共享库甚至是与C兼容的共享库。然而,使用Go的最常见和推荐方式是构建将所有依赖项静态链接在内的可执行文件。它提供了更好的体验,我们的二进制文件调用不需要特定目录中特定版本的系统依赖。这是以main函数开始的代码的默认构建模式,也可以使用go build -buildmode=exe明确调用。

go build命令调用编译和链接两个阶段。虽然链接阶段也执行某些优化和检查,但编译器可能执行最复杂的任务。Go编译器一次专注于一个包。它将包源代码编译成目标架构和操作系统支持的本机代码。除此之外,它还验证和优化这些代码,并为调试目的准备重要的元数据。

image.png

编译的整个过程围绕Go程序中使用的包展开。每个包都是分别编译的,这允许并行编译和关注点分离。图4中展示的编译流程如下:

  1. Go源代码首先被词法分析和解析。会进行语法检查。语法树引用文件和文件位置,以产生有意义的错误和调试信息。
  2. 构建一个抽象语法树(AST)。这种树状结构是一个常见的抽象,它允许开发者创建算法,以便轻松地转换或检查解析后的语句。在AST形式中,代码最初进行类型检查。会检测到声明了但未使用的项目。
  3. 执行第一轮优化。例如,初始的无用代码被消除,因此二进制文件的大小可以更小,需要编译的代码也较少。然后,执行逃逸分析(在“Go内存管理”中提到),以决定哪些变量可以放在栈上,哪些必须在堆上分配。在此阶段的顶部,还会对简单的函数进行函数内联。
  4. 在AST上进行早期优化后,树将被转换为静态单赋值(SSA)形式。这种低级、更明确的表示形式使得使用一组规则进行进一步的优化变得更加容易。例如,借助SSA,编译器可以轻松找到不必要的变量赋值的地方。
  5. 编译器应用进一步的机器无关优化规则。例如,像 y := 0*x 这样的语句将被简化为 y := 0 。此外,一些代码片段可以被替换为内联函数——高度优化的等效代码(例如,用原始汇编)。
  6. 根据GOARCH和GOOS环境变量,编译器调用genssa函数,该函数将SSA转换为目标架构(ISA)和操作系统的机器代码。
  7. 应用进一步的ISA和操作系统特定的优化。
  8. 编译优化过程中没有被消除的包机器代码被构建成单个对象文件(带有.o后缀)并包含调试信息。

链接过程

最终的对象文件被压缩成一个称为Go archive 的tar文件,通常带有.a文件后缀。每个包的此类存档文件可以被Go链接器使用,将所有内容组合成一个单一的可执行文件。根据操作系统的不同,这样的文件遵循特定的格式,告诉系统如何执行和使用它。对于Linux,它将是一个可执行链接格式(ELF)。

二进制文件中不仅包含机器代码,还携带程序的静态数据(全局变量和常量)以及大量的调试信息,这可能占用相当多的二进制大小,如简单的符号表、基本类型信息(用于反射)和PC到行映射(指令的地址到源代码中命令所在的行)。这些额外的信息使得调试工具能够将机器代码与源代码联系起来。

Go 构建过程中有许多不同的配置选项。首批大量的选项可以通过 go build -ldflags="<flags>" 传递,这代表链接器命令选项(ld 前缀传统上代表 Linux 链接器)。

我们可以省略 DWARF 表,从而减小二进制文件的大小,使用 -ldflags="-w"。 也可以通过 -ldflags="-s -w" 进一步减小文件大小,这会移除 DWARF 和符号表以及其他调试信息,从而不能收集性能分析数据。

类似地,go build -gcflags="<flags>" 代表 Go 编译器选项(小写的 gc 代表 Go 编译器)。例如:

  • -gcflags="-S" 打印源代码的 Go 汇编。
  • -gcflags="-N" 禁用所有编译器优化。
  • -gcflags="-m=<number>" 在打印主要优化决策的同时构建代码,其中数字代表细节级别。

编译过程在减轻程序员从事繁琐工作方面起着至关重要的作用。没有编译器优化,我们需要编写更多的代码才能达到相同的效率水平,同时牺牲了可读性和可移植性。但是想要最大化利用CPU的计算资源,还需要处理一些问题。

CPU与内存墙问题

image.png

竖轴使用对数刻度来记录CPU-DRAM 性能差距的大小。基线是 1980 年的 64 KiB DRAM,延迟性能每年提高 1.07。处理器线假设每年提高 1.25,直到 1986 年,然后到 2000 年每年提高 1.52,2000 年到 2005 年间每年提高 1.20,2005 年到 2015 年间处理器性能(按每核计)仅有小幅提高。尽管CPU单核的性能不再增长,但DRAM与CPU的延迟表现依然差距明显,在多核持续发展的今天,差距只会被进一步拉大。

这个差距即为为“内存墙”问题。由于这个问题,我们每次取指令和数据(然后保存结果)需要的时间很长,可能会浪费几十个甚至上百个 CPU 周期。对此,现代CPU使用了一些硬件结构来缓解。

硬件视角:多级缓存、流水线和超线程

多级缓存

多级缓存是一个使用了局部性原理和平衡内存技术的成本性能的设计。局部性原理指出,大多数程序并不均匀地访问所有代码或数据,局部性在时间上(时间局部性)和空间上(空间局部性)都有发生。这个原理加上这样一个指导方针:对于给定的实现技术和功率预算,更小的硬件可以做得更快,导致了基于不同速度和大小的存储器的层次结构。

image.png

流水线

流水线是一种实现多条指令重叠执行的技术,与生产流水线类似。CPU的流水线将指令的执行分解为几个阶段,每个阶段由不同的硬件单元处理,使得多个指令可以重叠执行,从而提高了执行效率。

image.png

超线程

超线程是英特尔用来称呼同时多线程(SMT)的专有名称。其他 CPU 制造商也实现了 SMT 技术。这种方法允许单个 CPU 核心在对程序和操作系统可见的模式下作为两个逻辑 CPU 核心操作。SMT 促使操作系统将两个线程调度到同一个物理 CPU 核心上。虽然单个物理核心一次永远不会执行多于一个指令,但队列中的更多指令有助于在空闲时间保持 CPU 核心的忙碌。考虑到内存访问等待时间,这可以在不影响过程执行的延迟的情况下更有效地利用单个 CPU 核心。此外,SMT 中的额外寄存器使 CPU 能够更快地在单个物理核心上运行的多个线程之间进行上下文切换。

SMT 必须得到操作系统的支持和集成。使用 Linux 命令 lscpu可以了解 CPU 是否支持超线程。

Architecture:               x86_64  
  CPU op-mode(s):           32-bit, 64-bit  
  Address sizes:             39 bits physical, 48 bits virtual  
  Byte Order:               Little Endian  
CPU:                         24  
  On-line CPU(s) list:      0-23  
Vendor ID:                  GenuineIntel  
  Model name:               13th Gen Intel(R) Core(TM) i7-13700KF  
    CPU family:             6  
    model:                  183  
    Thread(s) per core:     2  
    Core(s) per socket:     16  
    Socket(s):              1  
    NUMA node(s)::          1  
    CPU max MHz:            5400.0000  
    CPU min MHz:            800.0000  
    BogoMIPS:               6835.20

软件视角:操作系统和Go Runtime调度器

调度通常意味着为完成某个过程分配必要的、通常是有限的资源。在硬件的流水线等为单一程序提供了内存墙问题的解决方案,但是在现实中,成百上千的程序运行在同一台计算机中,共享一个16核32线程的CPU。如何在有限数量的物理CPU上调度任意程序?同时运行的多个程序如何影响我们的CPU资源?自己的Go程序执行延迟受什么影响?这是软件视角的调度器需要解决的问题

操作系统调度器

Linux的CPU资源的调度器的最小调度单位称为操作系统线程。线程(有时也称为任务或轻量级进程)包含一组独立的机器代码,这些代码以CPU指令的形式设计为顺序运行。虽然线程可以维护它们的执行状态、栈和寄存器集,但它们不能脱离上下文运行。

每个线程作为进程的一部分运行。进程代表正在执行的程序,可以通过其进程标识号(PID)识别。当我们告诉Linux操作系统执行我们编译的程序时,会创建一个新的进程(例如,使用fork系统调用时)。

现在Linux的主要线程调度器算法叫做完全公平调度器(Completely Fair Scheduler,简称CFS),CFS的设计基于公平性原则,旨在为每个运行线程提供尽可能公平的CPU时间分配。CFS的特点和功能如下:

  1. 基于时间的公平性: CFS的核心目标是确保所有可运行的线程获得公平的CPU时间。它通过一个称为“虚拟运行时间”(vruntime)的概念来实现这一点。vruntime表示一个线程相对于其他线程已经获得的CPU时间。CFS尽量确保所有线程的vruntime相近,从而达到公平调度。
  2. 红黑树数据结构: CFS使用红黑树来管理所有可运行的线程,每个线程都作为一个节点插入这棵树。红黑树是一种自平衡二叉搜索树,可以高效地插入、删除和查找节点。在CFS中,这种结构用于快速找到vruntime最小的线程,即下一个应该被调度的线程。
  3. 时间片分配: 虽然CFS的设计避免使用固定的时间片来调度线程,但它确实为每个线程分配了一个“时间片”。这个时间片基于系统负载和线程数量动态调整,目的是模拟线程的并发执行,而实际上在任何给定的时间点,单个处理器核心只能执行一个线程。
  4. 负载平衡: 在多核系统中,CFS还负责负载平衡,确保CPU负载均匀分布在所有核心上。当一个CPU核心上的线程过多时,CFS可以将一些线程迁移到较少负载的核心上。
  5. 组调度支持(Cgroup) : CFS支持与控制组(cgroups)集成,这允许基于资源使用组(如内存、CPU等)来进行任务的分组调度。这对于服务器和容器技术特别有用,可以按组分配系统资源。

但是在现实中,多核心处理器调度有被低估的复杂性。既不能简单地分配线程,线程退出时会处理器会空闲;也不能基于空闲处理器分配,跨处理器会导致cache和TLB失效。更需要处理异构处理器 + Non-Uniform Memory Access和优先级翻转等问题。

Go Runtime调度器

Go语言的并发框架建立在这样一个前提之上:由于典型工作流程的I/O密集型特性,单一的CPU指令流(例如,函数)很难利用所有CPU周期。尽管操作系统线程抽象通过将线程复用到一组CPU核心上来缓解这一问题,但Go语言引入了另一层——goroutine,它在一组线程之上复用函数。

本质上,goroutine使得应用层面的IO密集型在操作系统层面成为了CPU密集型。由于所有的上下文切换都在用户态完成,在Go中,同样的上下文切换只需要2400条指令而不像线程需要12000条指令。同时,调度器还有助于提高缓存命中率和NUMA的性能,从而不需要超过虚拟核心数的线程,减少了操作系统层面高昂的上下文切换成本。

Go具体的并发逻辑是由Go运行时中的Go调度器实现的,但由于解释现有G-P-M模型过于复杂和底层,在原书中并未详解Go调度器的演化和具体实现,我将额外发布一篇《Efficient Go | Go如何使用CPU资源(微观视角:Go Runtime调度器)》来记录具体实现。

引用

Bartlomiej Plotka. 2022. Efficient Go
John Hennessy, David Patterson. 2019. Computer Architecture A Quantitative Approach (6th)
David Patterson, John Hennessy. 2014. Computer Organization and Design: The Hardware/ Software Interface (5th)
蒋炎岩. "2022操作系统,处理器调度" .https://jyywiki.cn/OS/2022/index.html