兔子先生

探寻计算机的历史与哲学密码

文中的示例基于 X86_64 体系架构,基于 Linux 内核 5.9.16 版本,汇编语言采用 AT&T 汇编

在上一篇文章中,我简要介绍了 CPU 执行指令的过程,以及 CPU 如何将一段内存用于 stack。这篇文章会将讨论的重点集中于进程的内存布局上面,因为要搞明白 stack 的运作原理,必然要知晓 stack 在内存中分配的位置。而谈到位置,必然绕不开虚拟内存。

图 2-1 描述了一个 Linux 进程的虚拟内存布局,类似的布局图已经充斥于互联网的每个角落,虽形态各异,但描述的内容大抵相同。这张图我引用自 深入理解计算机系统.第三版,是一个比较详尽且权威的图(以后的文章中会经常出现此书,这是一本每个程序员必读的书):

图 2-1 Linux 进程的虚拟内存布局

这张图是进程的内存视角,从图中可知,进程虚拟内存的地址空间被分成两大部分:用户空间Process virtual memory,也被称为user space)和 内核空间Kernel virtual memory,也被称为kernel space)。每个进程拥有相同的用户空间视图(但彼此隔离),共享部分内核空间(如内核代码,图中的Identical for each process部分)。内核空间的另一部分是每个进程私有的, 如进程的内核堆栈、页表等。每个进程在内核空间都会有一块这样的私有区域(图中的Different for each process部分)。

这样的内存空间布局会在你的脑海里形成怎样的影像呢?

想象一下香蕉皮的样子🍌~,我心中虚拟内存地址空间的立体模型就是下面这个样子:

图 2-2 Linux 进程地址空间形象图

如果你尚未被进程地址空间布局折磨过,那图 2-1 或许会让你眼前一亮。你或许以为自己终于发现了事情的本质,但事实远没有那么简单。在陷入泥潭不能自拔之前,我们不妨先扪心自问一下:程序或者说进程为什么需要这样的虚拟内存抽象呢?

不妨让时光倒流,回到上个世纪70年代去,回到大型机落幕、小型机兴起的时代去,回到个人计算机开始萌芽的时代去,回到计算机先辈们为之奋斗的精彩纷呈的那个时代去......

1. 为什么需要虚拟内存

计算机的洪荒时代是没有内存抽象的,比如早期的大型机、小型机以及最早的个人计算机,它们都没有如今我们习以为常的虚拟内存抽象。没有抽象的直观表现就是:程序直接访问物理内存,比如我在上一篇文章中以 8086 演示汇编指令的示例,示例中对内存的访问就是直接操纵物理内存。

简单批处理时代,直接访问物理内存并没有任何不妥。人们把二进制程序制成穿孔卡,有专门的人员将穿孔卡输入计算机运行。彼时计算机一次只能处理一个任务,每个任务运行时内存被其独占,各个程序也就相安无事。

但由于当时的计算机非常昂贵,人们很自然地想要减少这种浪费。在计算机的体系结构里,IO 设备和 CPU 是两种独立运行的部件,一个程序在进行 IO 的时候,CPU 往往无事可干。这当然是一种不能容忍的浪费,更何况任务的接续是由人来进行干预的。为了解决作业切换需要人员干预而造成的 CPU 浪费问题,人们改良了作业的运行机制,使得计算机在运行完一件作业之后可以自动的载入下一个作业,这就是真正的批处理时代

然而,批处理并没有解决作业在进入 IO 等待时 CPU 的闲置问题,如果一个程序在等待 IO 完成,那么何不将下一个程序调入内存来执行呢?于是计算机便进入了多道程序(multiprogramming)时代。图 2-3 展示了三个作业同驻内存的情况:

图 2-3 一个内存中有3个作业的多道程序系统

这种解决方案将内存分为几个部分,每个部分存放不同的作业,当一个作业在等待 I/O 完成时,另一个作业就可以使用 CPU。假如内存足够大,就可以容纳所需的全部任务,而 CPU 也就可以达到 100% 的利用率。

但在那个内存以 KB 计算,动辄几百美元的时代,这种假设也只能是天方夜谭。于是聪明的先驱们发明了交换技术,交换技术是解决内存超载的方法,即把一个进程完整的调入内存,使该进程运行一段时间,然后把它存回磁盘。空闲的进程主要存储在磁盘上,因此一个进程不运行就不会占用内存,图 2-4 展示了随时间流逝内存分配的情况:

图 2-4 内存分配情况随着进程进出而变化

开始时内存中只有进程 A,之后创建进程 B 和 C。当 D 需要运行的时候,由于内存已经不足以容纳 D,A 即被交换到磁盘,D 装入内存。之后 B 运行完毕被调出,A 又被调入,但 A 的位置较之前发生了改变,所以在其被调入内存时需要特殊的手段对其进行重定位,比如我们之前介绍的段寄存器就适用于这种场景,代码段和堆栈段的寄存器都需要修改。

多道程序增加了计算机的利用率,提高了作业处理效率。很快,人们对计算机的要求变得更多,比如程序员们厌倦了较长周期的开发-调试循环,交互性变得更加迫切,因为许多用户可能在同时使用机器,每个人都在等待他们执行的任务及时响应。计算机便进入了分时共享时代。

分时共享系统其实是多道程序系统的变体,在分时系统中假设有 20 个用户登录,其中有17个在思考或者在喝咖啡,那么 CPU 就可以分配给其它 3 个需要的作业来轮流执行。由于调试程序的用户常常只发出简短的命令,很少有长的费时的指令,所以计算机能够为许多用户提供快速的交互式的服务。对每个用户来讲,计算机就像是他们独占一样。而此时,计算机还有余力在后台运行批量作业。

第一个通用的分时系统是 **CCTS (Compatible Time Sharing System)**,它于 1962 年由 MIT(麻省理工学院)在一台改装过的 7094 大型机上开发出来。但直到第三代计算机广泛采用了必须的保护硬件之后,分时系统才逐渐流行开来。

系统中的所有进程共享 CPU 和主存,这本身就是对操作系统内核的巨大挑战。如果其中的某些进程需要太多的内存,那么有可能就无法运行;如果某个进程不小心写了另一个进程的内存,程序便会出现某些迷惑的 BUG;甚至操作系统内核也暴露在这种风险之下,也许在某个时候整个机器就会莫名其妙的停止运行。所以现代的操作系统都提供了一种对主存的抽象,叫做虚拟内存

2. 虚拟内存

虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。

这是 深入理解计算机系统.第三版 中对于虚拟内存的定义,简明扼要!

虚拟内存提供了三个重要的能力:

  1. 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中仅保存活动的区域,并根据需要在磁盘和主存之间来回传送数据。
  2. 它为每个进程提供了一致的地址空间,从而简化了内存管理。
  3. 它保护了每个进程的地址空间不被其他程序破坏。

顾名思义,虚拟内存其地址是虚拟的,不是真实的物理地址。你在程序中打印出的地址以及在 Linux /proc/{pid}/maps中看的地址都是虚拟地址。图 2-5 给出了虚拟寻址的过程:

图 2-5 虚拟寻址

在虚拟寻址系统中,传送给 CPU 的是虚拟地址,CPU 在访问内存之前,需要将这个虚拟地址转换为物理地址。事实上,这一转换动作是依靠另一种单独的硬件来完成的,将虚拟地址转换为物理地址的过程被称为地址翻译(address translation)。而负责地址翻译的专用硬件叫做内存管理单元,即图中的 MMU(Memory Management Unit)

当 MMU 完成地址翻译之后,真实的物理地址便被送入地址总线到达主存,随后相关的数据就会经数据总线读入 CPU 寄存器或由寄存器写入内存当中。当然,MMU 并不能凭空将虚拟地址翻译为物理地址,它需要一个叫做页表的内存结构的辅助。

虚拟内存系统将虚拟地址空间中固定大小的块分隔为虚拟页,将物理内存分割为物理页。虚拟内存所作的主要工作就是处理虚拟页和内存页之间的映射,虚拟页有3种状态:

  1. 未分配:程序尚未用到(比如尚未执行到的代码片段所在页),VM 系统尚未创建此页。那么这种未分配的页没有任何数据相关联,更不会在物理内存中
  2. 缓存的:程序已经使用的页,并且 VM 已分配且已经驻留在物理内存中
  3. 未缓存的:程序用过,VM 已经分配,但此刻不在物理内存中(已被交换到文件或者交换到磁盘上的交换空间中)

图 2-6 一个 VM 系统是如何使用主存作为缓存的

图 2-6 展示了一个有 8 个虚拟页的小虚拟内存。虚拟页 0 和 3 还没有被分配,1、4、6 已经被缓存在物理内存中,2、5、7 已经被分配了,但是当前并不在主存中,已经被交换到文件或者 swap 空间中去了。

虚拟系统必须用某种方式记录下这层映射关系以及缓存与否,否则地址翻译便不能工作。用于记录映射关系与缓存有效情况的内存结构叫做页表(page table),它位于进程内核空间的进程私有区域,可参见图 2-1 进程空间布局视图。

本文并不想介绍页表的工作原理,这超出了文章的讨论范围。此处读者只需要明白页表的作用即可:为地址翻译提供帮助。页表相关内容可以参考任意一本介绍操作系统的书籍。

上述组织内存的方式被称为分页,事实上还有一种方式叫做分段,先将内存分段,再将这段内存分页,可以想见其复杂性。有必要指出的是第一个实现了分段加分页的操作系统是大名鼎鼎的 MULTICS

MULTICS 是有史以来最具影响力的操作系统之一,每一个 Unix 爱好者都曾闻其大名。它始于麻省理工学院的一个研究项目,其设计者着眼于建造一台满足整个波士顿地区所有用户计算需求的机器。除了 MIT 还有贝尔实验室以及通用电气公司参与。然而项目的难度远远超出了人们的预料,贝尔实验室退出了,通用电气公司也退出了,最后只有 MIT 坚持了下来。系统最后于 1969 年上线,在运行了 31 年后于 2000 年关闭。

几乎没有系统能像 MULTICS 一样没有修改地持续运行 31 年之久,尽管 MULTICS 在商业上失败了,但其许多原创的概念却散布于各种计算机文献。它的设计思想也经由 Ken Thompson 传承给了 Unix,一度催动了 C 语言的蓬勃发展,而 C 和 Unix 又反过来深深影响了后来的 Linux。

MULTICS 的这一波影响下,Intel 也未能幸免。 MULTICS 分段的思想在 X86 处理器上得到了继承,我们上篇提到的段寄存器便是受此影响的产物。换言之,8086 段设计的一部分思想来源于 MULTICS,并不完全是因为寄存器大小限制而催生的奇思妙想。虽然 x86-64 架构下仍有分段机制的某些痕迹,但正如上篇所言,这大多数情况下只是为了兼容。

那么,Intel 为什么要剔除它支持了近 30 年,且源自表现良好的 MULTICS 存储模型的变形体呢?

或许是因为 Unix 和 Windows 都不曾真正的使用过该模型!

3. 线程堆栈

3.1 stack 在哪儿 ?

经过上面的铺垫,终于可以聊一聊进程地址空间中的 stack 了。那么,stack 在进程地址空间中的什么位置呢?从图 2-1 来看,答案似乎显而易见:stack 开始于用户空间的顶端,从高地址向低地址增长。而作为动态增长的内存区域,heap 排在代码段、数据段和 bss 段之上,由低地址向高地址增长,与 stack 遥相呼应,相对增长。为了方便对照,此处再贴一次进程的地址空间布局,如图 2-7 :

图 2-7 Linux 进程的虚拟内存布局

x86_64 架构下 Linux 平台二进制可执行文件的代码段总是从地址的 0x40000 处开始,这是在链接阶段就决定的。但是如果你在稍微新一点的 Linux 上去做测试的话,代码段的起始地址很大程度上并不是从 0x40000 处开始,它似乎是一个随机的值。比如,我用下面一个简单的 C 程序来观察其地址空间的分布:

1
2
3
4
5
6
#include <stdio.h>

int main(int argc, char **argv) {
printf("Hello World\n");
getchar();
}

使用gcc demo.c来编译为a.out,并运行。之后获取其进程号,查看/proc/{pid}/maps中的内容来观察地址空间的映射情况,如 表 2-1 :

~/play/c ➭ cat /proc/2231/maps 
56487f376000-56487f377000 r--p 00000000 103:02 7093179                   /home/richard/play/c/a.out
56487f377000-56487f378000 r-xp 00001000 103:02 7093179                   /home/richard/play/c/a.out
56487f378000-56487f379000 r--p 00002000 103:02 7093179                   /home/richard/play/c/a.out
56487f379000-56487f37a000 r--p 00002000 103:02 7093179                   /home/richard/play/c/a.out
56487f37a000-56487f37b000 rw-p 00003000 103:02 7093179                   /home/richard/play/c/a.out
56487fbc6000-56487fbe7000 rw-p 00000000 00:00 0                          [heap]
7f593eb72000-7f593eb74000 rw-p 00000000 00:00 0 
7f593eb74000-7f593eb9a000 r--p 00000000 103:02 4859211                   /usr/lib/libc-2.33.so
7f593eb9a000-7f593ece5000 r-xp 00026000 103:02 4859211                   /usr/lib/libc-2.33.so
7f593ece5000-7f593ed31000 r--p 00171000 103:02 4859211                   /usr/lib/libc-2.33.so
7f593ed31000-7f593ed34000 r--p 001bc000 103:02 4859211                   /usr/lib/libc-2.33.so
7f593ed34000-7f593ed37000 rw-p 001bf000 103:02 4859211                   /usr/lib/libc-2.33.so
7f593ed37000-7f593ed42000 rw-p 00000000 00:00 0 
7f593ed6f000-7f593ed70000 r--p 00000000 103:02 4859198                   /usr/lib/ld-2.33.so
7f593ed70000-7f593ed94000 r-xp 00001000 103:02 4859198                   /usr/lib/ld-2.33.so
7f593ed94000-7f593ed9d000 r--p 00025000 103:02 4859198                   /usr/lib/ld-2.33.so
7f593ed9d000-7f593ed9f000 r--p 0002d000 103:02 4859198                   /usr/lib/ld-2.33.so
7f593ed9f000-7f593eda1000 rw-p 0002f000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffcf3aea000-7ffcf3b0b000 rw-p 00000000 00:00 0                          [stack]
7ffcf3ba2000-7ffcf3ba6000 r--p 00000000 00:00 0                          [vvar]
7ffcf3ba6000-7ffcf3ba8000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

表2-1 进程的内存布局映射

可见,其代码段的起始地址为56487f376000,stack 段的其实地址为7ffcf3b0b000,如果多观察几次,每次的地址都不相同。

这其实归根于 Linux 在 2.6.12 之后加入的地址空间布局随机化(Address space layout randomization,缩写ASLR),ASLR 的目的是为了安全,要禁用它,只需执行如下命令:

1
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

并且在使用gcc编译时加入-no-pie选项,然后再观察其内存布局,这一次我们直接观察proc中的maps:

~/play/c ➭ cat /proc/5206/maps
00400000-00401000 r--p 00000000 103:02 7095689                           /home/richard/play/c/a.out
00401000-00402000 r-xp 00001000 103:02 7095689                           /home/richard/play/c/a.out
00402000-00403000 r--p 00002000 103:02 7095689                           /home/richard/play/c/a.out
00403000-00404000 r--p 00002000 103:02 7095689                           /home/richard/play/c/a.out
00404000-00405000 rw-p 00003000 103:02 7095689                           /home/richard/play/c/a.out
00405000-00426000 rw-p 00000000 00:00 0                                  [heap]
7ffff7dca000-7ffff7dcc000 rw-p 00000000 00:00 0 
7ffff7dcc000-7ffff7df2000 r--p 00000000 103:02 4859211                   /usr/lib/libc-2.33.so
7ffff7df2000-7ffff7f3d000 r-xp 00026000 103:02 4859211                   /usr/lib/libc-2.33.so
7ffff7f3d000-7ffff7f89000 r--p 00171000 103:02 4859211                   /usr/lib/libc-2.33.so
7ffff7f89000-7ffff7f8c000 r--p 001bc000 103:02 4859211                   /usr/lib/libc-2.33.so
7ffff7f8c000-7ffff7f8f000 rw-p 001bf000 103:02 4859211                   /usr/lib/libc-2.33.so
7ffff7f8f000-7ffff7f9a000 rw-p 00000000 00:00 0 
7ffff7fc7000-7ffff7fcb000 r--p 00000000 00:00 0                          [vvar]
7ffff7fcb000-7ffff7fcd000 r-xp 00000000 00:00 0                          [vdso]
7ffff7fcd000-7ffff7fce000 r--p 00000000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffff7fce000-7ffff7ff2000 r-xp 00001000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffff7ff2000-7ffff7ffb000 r--p 00025000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffff7ffb000-7ffff7ffd000 r--p 0002d000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffff7ffd000-7ffff7fff000 rw-p 0002f000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

表2-2 无地址随机的进程内存布局映射

可见,代码段的起始位置已经是固定的00400000,stack 段的起始地址为固定的7ffffffff000了。

值得注意的是:在不开启地址随机的情况下,stack 的起始地址位于用户空间最高地址 - 4k 处,即0x7ffffffff000。用户空间的最高地址是00007fffffffffff,即用户空间有 128TB 大小,Linux 文档 mm 描述了x86_64 架构下的内存空间布局,如表 2-3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
====================================================
Complete virtual memory map with 4-level page tables
====================================================

Notes:

- Negative addresses such as "-23 TB" are absolute addresses in bytes, counted down
from the top of the 64-bit address space. It's easier to understand the layout
when seen both in absolute addresses and in distance-from-top notation.

For example 0xffffe90000000000 == -23 TB, it's 23 TB lower than the top of the
64-bit address space (ffffffffffffffff).

Note that as we get closer to the top of the address space, the notation changes
from TB to GB and then MB/KB.

- "16M TB" might look weird at first sight, but it's an easier to visualize size
notation than "16 EB", which few will recognize at first sight as 16 exabytes.
It also shows it nicely how incredibly large 64-bit address space is.

========================================================================================================================
Start addr | Offset | End addr | Size | VM area description
========================================================================================================================
| | | |
0000000000000000 | 0 | 00007fffffffffff | 128 TB | user-space virtual memory, different per mm
__________________|____________|__________________|_________|___________________________________________________________
| | | |
0000800000000000 | +128 TB | ffff7fffffffffff | ~16M TB | ... huge, almost 64 bits wide hole of non-canonical
| | | | virtual memory addresses up to the -128 TB
| | | | starting offset of kernel mappings.
__________________|____________|__________________|_________|___________________________________________________________
|
| Kernel-space virtual memory, shared between all processes:
____________________________________________________________|___________________________________________________________
| | | |
ffff800000000000 | -128 TB | ffff87ffffffffff | 8 TB | ... guard hole, also reserved for hypervisor
ffff880000000000 | -120 TB | ffff887fffffffff | 0.5 TB | LDT remap for PTI
ffff888000000000 | -119.5 TB | ffffc87fffffffff | 64 TB | direct mapping of all physical memory (page_offset_base)
ffffc88000000000 | -55.5 TB | ffffc8ffffffffff | 0.5 TB | ... unused hole
ffffc90000000000 | -55 TB | ffffe8ffffffffff | 32 TB | vmalloc/ioremap space (vmalloc_base)
ffffe90000000000 | -23 TB | ffffe9ffffffffff | 1 TB | ... unused hole
ffffea0000000000 | -22 TB | ffffeaffffffffff | 1 TB | virtual memory map (vmemmap_base)
ffffeb0000000000 | -21 TB | ffffebffffffffff | 1 TB | ... unused hole
ffffec0000000000 | -20 TB | fffffbffffffffff | 16 TB | KASAN shadow memory
__________________|____________|__________________|_________|____________________________________________________________
|
| Identical layout to the 56-bit one from here on:
____________________________________________________________|____________________________________________________________
| | | |
fffffc0000000000 | -4 TB | fffffdffffffffff | 2 TB | ... unused hole
| | | | vaddr_end for KASLR
fffffe0000000000 | -2 TB | fffffe7fffffffff | 0.5 TB | cpu_entry_area mapping
fffffe8000000000 | -1.5 TB | fffffeffffffffff | 0.5 TB | ... unused hole
ffffff0000000000 | -1 TB | ffffff7fffffffff | 0.5 TB | %esp fixup stacks
ffffff8000000000 | -512 GB | ffffffeeffffffff | 444 GB | ... unused hole
ffffffef00000000 | -68 GB | fffffffeffffffff | 64 GB | EFI region mapping space
ffffffff00000000 | -4 GB | ffffffff7fffffff | 2 GB | ... unused hole
ffffffff80000000 | -2 GB | ffffffff9fffffff | 512 MB | kernel text mapping, mapped to physical address 0
ffffffff80000000 |-2048 MB | | |
ffffffffa0000000 |-1536 MB | fffffffffeffffff | 1520 MB | module mapping space
ffffffffff000000 | -16 MB | | |
FIXADDR_START | ~-11 MB | ffffffffff5fffff | ~0.5 MB | kernel-internal fixmap range, variable size and offset
ffffffffff600000 | -10 MB | ffffffffff600fff | 4 kB | legacy vsyscall ABI
ffffffffffe00000 | -2 MB | ffffffffffffffff | 2 MB | ... unused hole
__________________|____________|__________________|_________|___________________________________________________________

表2-3 x86_64 下 Linux 48 bit 地址空间

可以简单的用图 2-8 来表示空间的跨度,其中最高位的 128 TB 是留给内核空间的,低 128 TB 则属于用户空间,stack 起始地址紧随用户空间顶部。由于 ASLR的原因,其真实的地址会随机进行偏移。

mm

那么,stack 会增长到多大呢?这个问题似乎跟体系结构和操作系统有很大的关系,各类书籍不仅没有定论,甚至含糊其词,更没有一个可以让你一览无遗的列表清单。但我们似乎可以根据 Linux 来窥视一二。

从图 2-7 中可以看到,stackheap之间还夹着一块**内存映射区域(mmap)**,且 Linux 下可以使用 ulimit -s size 来设置 stack 的大小。问题开始变得有些复杂,因为 mmap 区域的起点要根据 stack 的大小来进行计算,但 stack 的大小似乎可以任意指定,甚至可以设置为无限。

深入 Linux 内核架构 在其第 4 章:进程虚拟内存 的 246 页指出:可以根据栈的最大长度,来计算栈最低的可能位置,以用作 mmap 区域的起始点。但内核会确保 stack 至少跨越 128 MiB 的空间。另外,如果指定的栈界限非常巨大,那么内核会保证至少有一小部分地址空间不会被占据。

书中的论断于当今的 Linux 内核是否仍适用,我目前也无从证明,但这已无关紧要。进程的虚拟地址空间布局也经过多次变迁,我们如今讨论的默认空间布局想来也不会一成不变。但 Linux 内核志在为用户呈现虚拟内存抽象的思想已可见一斑。可以肯定的是,它一定在繁芜的细节上做了大量细致的工作,才有了用户空间代码使用内存时的举重若轻。

3.2 stack 能长到多大 ?

要验证 stack 能长多大,说来也很容易,下面这段 C 代码就可以探测到 stack 的边界:

1
2
3
4
5
6
7
8
9
10
11
12
#include <alloca.h>
#include <stdio.h>
int main (void)
{
long a = 0;
for (;;) {
void *y;
y = alloca(128);
a += 128;
printf ("\nStack Size = %ld", a);
}
}

代码在死循环中不断地从 stack 上申请128字节的空间并打印出当前已申请空间的大小,编译运行之,程序会在栈溢出时终止。令人意外的是,收到的错误并不是意料中的stack overflow,而是segmentation fault (core dumped)

其实,**Stack overflow is [a] cause, segmentation fault is the result.**,详见What is the difference between a segmentation fault and a stack overflow?

这段代码探测到的 stack 大小在我的 Linux 内核 5.9.16 版本上是受 ulimit 控制的。那么,如果不对其设限,是否会碰撞到 mmap 区域,甚至 heap 区域呢?

执行ulimit -s unlimited之后再来运行程序,会发现程序使用的内存一路飙升,用完了 16 GB的内存后,又填满了 16GB 的 swap 空间,最后 OOM 被内核杀死。

是的,回忆一下图 2-8 ,用户空间有 128 TB 的大小呢,我的弱机并不配去探测极限🤣!

3.3 其他线程的 stack 呢 ?

似乎有些东西被我们遗忘了,一直以来我们都是在测试主线程的 stack,那其它的线程呢?

图 2-8 表示的内存布局看起来非常完美,stackheap 相对增长,各自都有充裕的空间可以使用,何况大多数情况下 stack 并不会增长太大。因此,48 bit的寻址空间即便在中间又加入了 mmap 区域的情况下依旧可以处之泰然。

我们还没有介绍 mmap 区域,此处只需要知道 mmap 区域用于私有文件映射私有匿名映射共享文件映射共享匿名映射即可。需要指出的是,在默认的虚拟内存地址空间布局下mmap 区域是和heap相对增长的,如图 2-9 所示:

图2-9 mmap 区域自顶向下扩展

同时,从表 2-1 和 2-2 可以推出:stack 和 mmap 之间分别有 654 GiB 和 128 MiB,mmap 和 heap 之间占据了用户空间的绝大部分。如果我们不限制 stack 的大小,再来看一看布局情况。先执行ulimit -s unlimited,再来执行下面的代码:

1
2
3
4
5
6
#include <stdio.h>

int main(int argc, char **argv) {
printf("Hello World\n");
getchar();
}

内存布局见表 2-4:

~/play/c ➭ cat /proc/6612/maps
00400000-00401000 r--p 00000000 103:02 7096036                           /home/richard/play/c/getchar
00401000-00402000 r-xp 00001000 103:02 7096036                           /home/richard/play/c/getchar
00402000-00403000 r--p 00002000 103:02 7096036                           /home/richard/play/c/getchar
00403000-00404000 r--p 00002000 103:02 7096036                           /home/richard/play/c/getchar
00404000-00405000 rw-p 00003000 103:02 7096036                           /home/richard/play/c/getchar
00405000-00426000 rw-p 00000000 00:00 0                                  [heap]
155555321000-155555323000 rw-p 00000000 00:00 0 
155555323000-155555349000 r--p 00000000 103:02 4859211                   /usr/lib/libc-2.33.so
155555349000-155555494000 r-xp 00026000 103:02 4859211                   /usr/lib/libc-2.33.so
155555494000-1555554e0000 r--p 00171000 103:02 4859211                   /usr/lib/libc-2.33.so
1555554e0000-1555554e3000 r--p 001bc000 103:02 4859211                   /usr/lib/libc-2.33.so
1555554e3000-1555554e6000 rw-p 001bf000 103:02 4859211                   /usr/lib/libc-2.33.so
1555554e6000-1555554f1000 rw-p 00000000 00:00 0 
15555551e000-155555522000 r--p 00000000 00:00 0                          [vvar]
155555522000-155555524000 r-xp 00000000 00:00 0                          [vdso]
155555524000-155555525000 r--p 00000000 103:02 4859198                   /usr/lib/ld-2.33.so
155555525000-155555549000 r-xp 00001000 103:02 4859198                   /usr/lib/ld-2.33.so
155555549000-155555552000 r--p 00025000 103:02 4859198                   /usr/lib/ld-2.33.so
155555552000-155555554000 r--p 0002d000 103:02 4859198                   /usr/lib/ld-2.33.so
155555554000-155555556000 rw-p 0002f000 103:02 4859198                   /usr/lib/ld-2.33.so
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

表2-4 进程的地址映射表

可见,stackmmap 之间有 106TiB 之大,heapmmap之间仅剩 21TiB 的大小。

作为一个未曾充分使用过 C 语言的人,我看遍了所有的虚拟内存地址空间布局图之后,脑海中对于 stackheap 的位置早已烂熟于胸。这也自然而然让我产生了思维定式:我以为线程所有的 stack 都是在这些布局图所示的 stack 位置上分配的。

然而事实并非如此,这些图的产生也有其历史原因。依我推测,它们大多是成图于线程概念出现之前,而线程出现之后,其内存布局并未在图中充分的体现,让我们来看操作系统导论中的一句话:

不是地址空间中只有一个栈,而是每个线程都有一个栈 ...... 你可能注意到,多个栈也破坏了地址空间的美感。以前堆和栈可以互不影响地增长,直到空间耗尽。多个栈就没有这么简单了。幸运的是,通常栈不会很大。

细细体悟,此处破坏了空间布局美感背后的深意:即后续的线程 stack 可以在很多地方分配,包括 heapmmap 区域。

除了操作系统导论中这蜻蜓点水的一句之外,就没有其它的权威资料来说明这一点了么?

还真有,而且还被我找到了😉。

TLPI 第 29.1 节展示了同时执行 4 个线程的进程,美中不足的是这幅图是基于 32 位地址空间的,如图 2-10 所示:

图 2-10:同时执行 4 个线程的进程

主线程的 stack 位于我们熟知的内存布局的 stack 区域,而其余 3 个线程的栈位于 mmap 区域。事实上,使用 Pthread线程库来创建线程的时候,对于线程 stack 的分配就是使用的 mmap 系统调用,其位置也必然位于 mmap 区域内。

然而,Pthread 创建线程使用的是 clone 系统调用,而 clone 系统调用需要使用者自行传入一个内存区域以用作该线程的 stack。我们可以看一下文档中使用 clone 创建子进程的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Allocate memory to be used for the stack of the child. */

stack = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
if (stack == MAP_FAILED)
errExit("mmap");

stackTop = stack + STACK_SIZE; /* Assume stack grows downward */

/* Create child that has its own UTS namespace;
child commences execution in childFunc(). */

pid = clone(childFunc, stackTop, CLONE_NEWUTS | SIGCHLD, argv[1]);
if (pid == -1)
errExit("clone");
printf("clone() returned %jd\n", (intmax_t) pid);

可见子进程的 stack 并没有使用传统意义上主线程使用的 stack 区域,而是使用mmap 在内存映射区域开辟了一块内存用于 stack。既然是自行传入的,那是不是就说明可以把 heap 中的一块内存当作线程的 stack 来用呢?恰好 Pthread 也提供自行设置 stack 的接口,那么,不妨来试试吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

void* start_thread(){
long a = 0;
for (;;){
void *y;
y = alloca(1024);
a += 1024;
printf ("\nStack Size = %ld", a);
sleep(1);
}
}

int main(int argc, char **argv) {
printf("Hello World\n");
pthread_t thing1, pattr;
pthread_attr_t tattr;
void *stackbase;
void *ptr;

// 在 heap 总申请 100KiB 的内存
long size = 100 * 1024;

for (int i=0;i<5;i++) {
ptr = (void *)malloc(size);
}

stackbase = (void *) malloc(size);
printf("malloc address: %p\n",stackbase);

// const char *message = "ThingHive";

pthread_attr_init(&tattr);
pthread_attr_setstack(&tattr, stackbase, size);
// 自定义 stack 线程
pthread_create(&pattr, &tattr, start_thread, NULL);
// 默认线程
pthread_create(&thing1, NULL, start_thread, NULL);

pthread_join(pattr,NULL);
pthread_join(thing1, NULL);
// getchar();
return 0;
}

上述代码除主线程外,额外创建了 2 个线程,一个 pattr 使用自定义 stack,另一个thing1使用默认选项。需要注意的是,代码使用malloc在 heap 上开辟一块内存用于线程的 stack,但这块内存不能大于 128KiB,否则 malloc会使用mmap来申请内存,详见TLPI 49.7 节。

线程的执行体会每隔 1 秒钟在 stack 上分配 1KiB 的内存,便于我们观察 stack 的位置。编译并运行程序,这次使用pmap -X {pid}来观察区域的大小,如表 2-5 所示:

~/play/c ➭ pmap -X 9692
9692:   ./a.out
         Address Perm   Offset Device   Inode  Size  Rss Pss Referenced Anonymous  Mapping
        00400000 r--p 00000000 103:02 7093179     4    4   4          4         0  a.out
        00401000 r-xp 00001000 103:02 7093179     4    4   4          4         0  a.out
        00402000 r--p 00002000 103:02 7093179     4    4   4          4         0  a.out
        00403000 r--p 00002000 103:02 7093179     4    4   4          4         4  a.out
        00404000 rw-p 00003000 103:02 7093179     4    4   4          4         4  a.out
        00405000 rw-p 00000000  00:00       0   732   92  92         92        92  [heap]
    7ffff75a7000 ---p 00000000  00:00       0     4    0   0          0         0  
    7ffff75a8000 rw-p 00000000  00:00       0  8204   76  76         76        76  
    7ffff7dab000 r--p 00000000 103:02 4859211   152  148   1        148         0  libc-2.33.so
    7ffff7dd1000 r-xp 00026000 103:02 4859211  1324  876   6        876         0  libc-2.33.so
    7ffff7f1c000 r--p 00171000 103:02 4859211   304   64   0         64         0  libc-2.33.so
    7ffff7f68000 r--p 001bc000 103:02 4859211    12   12  12         12        12  libc-2.33.so
    7ffff7f6b000 rw-p 001bf000 103:02 4859211    12   12  12         12        12  libc-2.33.so
    7ffff7f6e000 rw-p 00000000  00:00       0    36   12  12         12        12  
    7ffff7f77000 r--p 00000000 103:02 4865362    28   28   0         28         0  libpthread-2.33.so
    7ffff7f7e000 r-xp 00007000 103:02 4865362    60   60   0         60         0  libpthread-2.33.so
    7ffff7f8d000 r--p 00016000 103:02 4865362    16   16   0         16         0  libpthread-2.33.so
    7ffff7f91000 ---p 0001a000 103:02 4865362     4    0   0          0         0  libpthread-2.33.so
    7ffff7f92000 r--p 0001a000 103:02 4865362     4    4   4          4         4  libpthread-2.33.so
    7ffff7f93000 rw-p 0001b000 103:02 4865362     4    4   4          4         4  libpthread-2.33.so
    7ffff7f94000 rw-p 00000000  00:00       0    24   12  12         12        12  
    7ffff7fc7000 r--p 00000000  00:00       0    16    0   0          0         0  [vvar]
    7ffff7fcb000 r-xp 00000000  00:00       0     8    4   0          4         0  [vdso]
    7ffff7fcd000 r--p 00000000 103:02 4859198     4    4   0          4         0  ld-2.33.so
    7ffff7fce000 r-xp 00001000 103:02 4859198   144  144   0        144         0  ld-2.33.so
    7ffff7ff2000 r--p 00025000 103:02 4859198    36   36   0         36         0  ld-2.33.so
    7ffff7ffb000 r--p 0002d000 103:02 4859198     8    8   8          8         8  ld-2.33.so
    7ffff7ffd000 rw-p 0002f000 103:02 4859198     8    8   8          8         8  ld-2.33.so
    7ffffffde000 rw-p 00000000  00:00       0   132   12  12         12        12  [stack]
ffffffffff600000 --xp 00000000  00:00       0     4    0   0          0         0  [vsyscall]
                                              ===== ==== === ========== =========  
                                              11300 1652 279       1652       260  KB 

表2-5 进程的地址映射表

程序运行之初会打印出malloc address: 0x482700,可以证明内存确实分配于 heap 上。

表 2-5 中黄色的两行代表,两个线程的 stack 区域,一个位于 heap 区域,另一个位于 mmap 区域。而红色部 rss 和 pss 可以理解为实际占用的物理内存,这部分会不断的增长,意味着我们的线程 stack 一直在扩充,直到发生segmentation fault

表 2-5 中 heap 段的 size 为 732 KiB,这是因为我在申请 stack 所用的内存之前,连续调用了 5 次 malloc 共申请了 500KiB 的内存。否则,其大小会是 132KiB,这是由 malloc 管理内存的机制决定的,可参照 Understanding glibc malloc 这篇文章。可见 pmap 读取的内容并不能识别出我们申请的每一块内存,对于无法识别的内容,在 smaps 中只会放在一个匿名的条目中,比如这里的 heap 区域。

Finally,我们终于可以画一个全面的 stack 角度的内存布局图了:

图 2-11 位于不同区域的线程 stack

4. 函数调用与栈帧

堆栈是一个后进先出的结构,它天然的适用于函数调用这种情况。这一节我们简单来看一下 stack 在函数调用方面的应用,同时也解答一下引发我写此系列文章的 stack 中变量引用的问题。

x86_64 架构的堆栈由高地址向低地址增长,寄存器%rsp始终指向栈顶元素。将堆栈的栈顶指针减小就可以在 stack 上分配空间,将指针增大就可以在 stack 上释放空间。

4.1 利用 stack 进行控制转移

每一个函数在 stack 分配的空间统称为该函数的栈帧(stack fram),图 2-12 给出了运行时 stack 的通用结构,包括把它划分为栈帧,当前正在执行的函数的帧总是在栈顶。假设 函数 P 在执行过程中调用了函数 Q,当函数 P 调用函数 Q 时,会把返回地址压入栈中,指明当 Q 返回时,要从 P 程序的哪个位置继续执行。我们把这个返回地址当做 P 的栈帧的一部分,因为它存放的是与 P 相关的状态。

图 2-12 通用的栈帧结构

Q 的代码会扩展当前堆栈的边界,为它的栈帧分配所需的空间。在此空间中, Q 可以保存寄存器的值,分配局部变量,如果它还调用其它函数,则为被调用的函数设置参数。函数 P 可以通过寄存器传递最多 6 个参数,如果 Q 的参数个数超过了 6 个,则 P 在调用之前会在自己的栈帧里存储好这些参数。

函数调用需要打破当前 CPU 顺序执行指令的状态,使其跳转至另外一部分代码块。这种控制转移自然是通过修改程序计数器(PC)来达成的,将控制从 P 转移到 Q,仅需将 PC 修改为 Q 的代码的起始位置。不过当 Q 返回的时候,CPU 必须要知道它要在 P 中继续的位置。x86_64 机器中,这个过程是通过 callret指令配合完成的。

首先,函数调用通过call Q来进行,该指令会把地址 A 压入堆栈中,并将 PC 设置为 Q 的起始地址。压入的地址 A 通常叫做返回地址,是紧随 call 指令之后的那条指令的地址。对应的指令 ret会从堆栈中弹出地址 A,并把 PC 设置为 A。

可以看到,这种把返回地址压入堆栈的简单机制能够让函数在稍后返回到程序中正确的位置。函数的调用和返回机制刚好与堆栈提供的后进先出的内存管理方式相吻合。

4.2 需要保存的寄存器

CPU 中的寄存器是被所有的函数共享的,虽然在一个执行流中,同一时刻只有一个函数是活动的(在 CPU 上执行),但我们必须保证在函数返回时,它不会覆盖或者破坏调用者稍后会使用的寄存器的值。为此,x86_64 采用了一组统一的寄存器使用惯例,所有的函数调用都必须遵循。

依照此管理,寄存器被分为两类:

  1. 需要被调用者保存的寄存器(callee-saved),寄存器 %rbx、%rbp 和 %r12~%r15 属于被调用者保存寄存器,图 2-12 中被保存的寄存器区域就是被调用者栈帧中存放寄存器值的位置。当函数 P 调用函数 Q 时,Q 必须保存这些寄存器的值,保证它们的值在 Q 返回到 P 的时候与 Q 被调用的时候是一样的。要做到这一点,Q 要么不去改变就这些寄存器的值,要么就要把原始值压入堆栈,然后在返回的时候从堆栈中弹出旧的值。我们稍后会看到%rbp寄存器就是这样的一个例子。
  2. 需要调用者保存的寄存器(caller-saved),事实上除了栈顶指针寄存器 %rsp,所有其它的寄存器都属于调用者保存寄存器。

4.3 %rbp 和 %rsp

%rsp 是栈顶指针寄存器,其值永远指向 stack 的顶部。%rbp 大家可能会陌生一点,x86_64 代码使用该寄存器作为帧指针(frame pointer),有时也称为基指针(base pointer),这也是 %rbp 中 bp 两个字母的由来。看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long swap_add(long *xp, long *yp) {
long x = *xp;
long y = *yp;
*xp = y;
*yp = x;
return x + y;
}

long caller(){
long arg1 = 534;
long arg2 = 1057;
long sum = swap_add(&arg1, &arg2);
long diff = arg1 - arg2;
return sum * diff;
}

使用 gcc -S 编译为汇编指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
caller:
.LFB1:
.cfi_startproc
pushq %rbp ;将 %rbp 入栈
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp ;将此时的栈顶指针设为 rbp
.cfi_def_cfa_register 6
subq $48, %rsp ;为此函数栈帧申请 48 字节
movq %fs:40, %rax ;获取金丝雀值(堆栈保护机制)
movq %rax, -8(%rbp) ;将金丝雀值存入堆栈
xorl %eax, %eax
movq $534, -40(%rbp) ;将 534 存入 arg1
movq $1057, -32(%rbp) ;将 1057 存入 arg2
leaq -32(%rbp), %rdx ;将 arg2 的地址存入 %rdx 寄存器
leaq -40(%rbp), %rax ;将 arg1 的地址存入 %rax 寄存器
movq %rdx, %rsi ;将 %rdx 的值存入 %rsi 寄存器
movq %rax, %rdi ;将 %rax 的值存入 %rdi 寄存器
call swap_add ;调用 swap_add 函数
movq %rax, -24(%rbp) ;将函数调用结果 sum 存入 stack 的 -24(%rbp) 处
movq -40(%rbp), %rax ;将 arg1 放入 %rax
movq -32(%rbp), %rdx ;将 arg2 放入 %rdx
subq %rdx, %rax ;计算 diff = arg1 - arg2 将结果 diff 放入 %rax
movq %rax, -16(%rbp) ;将 diff 入栈,放在 -16(%rbp) 处
movq -24(%rbp), %rax ; 将 sum 读入 %rax
imulq -16(%rbp), %rax ; 计算 sum * diff,结果放在 %rax 中准备返回
movq -8(%rbp), %rdx ; 下面检查堆栈结构是否有损坏
subq %fs:40, %rdx
je .L5
call __stack_chk_fail@PLT
.L5:
leave ;释放栈帧
.cfi_def_cfa 7, 8
ret ; 返回
.cfi_endproc

此时函数的栈帧如图 2-13 一样:

图 2-13 函数 caller 的栈帧结构

我们可以看到 caller 汇编指令的开始就是保存 %rbp 寄存器的值,因为 %rbp 属于 callee-saved 寄存器,它的值要由 caller 函数负责保存(此时的caller看作是被调用者)。在函数结尾,ret返回之前有一句leave 指令,它的作用是释放函数的栈帧,将 %rbp 寄存器恢复到 caller 被调用前的值,它等价于下面两条指令:

1
2
movq %rbp,%rsp    ;直接将栈顶指针拉回到 %rbp 处
popq %rbp ;将保存的 %rbp 出栈 放入 %rbp

从图 2-13 可以看出,movq %rbp,%rsp会将 %rbp 的值设置为栈顶,而此时 %rbp 指向栈帧的开始处,即刚保存完旧 %rbp 的位置。紧接着的操作popq %rbp会将栈顶的元素(保存的 %rbp)弹出,并设置到 %rbp 寄存器,这样 caller 函数的调用者的 %rbp 就恢复了。

我们从汇编指令上很容易看出堆栈上的寻址方式:在写入和读取堆栈上的数据时,使用的是类似于**-32(%rbp)基址+偏移量**的寻址方式。毕竟,在一个函数的栈帧生存期中,%rbp是永远固定不变的,使用%rbp来相对寻址也就顺理成章了。

但是,gcc 中的优化选项会对能在编译期确定栈帧大小的函数使用%rsp定位,让我们再对比一下gcc -Og -S生成的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
caller:
.LFB1:
.cfi_startproc
subq $40, %rsp ;为此函数栈帧申请 40 字节
.cfi_def_cfa_offset 48
movq %fs:40, %rax ;获取金丝雀值(堆栈保护机制)
movq %rax, 24(%rsp) ;将金丝雀值存入堆栈
xorl %eax, %eax
movq $534, 8(%rsp) ;将 534 存入 arg1
movq $1057, 16(%rsp) ;将 1057 存入 arg2
leaq 16(%rsp), %rsi ;将 arg2 的地址存入 %rsi 寄存器
leaq 8(%rsp), %rdi ;将 arg1 的地址存入 %rdi 寄存器
call swap_add ;调用 swap_add 函数
movq 8(%rsp), %rdx ;将 arg1 存入 %rdx 寄存器
subq 16(%rsp), %rdx ; 计算 diff = arg1 - arg2
imulq %rdx, %rax ; 计算 sum * diff
movq 24(%rsp), %rdx ; 下面检查堆栈结构是否有损坏
subq %fs:40, %rdx
jne .L5
addq $40, %rsp ; 释放栈帧
.cfi_remember_state
.cfi_def_cfa_offset 8
ret ;返回

可见此时的汇编代码中,用于定位堆栈内存的寻址方式变成了类似 8(%rsp) 的模式,而且使用**-Og**生成的汇编代码更符合 C 代码整体结构,我们的第一版汇编代码的和原始的 C 代码相比就有很大程度的变形,导致汇编代码和源代码之间的关系难以理解。

但是,这种使用栈顶指针定位的方式只适用于在编译期能够确定栈帧大小的情况,如果函数的栈帧是在运行中动态改变的。比如使用了变长数组、alloca等情况下,在堆栈上分配的字节数是任意的,也就无法确定 %rsp 和要寻址的内容之间的地址偏移量,此时便只有 %rbp 一种定位方式了,让我们看下面这个变长数组的例子:

1
2
3
4
5
6
7
8
9
long vframe(long n, long idx, long *q){
long i;
long *p[n];
p[0] = &i;
for(i =1; i<n;i++){
p[i] = q;
}
return *p[idx];
}

默认情况生成的汇编代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
vframe:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $72, %rsp
.cfi_offset 3, -24
movq %rdi, -56(%rbp)
movq %rsi, -64(%rbp)
movq %rdx, -72(%rbp)
movq %fs:40, %rax
movq %rax, -24(%rbp)
xorl %eax, %eax
movq %rsp, %rax
movq %rax, %rsi
movq -56(%rbp), %rax
leaq -1(%rax), %rdx
movq %rdx, -40(%rbp)
movq %rax, %rdx
movq %rdx, %r8
movl $0, %r9d
movq %rax, %rdx
movq %rdx, %rcx
movl $0, %ebx
leaq 0(,%rax,8), %rdx
movl $16, %eax
subq $1, %rax
addq %rdx, %rax
movl $16, %ebx
movl $0, %edx
divq %rbx
imulq $16, %rax, %rax
subq %rax, %rsp
movq %rsp, %rax
addq $7, %rax
shrq $3, %rax
salq $3, %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
leaq -48(%rbp), %rdx
movq %rdx, (%rax)
movq $1, -48(%rbp)
jmp .L2
.L3:
movq -48(%rbp), %rdx
movq -32(%rbp), %rax
movq -72(%rbp), %rcx
movq %rcx, (%rax,%rdx,8)
movq -48(%rbp), %rax
addq $1, %rax
movq %rax, -48(%rbp)
.L2:
movq -48(%rbp), %rax
cmpq %rax, -56(%rbp)
jg .L3
movq -32(%rbp), %rax
movq -64(%rbp), %rdx
movq (%rax,%rdx,8), %rax
movq (%rax), %rax
movq %rsi, %rsp
movq -24(%rbp), %rdx
subq %fs:40, %rdx
je .L5
call __stack_chk_fail@PLT
.L5:
movq -8(%rbp), %rbx
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc

vframe 的函数栈帧如图 2 - 14 所示:

图 2-14 函数 vframe 的栈帧

可以看到,在整个函数的执行过程中,使用不变的 %rbp 帧指针寄存器和不同的偏移量来引用堆栈上的内容。

5. 总结

本篇名为《穿越虚拟内存的迷雾》,但并未深入的介绍虚拟内存,只是通过对虚拟内存的溯源来更好的理解 stack。

虚拟内存并非与生俱来,乃是先驱们在计算机的发展过程中总结出的有效的内存管理方式。它通过对存储的抽象为运行在计算机中的每个进程提供了统一的地址空间,并使用交换技术和分页使得计算机可以在有限的物理内存上运行比较大的程序。利用程序的局部性原理,让数据在磁盘和真实的物理内存之间以页的形式换入换出,以此节约了成本,提高了硬件的利用率。

Stack 是进程地址空间中举足轻重的一环,函数的调用与返回、局部变量的存储都依赖于它。但它的位置与大小却并不总是那么明朗,通过对 Linux 下 stack 分布和大小的探查,我们对 stack 的分配问题如管中窥豹,可见一斑。见诸各种资料上的进程地址空间布局图仅仅只是标明了主线程的 stack,并没有在线程概念普及后予以校正。或许这在计算机专家眼中根本不值一提,但它成功迷惑了我。因此我做了这些许微小的工作,以俟夫究察者得焉!

有了这些对于 stack 的基础认识,会更容易理解 Go 语言中协程的 stack,我们下一篇文章见😙~

在撰写此文时,深深感受到自身学识之匮乏、能力之浅薄,因此行文中定有疏漏讹误之处,也请阅读此文的朋友热情斧正。

参考文献

  1. 操作系统导论
  2. 深入理解计算机系统
  3. 程序员的自我修养
  4. Linux/UNIX系统编程手册
  5. 深入 Linux 内核架构
  6. 现代操作系统
  7. mm
  8. How to mmap the stack for the clone() system call on linux?
  9. allocatestack.c
  10. Understanding glibc malloc

为什么会有这篇文章

我在啃 rust 圣经英文原版时,所有权那一章就反复看了几遍,作者简要介绍了 stackheap 的使用区别,这引起了我以前从未有过的一个思考:stack 上的数据是如何被引用的?

All data stored on the stack must have a known, fixed size. Data with an unknown size at compile time or a size that might change must be stored on the heap instead. The heap is less organized: when you put data on the heap, you request a certain amount of space. The memory allocator finds an empty spot in the heap that is big enough, marks it as being in use, and returns a pointer, which is the address of that location. This process is called allocating on the heap and is sometimes abbreviated as just allocating. Pushing values onto the stack is not considered allocating. Because the pointer is a known, fixed size, you can store the pointer on the stack, but when you want the actual data, you must follow the pointer.

简要翻译一下:

所有存储在 stack 上的数据,其 size 必须是固定不变且在编译期已知的。如果数据的大小不能在编译期决定,亦或是在运行时其大小会发生改变,那么这样的数据就应该放在 heap 上。heap 是一个组织比较松散的区域:当你想把数据放置在 heap 上时,你必须先请求一块存储空间才行。专门的内存分配器会去寻找一块空余的、大小足够的空间,并将其标记为已用,之后返回指向这块区域的指针。这个过程就是在 heap 上分配内存,有时会简称为 allocating。将数据推入 stack(入栈)不会被叫做 allocating(因为 stack 的空间在线程创建时就已分配并初始化)。因为指针类型的大小固定且已知,因此你可以将指针入栈,但是当你想使用数据本身时,你必须追踪跟指针(解引用)。

正如我开篇所言,让我心头一震的并不是这些随处可见的关于 stackheap 区别的论述,而是我从未深入思考过的一个问题:stack 上的数据是如何被引用的?

以前对堆栈的认知就是 PUSH 入栈,使用时 POP 出栈,这大概源于对数据结构课程中 stack 数据结构认知的根深蒂固。所以当我读到 stack 上的数据都是固定大小且日后不会改变、不会增长时,才后知后觉:为何以前没有去想这个细节呢?

CPU 把一块内存当做 stack 使用时,参数和局变量入栈之后仍会被使用,并不像我们数据结构课程中的 stack,只在用的时候才去 POP。CPU中的 stack 机制只有在函数结束时才释放该函数的栈帧,那栈上的数据在没有弹出时是如何被引用到的呢?

其实,这个问题很好回答,从编译之后的汇编指令上看,是使用的相对定位。大致是利用 ebp(帧指针寄存器或基指针寄存器) 和 esp(栈顶指针寄存器)中的一个或两个来达到定位的目的。

以此为由,我深入调查并思考了很多关于 stack 的内容,主要涉及有如下几点:

  1. CPU 的 stack 机制是如何工作的?
  2. 函数调用是如何利用 stack 的?
  3. 进、线程在创建时 stack 如何分配,虚拟内存中的 stack 区域是线程共有的么?
  4. 从指令级别理解 Go 语言的 goroutine 是如何调度和分配 stack 的?

因此,我将分几篇文章分别对这些内容进行叙述,虽然我是围绕着 stack 来进行思考的,但文章内容也会谈及很多 stack 之外的东西,比如虚拟内存、线程调度,上下文切换等等。

友情提醒一下,因个人能力有限,我并不会去深抠相关的源码细节,我目前所寻求的信息,意在建立计算机系统的世界观与 Go 语言的世界观,是在陷入具体细节之前为自己提供一个大致的轮廓,让自己对计算机运行的脉络有一个关键性的认识。

这是这个系列的第一篇,本篇我主要从 CPU 的视角来带大家一起复习一下理论知识,看一看 CPU 是如何执行指令,如何将一块内存当做 stack 来使用。

因 stack 一般会被翻译为堆栈,因此后续文章内容能使用英文原文 stack 的地方,我会尽量使用 stack,需要中文的地方我会使用堆栈一词,如果堆栈一词放上去比较突兀的话,我会直接使用栈这个字。所以,无论是 stack、堆栈、栈,我希望阅读文章的你能铭记我指的就是 stack!

内存和它里面的东西

众所周知,计算机内部用二进制表示一切。从应用的角度而言,内存中存放的信息大致可分为指令和数据,然而在内存看来,这些信息并没有什么区别。一段信息是指令还是数据,全看 CPU 如何使用它们。CPU 在工作的时候,把有的信息看作指令,有的信息看作数据,为同样的信息赋予了不同的意义。那这是如何做到的呢?我们以 8086 CPU 为例来简要说明。

CPU与内存之间的总线

如图所示,CPU 与内存之间有三类总线,分别为地址总线、控制总线和数据总线。数据的读、写就是靠这三类总线配合完成的。我们来看一下 CPU 从内存 3 号单元中读取数据的过程。

CPU 从内存读取数据

  1. CPU 通过地址线将地址信息 3 发出。
  2. CPU 通过控制线发出内存读命令,选中存储器芯片,并通知它,将要从中读取数据。
  3. 存储器将 3 号单元中的数据 08 通过数据线送入 CPU。

写操作与读操作的步骤相似。如向 3 号单元写入数据 26 :

  1. CPU 通过地址线将地址信息 3 发出。
  2. CPU 通过控制线发出内存写命令,选中存储器芯片,并通知它,要向其中写入数据。
  3. CPU 通过数据线将数据 26 送入内存的 3 号单元中。

当然,现实当中地址并不总是像 3 这样简单,我们可以把每个字节当成一个小盒子,内存就是一堆小盒子的线性排列,如果从第一个盒子开始给他们都编上号,那么每个盒子的号码就是对应内存空间的地址,也就是说在计算机主存当中,每一个字节都有它的地址。你可以把内存想象成下面这样(图中每一行代表前文论述的盒子):

memory

有一点很明显,就是 CPU 能寻址的空间大小是受地址总线导线数量限制的。在 8086 中,其地址总线是 20 根,也就是它能用 20 个 bit 来表示一个地址,算来它最大的寻址空间就是 2 的 20 次方,即 1M 大小。

但问题是,在 8086 内部用于倒腾数据的寄存器都是 16 位的, 16 位最大寻址空间只有 64K,那如何用 16 位的寄存器组合成 20 位的地址呢?8086 的处理方法是使用 2 个 16 位地址,通过一个地址加法器的运算来得出 20 位的物理地址。运算的逻辑就是一个 16 位的段地址左移 4 位再加上偏移地址:物理地址=段地址*16 + 偏移地址。此处不再举例描述细节,有兴趣的可以参考任意讲解段地址和偏移地址的资料,此处只需要记住,8086 CPU 下读写内存时,都需要一个段地址的辅助,这个段地址就放在段寄存器中。

8086 有 4 个段寄存器,CS、DS、SS、ES,分别是代码段寄存器、数据段寄存器、堆栈段寄存器以及扩展段寄存器,本文主要涉及代码段和堆栈段,那么先从 CS 代码段寄存器说起吧。

CPU 是如何执行指令的

我们都知道,CPU 的任务说起来很简单,就是从程序计数器(PC)中拿到地址,将该地址处的数据看作指令并读入指令缓冲器,之后指令缓冲器中的指令便会进入执行控制器执行。当PC中的地址所指向的数据被读取之后,PC中的值会自动增加为下一条指令的地址,接下来就会重复之前的内容。

但在 8086 中, PC 是由两个寄存器来共同表示的,那就是 CS 和 IP,CS是代码段寄存器,IP是指令指针寄存器。任意时刻,设 CS 中的内容为 M,IP 中的内容为 N,8086 CPU 将从内存单元 M*16 + N 开始,读取一条指令并执行。换句话说,任意时刻,CPU 将 CS:IP 指向的内容当作指令执行。

下图为CPU执行一条指令的过程拆解,其中 CS 寄存器的值为 2000HIP 寄存器的值为 0000H,内存 20000H~20009H 单元存放着可执行的机器码。

CPU 执行指令

CPU 会将CS、IP中的内容送入加法器得出物理地址 2000H*16+0000H(20000H),之后 20000H 送入地址总线,位于 20000H 单元处的指令 B8 23 01 会被读入指令缓冲器并增加 IP 的值(增加的值为本次读取的指令的长度,此处为 3 个字节),接下来就会进入执行阶段。因为此时 CS:IP 中的内容已经是下一条指令的地址(mov bx,0003H),CPU 便进入下一次循环,如此往复。

上图执行完第一条指令之后 AX 寄存器的内容已变为 0123H

更详细的内容,请参考王爽《汇编语言》2.10 章节。在这里描述 CPU 指令的执行过程是为了在后面的文章中方便描述线程调度,其实线程切换的原理很简单,就是修改 CS:IP 的值让 CPU 去执行另外一个地方的指令,而这个地方就是新线程的函数指令入口。

CPU 提供的stack机制

如今的 CPU 都有堆栈的设计,提供相关的指令来以堆栈的方式访问内存空间。这意味着在编程的时候,可以将一段内存当作堆栈来使用。CPU 提供了以堆栈方式访问内存的机制,操作系统用这种机制实现了函数调用。

CPU 提供了入栈和出栈的指令,最基本的就是 PUSHPOP 指令,比如 PUSH AX 表示将寄存器AX中的内容送入堆栈,POP AX 就是将栈顶的元素送入 AX 寄存器,并移动栈顶。那么,CPU 是如何知道一段内存被当做栈来用的呢?答案是 SS 堆栈寄存器SP 栈顶寄存器

任意时刻,SS:SP 指向栈顶元素,PUSH 和 POP 指令执行的时候,CPU 从 SS 和 SP 处获取栈顶地址,并在执行过程中自动加减SP寄存器中的值,是的 SS:SP 永远指向栈顶。

PUSH AX 的指令,由以下两步完成。

  1. SP=SP-2, SS:SP 指向当前栈顶的下方的单元,以当前栈顶下方的单元为新的栈顶;
  2. 将 AX 中的内容送入 SS:SP 指向的内存单元处,SS:SP 此时指向新的栈顶。

下图描述了 8086CPU 对 PUSH 指令的执行过程。

PUSH

POP AX 的指令更好相反,由以下两步完成。

  1. 将 SS:SP 指向的内存单元的数据送入 AX 中;
  2. SP=SP+2,SS:SP 指向当前栈顶上方的单元,以当前栈顶上方的单元为新的栈顶。

下图描述了 8086CPU 对 POP 指令的执行过程。

POP

注意,栈由高地址向低地址增长,这两幅图相当于倒置的状态。

从中我们可以得出一点结论,将一段内存当做栈,仅仅是我们在编程时的一种安排,CPU 并不在乎这段内存在哪,也不在乎内存放的到底是指令还是数据。换句话说,内存并无区别,区别只在 CPU 如何看待它们。我们会在后面讲述线程或协程堆栈使用的内存,只要将一块内存的地址设置为 SS:SP 那么这段内存就会在你使用 PUSH、POP 的时候作为堆栈来用。

我们在学习虚拟内存的时候(下一章会简单介绍虚拟内存)容易被其划分的各种区域迷惑,某段区域用于 stack ,某块区域用于 heap。很容易让人产生这些区域都有各自特殊之处的错觉。加上一些盛行理论的影响,让问题更加扑朔迷离,常见的如:数据放在 stack 上要比 heap 上快,因为入栈只需要一条指令,而在 heap 上申请内存则十分耗时,简言之使用 stack 廉价,使用 heap 昂贵。

其实这种说法不严谨,除了会迷惑初学者之外,别无益处。试想一下,stack 与 heap 有什么区别?区别只在 stack 已经分配,且使用方式不同;heap 只在需要的时候去分配,耗时的是分配的过程,而不是访问的过程;CPU 访问 stack 中的内容和 heap 中内容的方式并无二致,都是使用的标准的内存寻址方式

CPU 把主存当做什么用,完全看我们的规划以及使用的指令,比如设置了代码段之后,代码段部分保存的数据就被 CPU 看做指令,当设置了 DS 数据段之后,使用 mov 等操作时 CPU 即将内存中的内容视作数据,当设置了 SS 段之后,使用 PUSH 和 POP 时 CPU 就把那块内存当做堆栈来使用。

CPU 对主存的一视同仁可以在另外一个现象上得到印证,如果使用现代的编译器编译为汇编文件,你会发现很多理论上应该使用 PUSH 的地方却使用了 MOV,这是因为编译器做了优化,PUSH 隐含了两个操作,一个是移动数据,一个是维护堆栈指针。当操作较多时,指针维护较为频繁,为性能计转而使用 mov 来达到目的,并在必要的时候手动维护栈顶指针。这间接说明了堆栈段的内存并无甚特别之处,其特别只在于使用的方式(入栈和出栈的操作同样可以用 mov 和数据段来配合达到)。

现代 CPU 中的段

8086 采用了段的方式来增加寻址空间,使得最大寻址空间达到 1M,但它并没有对内存访问做寻址检查,也不支持现在广为流行的虚拟内存(虚拟内存的内容,我们在下一篇探讨函数栈帧的时候详述)。这意味着 8086 没有内存保护功能,且此种寻址方式无法对多道程序提供支持,这种使用段直接生成物理地址的模式被称为实模式。

Intel 在 8086 的继任者身上实现了保护模式,对虚拟内存提供了硬件上的支持。保护模式依然使用段来加强寻址,且方案设计非常复杂精巧,这种方式人们将其称为 IA-32 保护模式,但是此种模式应用甚少,进入 64 bit 时代后,CPU 仅仅作为兼容需要予以保留。

AMD 是第一个吃螃蟹的,首次从架构上将 CPU 的虚拟寻址空间带入 64 bit 模式(可参考 wiki x86-64 的描述,内容还算详实),虽然在架构上已支持 64 bit,但是 AMD 在处理器的实现上实际仅支持 48 bit 的虚拟寻址空间,以及 40 bit 的物理寻址空间;对应的 Intel i7 实现为 48 bit 虚拟寻址空间 和 52 bit 的物理寻址空间。这主要是出于经济上的考虑,并在架构设计上做了功夫,使得日后可以很容易扩展到真正的 64 位。不要小看 48 bit 的寻址空间,这意味着当前实现的 CPU 最大支持 256 TB 的内存寻址,然而当下几乎没有场景会达到这一临界点。更详细的内容可以参考这两篇讨论:

  1. why virtual address are 48 bits not 64 bits?
  2. Why do x86-64 systems have only a 48 bit virtual address space?

在这里有必要提一下 64 bit 模式下与寻址相关的Canonical form addresses,AMD 标准规定:虚拟地址的高 16 位 也就是从 48 位到 63 位必须拷贝第 47 位的值。Canonical form addresses 规定有效的地址空间为 000007FFF'FFFFFFFF,以及 FFFF8000'00000000FFFFFFFF'FFFFFFFF,可以看下图形象的表示:

Canonical form addresses

Canonical form addresses 将空间分为上下两部分,可见 48 bit 寻址空间下中间有很大的的空洞。Linux 将上部 128TB 用于内核空间,下部 128TB 用于用户空间,即可以寻址 256 TB 的内存空间。无论如何地址已经是 64 bit 的了,不再需要借助段寄存器。

如果上面这些内容难以理解,那么你只需要明晰一点:在汇编语言中,修改指令寄存器和栈顶寄存器的时候,只需设置一个寄存器即可,已不再需要段的参与!

程序调度、堆栈初始化无非就是修改 CPU 对应的寄存器,之后就可以控制进程执行流的走向,以及进程用到的 stack。这一点也是本系列文章围绕的核心所在,故不厌其烦的简述其原理于此。

参考文献

  1. 汇编语言
  2. 深入理解计算机系统
  3. 程序员的自我修养
  4. 计算机体系结构:量化研究方法
  5. why virtual address are 48 bits not 64 bits?
  6. Why do x86-64 systems have only a 48 bit virtual address space?
  7. x86-64
  8. Address canonical form and pointer arithmetic
  9. Why 64 bit mode ( Long mode ) doesn't use segment registers?
  10. How are the segment registers (fs, gs, cs, ss, ds, es) used in Linux?

原文出处:Exploring "io/fs" to Improve Test Performance and Testability

io/fs概述及其存在的必要性

​要理解为什么 Go1.16 版本引入io/fs,就必须先要理解 embedding(内嵌)的基本原理。当开发一个工具的时候,嵌入那些日后需要被访问(寻址)的内容涉及到很多方面,但本文仅仅讨论其中之一。

​对于每个要嵌入静态文件的工具来说,其工作本质都大同小异。当它们运行的时候,每个静态文件都会被转换为字节,放入一个.go文件之中,最后被编译成二进制文件。一旦进行编译,工具本身就必须负责将针对文件系统的调用转换为对一个虚拟文件系统的调用

​当运行嵌入了assets静态文件的程序后,代码访问这些文件的方式依然是针对文件系统的调用,我们必须把这种调用转换为一种虚拟调用(因为实际访问的文件内容已被转换为字节,并编译进程序本身)。此时,我们面临一个问题:如何在代码中确定一个调用是针对虚拟的assets还是真实的文件系统?

​想象一下这样一个工具:它会遍历一个目录,并返回所能找到的所有以.go结尾的文件名称。如果此工具不能和文件系统交互,那么它将毫无用处。现在,假设有一个 web 应用,它内嵌了一些静态文件,比如images, templates, and style sheets等等。那这个 Web 应用程序在访问这些相关assets时应使用虚拟文件系统,而不是真实文件系统。

​要分辨出这两种不同的调用,就需要引入一个供开发人员使用的API,该API可以指导该工具何时访问虚拟化,何时访问文件系统。这类API都各有特色,像早期的嵌入工具 Packr,它使用的就是自定义的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Box
func Folder(path string) *Box
func New(name string, path string) *Box
func NewBox(path string) *Box
func (b *Box) AddBytes(path string, t []byte) error
func (b *Box) AddString(path string, t string) error
func (b *Box) Bytes(name string) []byte
func (b *Box) Find(name string) ([]byte, error)
func (b *Box) FindString(name string) (string, error)
func (b *Box) Has(name string) bool
func (b *Box) HasDir(name string) bool
func (b *Box) List() []string
func (b *Box) MustBytes(name string) ([]byte, error)
func (b *Box) MustString(name string) (string, error)
func (b *Box) Open(name string) (http.File, error)
func (b *Box) Resolve(key string) (file.File, error)
func (b *Box) SetResolver(file string, res resolver.Resolver)
func (b *Box) String(name string) string
func (b *Box) Walk(wf WalkFunc) error
func (b *Box) WalkPrefix(prefix string, wf WalkFunc) error

​使用自定义 API 的好处就是工具开发者可以完全掌控用户体验。这包括使开发人员更轻松地管理需要在幕后维护的复杂关系。缺点也很明显,那就是使用者需要去学习这种新的 API。其代码也就严重依赖于这种自定义的 API,这使得它们难以随时间升级。

​另一种方式就是提供一种模拟标准库的 API , Pkger 就是此例之一:

1
2
3
4
5
6
7
8
9
10
type File interface {
Close() error
Name() string
Open(name string) (http.File, error)
Read(p []byte) (int, error)
Readdir(count int) ([]os.FileInfo, error)
Seek(offset int64, whence int) (int64, error)
Stat() (os.FileInfo, error)
Write(b []byte) (int, error)
}

​这种方式使用已知的、大家都熟悉的 API,会更容易吸引用户,而且也避免了再去学习新的 API 。

​Go 1.16标准库引入的io/fs包就采用了此种方式,其优点就是使用了用户熟知的 API 接口,因此也就降低了学习成本,使得用户更加容易接受。

​但有其利必有其弊,虽然使用现有 API 迎合了用户使用习惯、增加了程序的兼容性,但同时也导致了大而复杂的接口。这亦是io/fs所面临的问题,不幸的是,要正确模拟对文件系统的调用,需要很大的接口占用空间,我们很快就会看到。

测试基于文件系统的代码

io/fs包不仅仅只是支撑1.16 版本嵌入功能这么简单,它带来的最大便利之一就是丰富了单元测试,它可以让我们编写更加易于测试的文件系统交互方面的代码。

除了增加代码的可测试性之外,io/fs还可以帮助我们编写更加易读的测试用例,并且在我们测试文件系统交互代码时拥有不寻常的性能表现。

为了更深入地了解io/fs包,我们来实现一段代码,它的功能是遍历一个给定的根目录,并从中搜索以.go结尾的文件。在循环遍历过程中,程序需要跳过一些符合我们预先设定前缀的目录,比如.git , node_modules , testdata等等。我们没必要去搜寻.git , node_modules文件夹,因为我们清楚它们肯定不会包含.go文件。一旦我们找到了符合要求的文件,我们就把文件的路径加入到一个列表中然后继续搜索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func GoFiles(root string) ([]string, error) {
var data []string

err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
base := filepath.Base(path)
for _, sp := range SkipPaths {
// if the name of the folder has a prefix listed in SkipPaths
// then we should skip the directory.
// e.g. node_modules, testdata, _foo, .git
if strings.HasPrefix(base, sp) {
return filepath.SkipDir
}
}

// skip non-go files
if filepath.Ext(path) != ".go" {
return nil
}

data = append(data, path)

return nil
})

return data, err
}

这个函数的执行结果将产生一个类似于下面这样的slice

1
2
3
4
5
6
7
8
[
"benchmarks_test.go",
"cmd/fsdemo/main.go",
"cmd/fsdemo/main_test.go",
"fsdemo.go",
"fsdemo_test.go",
"mock_file.go",
]

我现在提出的问题是:我们该如何测试这段代码?因为这段代码直接和文件系统交互,我们该如何保证在文件系统上呈现一个准确无误的测试场景呢?

鉴于测试方法会有很多种,在深入io/fs之前,我们先看一下最常见的两种方法,从而对比一下io/fs能带给我们怎样的便利。

JIT Test File Creation

第一个测试文件系统代码的方法就是在运行时刻创建必须的文件夹结构。

本文将以benchmark的方式呈现单元测试,如此我们就可以对比各种测试方法的性能。这也是为何setup(创建测试用的文件结构)的代码会被包含在基准代码当中,我们基准测试的目标就是setup的过程。在此情况下,各种测试方法都不会改变setup的底层函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func BenchmarkGoFilesJIT(b *testing.B) {
for i := 0; i < b.N; i++ {

dir, err := ioutil.TempDir("", "fsdemo")
if err != nil {
b.Fatal(err)
}

names := []string{"foo.go", "web/routes.go"}

for _, s := range SkipPaths {
// ex: ./.git/git.go
// ex: ./node_modules/node_modules.go
names = append(names, filepath.Join(s, s+".go"))
}

for _, f := range names {
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(f)), 0755); err != nil {
b.Fatal(err)
}
if err := ioutil.WriteFile(filepath.Join(dir, f), nil, 0666); err != nil {
b.Fatal(err)
}
}

list, err := GoFiles(dir)

if err != nil {
b.Fatal(err)
}

lexp := 2
lact := len(list)
if lact != lexp {
b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
}

sort.Strings(list)

exp := []string{"foo.go", "web/routes.go"}
for i, a := range list {
e := exp[i]
if !strings.HasSuffix(a, e) {
b.Fatalf("expected %q to match expected %q", list, exp)
}
}

}
}

​在BenchmarkGoFilesJIT测试用例中,我们使用io/ioutil包来为测试创建满足需求场景的临时文件夹和文件。此刻,意味着要创建包含.go文件的node_modules.git目录,以便于确认这些.go文件不会出现在处理结果中。如果GoFiles函数正常工作的话,我们在结果集中将看到两个条目,foo.go 以及 web/routes.go

​这种JIT方式有两大缺点:其一,随着时间的推移,编写和维护setup部分的代码将会变得非常麻烦,为测试用例做大量的setup本身也会引入更多的 bug。其二,也是最大的弊端,JIT测试会创建大量的文件和文件夹,这势必会在文件系统上产生大量的i/o竞争和i/o操作,从而让我们的任务性能非常低效。

1
2
3
4
5
goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesJIT-16 1470 819064 ns/op

Pre-Existing File Fixtures

另一种测试GoFiles的方法是创建一个名为testdata的目录,并且在里面创建好测试场景所需的全部文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
testdata
└── scenario1
├── _ignore
│ └── ignore.go
├── foo.go
├── node_modules
│ └── node_modules.go
├── testdata
│ └── testdata.go
└── web
└── routes.go

5 directories, 5 files

使用这种方法,我们就可以清理掉很多我们的测试代码,让GoFiles函数指向事先准备好的已包含相应测试场景的文件夹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func BenchmarkGoFilesExistingFiles(b *testing.B) {
for i := 0; i < b.N; i++ {

list, err := GoFiles("./testdata/scenario1")

if err != nil {
b.Fatal(err)
}

lexp := 2
lact := len(list)
if lact != lexp {
b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
}

sort.Strings(list)

exp := []string{"foo.go", "web/routes.go"}
for i, a := range list {
e := exp[i]
if !strings.HasSuffix(a, e) {
b.Fatalf("expected %q to match expected %q", list, exp)
}
}

}
}

这种方法大大减少了测试的消耗,从而提高了测试的可靠性和可读性。与JIT方法相比,此方法呈现的测试速度也快得多。

1
2
3
4
5
6
goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesExistingFiles-16 9795 120648 ns/op
BenchmarkGoFilesJIT-16 1470 819064 ns/op

这种方法的缺点是为GoFiles函数创建可靠测试所需的文件/文件夹的数量和组合(意指数量和组合可能都很巨大)。到目前为止,我们仅仅测试了“成功”的情况,我们还没有为错误场景或其它潜在的情况编写测试。

使用这种方式,一个很常见的问题就是,开发者会逐渐的为多个测试重复使用这些场景(指testdata中的测试场景)。随时间推移,开发者并非为新的测试创建新的结构,而是去更改现有的场景以满足新的测试。这将测试全部耦合在了一起,使测试代码变得异常脆弱。

使用io/fs重写GoFiles函数,我们将会解决所有的问题!

使用 FS

​通过上面的了解,我们知道io/fs包支持针对virtual file system的实现(译者注:意指io/fs包提供了很多针对fs.FS的功能)。为了利用io/fs提供的功能,我们可以通过重写GoFiles函数让它接受一个fs.FS作为参数。在正式的代码中,我们可以调用os.DirFS来获得一个由底层文件系统支持的fs.FS接口的实现。

​为了遍历一个fs.FS的实现,我们需要使用fs.WalkDir 函数,fs.WalkDir 函数的功能近乎等同于filepath.Walk函数。尽管这些差异很值得推敲一番,但这超出了本文的范围,因此我们将在以后的文章中另行阐述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func GoFilesFS(root string, sys fs.FS) ([]string, error) {
var data []string

err := fs.WalkDir(sys, ".", func(path string, de fs.DirEntry, err error) error {
if err != nil {
return err
}

base := filepath.Base(path)
for _, sp := range SkipPaths {
// if the name of the folder has a prefix listed in SkipPaths
// then we should skip the directory.
// e.g. node_modules, testdata, _foo, .git
if strings.HasPrefix(base, sp) {
return filepath.SkipDir
}
}

// skip non-go files
if filepath.Ext(path) != ".go" {
return nil
}

data = append(data, path)

return nil
})

return data, err
}

得益于io/fs包兼容性API带来的便利,GoFilesFS函数避免了昂贵的重写,仅需要很小的修改就可完工。

实现 FS

现在,该函数已更新为使用fs.FS,让我们看看如何为它编写测试。在此之前,我们先来实现fs.FS

1
2
3
4
5
6
7
8
9
10
11
12
type FS interface {
// Open opens the named file.
//
// When Open returns an error, it should be of type *PathError
// with the Op field set to "open", the Path field set to name,
// and the Err field describing the problem.
//
// Open should reject attempts to open names that do not satisfy
// ValidPath(name), returning a *PathError with Err set to
// ErrInvalid or ErrNotExist.
Open(name string) (File, error)
}

Open函数接收一个文件的路径,然后返回一个fs.File和一个error。如文档所述,需要满足某些关于错误的需求。

对于我们的测试来说,我们将会使用一个模拟文件类型的切片,并稍后将其实现为fs.FS,该切片还将实现所有本次测试所需的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type MockFS []*MockFile

func (mfs MockFS) Open(name string) (fs.File, error) {
for _, f := range mfs {
if f.Name() == name {
return f, nil
}
}

if len(mfs) > 0 {
return mfs[0].FS.Open(name)
}

return nil, &fs.PathError{
Op: "read",
Path: name,
Err: os.ErrNotExist,
}
}

MockFS.Open中,我们在已知文件列表中循环匹配请求的名称,如果匹配成功则返回该文件;如果没有找到,则尝试在第一个文件中递归打开。最后,如果没有找到,则按文档要求返回适当的error

我们的MockFS目前还未实现完整,我们还需要实现fs.ReadDirFS接口来模拟文件。尽管fs.ReadDirFS文档未提及以下约束,但fs.ReadDirFileFile.ReadDir则需要它们。因此,它们也值得留意和实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ReadDir reads the contents of the directory and returns
// a slice of up to n DirEntry values in directory order.
// Subsequent calls on the same file will yield further DirEntry values.
//
// If n > 0, ReadDir returns at most n DirEntry structures.
// In this case, if ReadDir returns an empty slice, it will return
// a non-nil error explaining why.
// At the end of a directory, the error is io.EOF.
//
// If n <= 0, ReadDir returns all the DirEntry values from the directory
// in a single slice. In this case, if ReadDir succeeds (reads all the way
// to the end of the directory), it returns the slice and a nil error.
// If it encounters an error before the end of the directory,
// ReadDir returns the DirEntry list read until that point and a non-nil error.

尽管这些规则听起来令人困惑,但实际上,这种逻辑非常简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (mfs MockFS) ReadDir(n int) ([]fs.DirEntry, error) {
list := make([]fs.DirEntry, 0, len(mfs))

for _, v := range mfs {
list = append(list, v)
}

sort.Slice(list, func(a, b int) bool {
return list[a].Name() > list[b].Name()
})

if n < 0 {
return list, nil
}

if n > len(list) {
return list, io.EOF
}
return list[:n], io.EOF
}

实现 File 接口

我们已经完成了fs.FS的实现,但仍需要实现一组接口来满足fs包的需要。幸运的是,我们可以将所有接口实现到一个类型当中,从而使我们的测试更加简便。

继续之前,我要申明一点:我故意没有完全实现接口的文件读取部分,因为这将增加不必要的复杂度,而这些复杂度不是本文所需要的。所以我们将在后续的文章中探讨相关主题。

为了测试我们的代码,我们将要实现四个接口: fs.File , fs.FileInfo , fs.ReadDirFile , and fs.DirEntry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}

type FileInfo interface {
Name() string
Size() int64
Mode() FileMode
ModTime() time.Time
IsDir() bool
Sys() interface{}
}

type ReadDirFile interface {
File
ReadDir(n int) ([]DirEntry, error)
}

type DirEntry interface {
Name() string
IsDir() bool
Type() FileMode
Info() (FileInfo, error)
}

乍看之下,这些接口的体量之大似乎压人心魄。但是不用多虑,因为它们很多重叠的功能,所以我们可以把他们凝聚到一个类型当中。

1
2
3
4
5
6
7
8
9
type MockFile struct {
FS MockFS
isDir bool
modTime time.Time
mode fs.FileMode
name string
size int64
sys interface{}
}

MockFile类型包含一个fs.FS的实现MockFS,它将持有我们测试用到的所有文件。MockFile 类型中的其余字段供我们设置为其相应功能的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
func (m *MockFile) Name() string {
return m.name
}

func (m *MockFile) IsDir() bool {
return m.isDir
}

func (mf *MockFile) Info() (fs.FileInfo, error) {
return mf.Stat()
}

func (mf *MockFile) Stat() (fs.FileInfo, error) {
return mf, nil
}

func (m *MockFile) Size() int64 {
return m.size
}

func (m *MockFile) Mode() os.FileMode {
return m.mode
}

func (m *MockFile) ModTime() time.Time {
return m.modTime
}

func (m *MockFile) Sys() interface{} {
return m.sys
}

func (m *MockFile) Type() fs.FileMode {
return m.Mode().Type()
}

func (mf *MockFile) Read(p []byte) (int, error) {
panic("not implemented")
}

func (mf *MockFile) Close() error {
return nil
}

func (m *MockFile) ReadDir(n int) ([]fs.DirEntry, error) {
if !m.IsDir() {
return nil, os.ErrNotExist
}

if m.FS == nil {
return nil, nil
}
return m.FS.ReadDir(n)
}

Stat() (fs.FileInfo, error)方法可以返回MockFile 本身,因为它已经实现了fs.FileInfo接口,此为我们如何用一个MockFile类型实现众多所需的接口的一个例证!

使用 FS 进行测试

鉴于我们已经拥有了MockFSMockFile,那么是时候为GoFilesFS函数编写测试了。依例,我们首先要为测试设置文件夹和文件结构。通过两个辅助函数NewFileNewDir、以及使用切片直接构建一个fs.FS(指 MockFS)的便捷性,我们可以在内存中快速的构建出复杂的文件夹和文件结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

func NewFile(name string) *MockFile {
return &MockFile{
name: name,
}
}

func NewDir(name string, files ...*MockFile) *MockFile {
return &MockFile{
FS: files,
isDir: true,
name: name,
}
}

func BenchmarkGoFilesFS(b *testing.B) {
for i := 0; i < b.N; i++ {
files := MockFS{
// ./foo.go
NewFile("foo.go"),
// ./web/routes.go
NewDir("web", NewFile("routes.go")),
}

for _, s := range SkipPaths {
// ex: ./.git/git.go
// ex: ./node_modules/node_modules.go
files = append(files, NewDir(s, NewFile(s+".go")))
}

mfs := MockFS{
// ./
NewDir(".", files...),
}

list, err := GoFilesFS("/", mfs)

if err != nil {
b.Fatal(err)
}

lexp := 2
lact := len(list)
if lact != lexp {
b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
}

sort.Strings(list)

exp := []string{"foo.go", "web/routes.go"}
for i, a := range list {
e := exp[i]
if e != a {
b.Fatalf("expected %q to match expected %q", list, exp)
}
}

}
}

本次 setup的代码非常简单高效地完成了我们所需的工作,如果我们需要在测试中增加文件或文件夹,可以通过插入一行或两行来迅速完成。更重要的是,在尝试编写测试时,我们不会因复杂的setup代码而分心。

1
BenchmarkGoFilesFS-16										432418				2605 ns/op

总结

​使用BenchmarkGoFilesJIT方式,我们有很多直接操作filesystem的文件setupteardown 代码(译者注:指文件结构的创建和销毁),这会让测试代码本身引入很多潜在的errorbugsetupteardown 代码会让测试的重心偏移,其复杂性使得很难对测试方案进行更改。而且,这种方式的基准测试性能最差。

​不同的是,BenchmarkGoFilesExistingFiles方式使用预先在testdata中准备好的文件结构场景。这使得测试过程不再需要setup代码,仅仅需要为测试代码指明场景在磁盘中的位置。这种方式还有其它便利之处,例如其使用的是可以用标准工具轻松编辑和操纵的真实文件。与JIT方式相比,因其使用了已存在的场景数据,这极大地增加了测试的性能。其成本是需要在repo中创建和提交很多的场景数据,而且这些场景数据很容易被其他的测试代码滥用,最终导致测试用例变得脆弱不堪。

​这两种方式都有其它的一些缺陷,比如难以模拟大文件、文件的权限、错误等等,而io/fs,可以帮我们解决这些问题!

​我们已经看到了如何通微小的代码改动来使用io/fs包,得益于此,我们的测试代码变得更易于编写。这种方式不需要teardown代码,设置场景数据就像为切片追加数据一样简单,测试中的修改也变得游刃有余。我们的MockFile类型可以让我们像MockFS类型一样模拟出文件的大小、文件的权限、错误甚至更多。最重要的是,我们看到,通过使用io / fs并实现其接口,与JIT测试相比,我们可以将文件系统测试的速度提高300%以上。

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesFS-16 432418 2605 ns/op
BenchmarkGoFilesExistingFiles-16 9795 120648 ns/op
BenchmarkGoFilesJIT-16 1470 819064 ns/op

​虽然本文介绍了如何使用新的io/fs包来增强我们的测试,但这只是该包的冰山一角。比如,考虑一个文件转换管道,该管道根据文件的类型在文件上运行转换程序。再比如,将.md文件从Markdown转换为HTML,等等。使用io/fs包,您可以轻松创建带有接口的管道,并且测试该管道也相对简单。 Go 1.16有很多令人兴奋的地方,但是,对我来说,io/fs包是最让我兴奋的一个。

这一篇无关技术,仅仅是在读完林语堂版《苏东坡传》之后的随笔小记。从最近开始,我要求自己尽量做到每读完一本书、一篇有感想的文章、研究过某种技术、亦或某件我认为有趣的事之后,都要写一写感受,以此磨炼一下思想、锻炼一下笔触。

1
2
3
4
5
            江城子

自古相知无憎由,别离恨,不可求。如是如云,只道言曾旧。烟雨几度任回首,长东事,懒回头;

生老死病却陈游,多少苦,又逢秋。今逢自杜,莫道水难流。相逢只教一碗酒,饮长孤,笑短愁。

​ 我所读诗词之中,数苏东坡的诗最多。然读诗时年少,于诗中所寄体悟不多,却年少气盛,好学东坡题词,所以很多苏东坡写过的词牌,我都附庸风雅过。上面这首《江城子》便是我18岁时写下的,从中依然能看出遣词多浮于表面,大有为赋新词强说愁的少年气。

​ 林著《苏东坡传》原书为英文版,我读的版本是张振玉的译本。坦白讲,我是耐着性子读完这本书的,断断续续读了一年之久。感觉应该是翻译的缘故,通篇下来都是流水式的叙述,很大的篇幅都是描述作者眼中的苏东坡。私以为,这种平铺直叙式的文章,很难拉近读者的距离,也并未让作者和主人公的感情交织融合,从而也就很难引起共鸣。

​ 林为文学大家,但我却知之甚少,此处不宜置喙。然李一冰所著《苏东坡新传》出自林书之后,文学界却评论其是意外胜过林书。所以,我在读完林书后,将继续拜读李一冰的《苏东坡新传》。

​ 我少年时读苏诗,都是敬其诗才绝艳。诗中有不少好句子,简单易懂,郎朗上口。但囿于当时阅历尚浅,自然未能完全体会其中深意。然而,总是听人讲苏东坡乃古今文人中性情最为豁达、看世事最为通透的人,又称其在佛道方面也有不少造诣。每次听到这些评论时,我脑海中除了浮现出“竹杖芒鞋轻胜马”的诗句之外,别无其他。我才发现,对苏东坡竟一无所知!因此,我也是抱着解开心中疑问的想法去阅读林书的,很可惜我未能从中得到答案。

1
2
3
4
心似已灰之木,
身如不系之舟。
问汝平生功业,
黄州惠州儋州。

​ 这首《自题金山画像》是苏东坡临终前所作,虽是自嘲,亦是自身写照。以前读书,文人遭谪似是家常便饭,彼时也不做他想。而今读林书方才知道,苏轼的一生经历了北宋仁宗、英宗、神宗、哲宗、徽宗五朝,官至正三品,是仅次于宰相的朝中大员。授翰林学士、端明殿侍读学士,还做过兵部和礼部尚书。他的一生担任过30多个官职,遭过三次贬谪,辗转奔波于凤翔、杭州、密州、徐州、湖州、黄州、登州、颖州、扬州、定州、惠州、儋州等地,足迹踏遍大半个中国。

​ 如此起落的人生,如此崎岖坎坷的经历,若不是有非常的旷达之心,怎能经受?若不是看破了得失荣辱,历尽了艰难困苦,又怎么会写出“此间有什么歇不得处”之句呢?

​ 我读林书,唯一所获,便是这句:“此间有什么歇不得处?由是如挂勾之鱼,忽得解脱。”

​ 原文如下:

余尝寓居惠州嘉佑寺,纵步松风亭下。足力疲乏,思欲就亭止息。望亭宇尚在木末,意谓是如何得到?良久,忽曰:“此间有什么歇不得处?”由是如挂勾之鱼,忽得解脱。若人悟此,虽兵阵相接,鼓声如雷霆,进则死敌,退则死法,当恁么时也不妨熟歇。

​ 文章题目标明“记游”,本可记述游历经过和松风亭的由来及四周的景物。但苏东坡非为叙事,而是明理。从“意谓如何得到”,悟出世间“有甚么歇不得处”的道理。这种即时放下,随遇而安,“当恁么时,也不妨熟歇”的旷达态度,正是苏轼从自己丰富的人生磨砺中,触动外物,偶然得之的。一件本来令人沮丧的遭遇,换个角度想,豁然开朗,“由是如挂钩之鱼,忽得解脱”。

​ 这种思考方式,在后来贬谪过程中不断从苏轼笔下表现出来,这既是苏轼对自己生活困境的一种积极反抗——以乐处哀,又是苏轼在具体现实中始终不堕其精神品格、自我提升到一种旷远开阔境地的呈示。

​ 我们读书要讲究向自家身心上用工夫,遂由此联想到自己。我本是一个平庸之人,奈何却不甘平庸,因此平白多了些烦恼。一边面对的是人外有人、天外有天的事实,所见之处都是比自己优秀的人;另一边面对的是从小所受的“世上无难事,只要肯攀登“的教育思想。便时常夹在其中,备受煎熬。直至读东坡此语,我的心亦如那挂钩之鱼,终得解脱!

​ 是啊,此间有什么歇不得处?对于东坡而言,放下顾虑,此间便可好好休息,放下顾虑,此处亦是值得珍惜的人生。对我而言,放下顾虑,此间便可好好做事,可读一本没读过的书,可学一门新的语言,可以陪女儿度过一个愉快的周末,而不必担心还有未完成的博文......

1
2
3
4
亭楼非我驻,
林下亦可留。
去就随我意,
不戚亦不求。

后记:写这篇随笔的时候,香港演员吴孟达因病离世,陪伴我们成长的达叔永远的离开了我们。它曾带给年少的我们欢笑和泪水,是一个在喜剧中留下深情的人...

​ 我在上一篇 策略模式(上) 中介绍了策略模式的概念,并使用传统的OO语言Java实现了一个模拟鸭子游戏的例子。而当今一些新兴的编程语言,并不算严格意义上的面向对象语言,比如GoRust 等。若从面向对象严格的定义上讲,它们并没有类、继承等特性,没有严格遵守OO的四大特性,也就是非严格意义上的面向对象语言。

​ 实际上,对于什么是面向对象编程、什么是面向对象编程语言,并没有一个官方的、统一的定义。而且,从 1960 年,也就是 60 年前面向对象编程诞生开始,这两个概念就在不停地演化,所以,也无法给出一个明确的定义,也没有必要给出一个明确定义。

​ 在此,我们不做学院派,也不替圣人争长短,揪住概念不放。我们仅仅从语言的特性上来考虑问题,看同样的设计模式,在新时代的语言中会带给我们怎样的惊喜。接下来我以Go语言为例,重新使用策略模式实现我们上一篇中的模拟鸭子的游戏。

超类在哪里

​ 我们在上一篇文章中使用抽象类和继承来实现多态,超类就是抽象类。但是Go并没有提供经典OO语言中的类和继承,那么要实现多态我们只有接口可用,因此这里可以将接口视为超类。但是Go的接口又不完全等同于Java的接口,Go的接口实现是完全隐式的

interfaceGo语言中真正的魔法,是Go语言的一个创新设计,它只是方法集合,并且它与实现者之间的关系是隐式的。它让程序内部各部分之间的耦合降至最低,同时它也是连接程序各个部分之间“纽带”。隐式的interface实现会不经意间满足:依赖抽象、里氏替换、接口隔离等原则,这在其他语言中是需要很"刻意"的设计谋划才能实现的,但在Go interface来看,一切却是自然而然的。

​ 我们首先定义一个鸭子接口:

1
2
3
4
5
6
type Duck interface {
Display()
Fly()
Quack()
Swim()
}

​ 按照鸭子的抽象,我们定义了DisplaySwimFlyQuack四个方法,意在每个接口的实现者都提供自己独立的方法实现。根据上一篇的讨论,这里面混合了不变的、可能会变的、一定会变的内容,根据这样的接口去实现会带来代码无法复用和维护上的难题。

​ 这里面只有Display是一定会变的,所以可以继续留在Duck接口中。而FlyQuack是可能会变的,根据我们上一篇总结的原则,应该把可能会变的内容抽离出去,用单独的类实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 定义 Flier 接口
type Flier interface {
Fly()
}
// FlyWithWings 实现了 Flier 接口
type FlyWithWings struct{}

func (f FlyWithWings) Fly() {
fmt.Println("I am flying with my wings!")
}

// FlyNoWay 实现了 Flier 接口
type FlyNoWay struct{}

func (f FlyNoWay) Fly() {
fmt.Println("I can not fly!")
}

// FlyWithRocket 实现了 Flier 接口
type FlyWithRocket struct{}

func (f FlyWithRocket) Fly() {
fmt.Println("I am flying with a rocket!")
}

// 定义 Quacker 接口
type Quacker interface {
Quack()
}
// QuackNormal 实现了 Quacker 接口
type QuackNormal struct{}

func (q QuackNormal) Quack() {
fmt.Println("Quack Quack!")
}
// Squeak 实现了 Quacker 接口
type Squeak struct{}

func (s Squeak) Quack() {
fmt.Println("Squeak Squeak!")
}
// QuackMute 实现了 Quacker 接口
type QuackMute struct{}

func (q QuackMute) Quack() {
fmt.Println("I can not Quack!")
}

// 目前的 Duck 接口
type Duck interface {
Display()
}

​ 我们分别定义了FlierQuacker接口,并分别为之提供了多种对应的实现。根据Java的经验, 这时候应该在抽象类里面声明接口成员变量,将行为委托出去,但是Go中的接口仅仅定义了行为方法,无法定义成员,我们该如何将FlyQuack的行为委托出去呢?

​ 而且,接口中也无法定义方法实现,那么我们又该如何继承Swim的行为呢?

忘掉继承,使用组合

Go语言提供了的最为直观的组合的语法元素就是type embedding,即类型嵌入。通过类型嵌入,我们可以将已经实现的功能嵌入到新类型中,以快速满足新类型的功能需求,这种方式有些类似经典OO的“继承”,但在原理上与经典OO的继承完全不同。这是一种Go精心设计的“语法糖”,被嵌入的类型和新类型两者之间没有任何关系,甚至相互完全不知道对方的存在,更没有经典OO那种父类、子类的关系以及向上、向下转型(type casting)。

​ 通过新类型实例调用方法时,方法的匹配取决于方法名字,而不是类型。这种组合方式,我称之为“垂直组合”,即通过类型嵌入,快速让一个新类型“复用”其他类型已经实现的能力,实现功能的垂直扩展。

​ 利用类型嵌入,我们可以定义一个基础的鸭子结构,并在其中嵌入FlierQuacker接口,以达到行为委托的目的。而在接下来实现具体鸭子时将基础结构体嵌入,即可达到了垂直组合的效果:

1
2
3
4
5
6
7
8
9
10
11
12
type DuckBase struct {
Flier
Quacker
}

func (d *DuckBase) SetFlyBehaviro(f Flier) {
d.Flier = f
}

func (d *DuckBase) SetQuackBehaviro(q Quacker) {
d.Quacker = q
}

​ 上述代码定义了DuckBase结构体,并在其中嵌入了FlierQuacker接口(我们还提供了设置行为的方法),如果要初始化DuckBase就必须使用FlierQuacker的实现来构造;根据类型嵌入的规则:将已经实现的功能嵌入到新类型中,新类型便会获得被嵌入类型的功能,这相当于DuckBase也实现了FlierQuacker接口。

​ 我们来看一个具体的鸭子实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type MallardDuck struct {
*DuckBase
}

func (m MallardDuck) Display() {
fmt.Println("my head is green!")
}

type RedheadDuck struct {
*DuckBase
}

func (r RedheadDuck) Display() {
fmt.Println("my head is red!")
}

type RubberDuck struct {
*DuckBase
}

func (r RubberDuck) Display() {
fmt.Println("I am rubber duck!")
}

​ 上述代码片段提供了三种鸭子的实现,它们都嵌入了*DuckBase结构(因为我们的行为设置方法是指针接收器),并且分别实现了Duck接口。

​ 既然可以通过类型嵌入来达到类似于“继承”的效果,那么我们完全可以让DuckBase结构体实现Swim方法,这样一来,每一个鸭子的实现都可以复用Swim的代码:

1
2
3
func (d DuckBase) Swim() {
fmt.Println("I am swimming!")
}

​ 现在,请仔细回想,让我们沿着回忆的小路再多走几遍:

  1. *DuckBase 嵌入了FlierQuacker接口,并实现了Swim方法
  2. 等于*DuckBase 实现了FlierQuacker接口和Swim方法
  3. 具体的鸭子实现嵌入了*DuckBase 结构
  4. 等于具体的鸭子实现实现了FlierQuacker接口,以及Swim方法

我们就可以回到最初的Duck接口了:

1
2
3
4
5
6
type Duck interface {
Display()
Fly()
Quack()
Swim()
}

Go提供了接口组合的功能,也就是可以通过接口嵌入达到用小接口组合为大接口的目的,所以我们可以修改为接口嵌入:

1
2
3
4
5
6
7
8
type Duck interface {
Flier
Quacker
Display()
Swim()
SetFlyBehaviro(Flier)
SetQuackBehaviro(Quacker)
}

我们通过组合和类型嵌入的方式构建了一个大而全、抽象度低的Duck接口,且完全避免了之前用继承带来的弊端。我们可以自由替换行为,也可以利用多态,且合理的实现了代码复用,同时又不会有维护上的困扰。最关键的是,我们有了一个无比自然的Duck接口,我们上一篇所有的努力都是在构建一个我们逻辑认知上的一个自然的Duck接口。

现在,Go很轻松就做到了!

游戏时刻

继续使用上一篇的例子来测试一下我们的成果吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
mo := MallardDuck{&DuckBase{Flier: FlyWithWings{}, Quacker: QuackNormal{}}}

mo.Display()
mo.Fly()
mo.Quack()
mo.Swim()

mo.SetFlyBehaviro(FlyWithRocket{})
mo.Fly()
}

my head is green!
I am flying with my wings!
Quack Quack!
I am swimming!
I am flying with a rocket!

文章中的代码见:strategy-go

有一点差点忘记,我们实现了策略模式

接下来是什么

还记得我们的“针对接口编程,而不是针对实现编程”的设计准则么?

我们在上一篇Java的实现中使用多态时是通过new来实例化一个实现的:

1
2
3
4
5
6
7
8
Duck mallard = new MallardDuck();
mallard.performQuack();
mallard.performFly();

Duck model = new ModelDuck();
model.performFly();
model.setFlyBehavior(new FlyRocketPowered());
model.performFly();

在本篇中,我们直接使用实现类型的字面量初始化的:

1
mo := MallardDuck{&DuckBase{Flier: FlyWithWings{}, Quacker: QuackNormal{}}}

这似乎违反了针对接口编程的设计准则,因为我们的代码里包含了具体实现的代码。所以,接下来我们会进入工厂模式的学习,在此之前先解释一点:虽然是针对接口编程,但是我们不能把实现完全消灭,因为程序的运行最后依然靠的是具体实现。那么,我们就需要有一个很好的方法将其组织起来,这就是工厂模式

本篇为策略模式的上篇,我以传统的严格意义上的面向对象语言 Java为例来说明此模式;我会在下一篇用非严格意义上的OO语言 Go基于同样的例子进行说明。

有一个游戏

假设我们在设计一款鸭子游戏。玩家可以通过按钮选择任意一款鸭子,使得对应的鸭子可以在屏幕上展现,并且做相应的动作。为此我们设计了如下的高层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Game {
Duck duck;
public Game(String type){
if (type.equals("picnic")) {
duck = new MallardDuck();
} else if (type.equals("hunting")) {
duck = new DecoyDuck();
} else if(type.equals("inBathTub")) {
duck = new RubberDuck();
}
}

public play() {
duck.display();
duck.swim();
}
}

我们希望Duck是一个超类,并且在这里起到多态的作用。通过type输入参数来模拟用户通过按钮选择鸭子这一行为,并且在构造函数中初始化相应的鸭子实例,以便于后续调用play函数来在屏幕上展示。

我们假定每种鸭子都有独一无二的外观,并且每种鸭子都有相同的游泳方式。记住我们的这两个假设,因为我们马上要据此设计超类。

超类

我们之前假设了鸭子的两种特征:

  1. 每种鸭子都有独一无二的外观
  2. 每种鸭子的游泳方式都相同

我们把Duck设计为一个抽象类,因为外观是独一无二的,所以display设计为抽象方法,具体的外观由每一个具体的子类去实现;swim的行为每种鸭子都一样,所以我们在抽象类里将其实现,以达到所有子类共享的目的。

但是,我们忽然又意识到鸭子还有quack叫的特征,但是目前又不确定quack是不是有变化,所以暂时将其也在抽象类里实现。我们的超类代码大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Duck {

public abstract void display();

public void quack() {
System.out.println("quack quack quack!");
}

public void swim() {
System.out.println("All ducks float, even decoys!");
}

}

我们来总结一下如此设计的目的:

  1. 抽象类Duck用于实现多态
  2. 抽象方法display用于统一接口,让子类分别实现
  3. quackswim分别用于继承,以达到代码复用的目的

我们用一张图来表示这种继承关系:

duck

我们的设计是利用继承达到多态和代码复用的目的。绿头鸭和红头鸭分别是具体的实现,它们继承了quackswim代码,又各自实现了display的方法。目前来看,我们的设计还算完美,如果不是一只橡皮鸭子出现的话。

现在要让鸭子飞

​ 现在游戏上线了一种新的鸭子—橡皮鸭,橡皮鸭与之前的鸭子不同的是,它不会“呱呱”叫,只会“吱吱”叫。我们所有种类的鸭子都是继承自超类Duck,所以我们可以在橡皮鸭的子类中覆盖Duckquack方法,向下面这样:

1
2
3
4
5
6
7
8
9
10
11
public class RubberDuck extends Duck{

public void display(){
System.out.println("I am a rubber duck!");
}

public void quack() {
System.out.println("squeak squeak squeak!");
}

}

因为橡皮鸭子不会呱呱叫,所以我们覆写了quack方法。子类重写虽然能解决当前的问题,但也势必会引入新的问题,我们接着往下看。

现在有一个新的需求要加入到游戏当中去,那就是我们需要让鸭子展示飞行的动作。基于我们目前的设计,很容易想到的是:在Duck中加入fly()方法:

fly

那么,问题来了,我们是将fly()设计成抽象方法呢,还是将其在超类里实现呢?在超类里实现就会出现橡皮鸭子会飞的情况,我们自然会想到子类重写,就像重写quack函数一样。但是,如果以后又加入了其它的不会飞也不会叫的鸭子怎么办?比如诱饵鸭是木头鸭,不会飞也不会叫;橡皮鸭不会飞但是会叫。长此以往,我们的代码会充斥着各种子类、各种重写的方法,这显然是有问题的。换句话说,我们之前的重写quack是不明智之举。

其实,问题的本质是继承不适用于目前的场景。

使用继承的问题:

  • 如果是抽象的方法,代码在多个子类中重复,即代码无法复用。试想一下:一部分鸭子的飞行代码相同,而又有很多种类的鸭子飞行行为各有特色。
  • 每次新增行为都要修改抽象类和子类,包括以后面临的修改,这都违反了开闭原则,且不可能预知全部的行为。
  • 如果超类实现子类继承,复写子类方法同样无法达到代码复用,因为可能有多个种类的鸭子飞行行为相同,但又不同于超类里的实现。如果一旦涉及到修改,势必会带来维护上的噩梦。
  • 改变会牵一发而动全身,造成其他鸭子不想要的改变。

接口能解决问题么

Java为我们提供了interface来实现多态。既然不能使用继承和重写来实现对应的功能,那么我们很容易想到用定义接口的方式让子类分别实现quackfly接口:

duck interface

像图中那样将quackfly作为单独的接口去实现。这可以解决部分问题,也就是不会有鸭子和行为不符合的情况,但依然面临严峻的问题:

  • 行为只是可能会发生变化的话,每个子类都实现接口,会造成代码无法复用,继而也会带来维护上的噩梦。
  • 如果我想在运行中随时改变飞行的动作呢?继承超类和子类实现单独的接口都无法有效的解决问题。

其实,单独的接口和抽象方法所面临的问题是一致的,即代码复用和维护变更的问题。试想一下,如果有48个Duck子类的飞行行为相同,代码实现就会有48份,而你恰巧在某个时刻需要修改这个行为...

这时,你肯定期待着设计模式能骑着白马来解救你!

分开变化和不变的部分

幸运的是,有一个设计原则,恰好适用于此状况。

找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。

这样的概念很简单,几乎是每个设计模式背后的精神所在。所有的模式都提供了一套方法让“系统中的某部分改变不会影响其它部分”

我们试分析一下,关于变和不变存在以下三种情况:

  1. 一定不变的
  2. 一定会变的
  3. 可能会变的

对于一定会变的,我们很容易用抽象方法或者接口来解决(display)。对于一定不变的(swim),我们就用继承,把实现放在超类里。

棘手的是可能会变化的部分,比如这里的flyquack,这正是让我们左支右绌、进退维谷的根源。根据上面提到的原则,我们应该把这两个部分从Duck类中取出来,建立一组新类来代表每个行为。

separate behavior

我们该如何实现新的行为类呢?我们希望每种Duck在使用行为类的时候具有弹性和灵活性,比如可以动态改变,也就是说我们可以在Duck类中增加设定行为的方法,这样就可以在运行时动态的改变鸭子的行为了。

那么,有什么设计原则可以指导我们么?

针对接口编程,而不是针对实现编程。

我们理应在Duck类中使用接口,而不是具体的实现。也就是说Duck类不负责实现flyquack,具体的实现交给新的类去实现FlyBehaviorQuackBehavior接口。

Duck类应该只针对接口编程,“针对接口编程”真正的意思是“针对超类型编程”。这里所谓的“接口”有多个含义,关键在于使用多态。利用多态,程序可以针对超类型编程,执行时会根据实际状况执行到真正的行为,不会被绑死在超类型的行为上。“针对超类型编程”这句话,可以明确的说成“变量的声明应该是超类型,通常是一个抽象类或者是一个接口。如此,只要是具体实现次超类型的类所产生的对象,都可以指定给这个变量。这也意味着,声明类时不用理会以后执行时的真正对象类型!”

也就是说,高层代码应该针对行为编程!

实现行为

implement duck's behavior

我们设计了两个接口FlyBehaviorQuackBehavior,还有它们对应的类,负责实现具体的行为。这样的设计,可以让飞行和呱呱叫的动作被其它的对象复用,因为这些行为已经与鸭子类无关了。而我们可以新增一些行为,不会影响到既有的行为类,也不会影响到使用飞行行为的鸭子类。这么一来,有了继承的代码复用好处,却没有了继承所带来的包袱。

用代码实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public interface FlyBehavior {
public void fly();
}

public class FlyWithWings implements FlyBehavior {
public void fly() {
System.out.println("I'm flying!!");
}
}

public class FlyNoWay implements FlyBehavior {
public void fly() {
System.out.println("I can't fly");
}
}

public class FlyRocketPowered implements FlyBehavior {
public void fly() {
System.out.println("I'm flying with a rocket");
}
}

public interface QuackBehavior {
public void quack();
}

public class Quack implements QuackBehavior {
public void quack() {
System.out.println("Quack");
}
}

public class Squeak implements QuackBehavior {
public void quack() {
System.out.println("Squeak");
}
}

public class MuteQuack implements QuackBehavior {
public void quack() {
System.out.println("<< Silence >>");
}
}

把行为整合进抽象类duck

new duck

上图是新的鸭子类,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class Duck {

FlyBehavior flyBehavior;
QuackBehavior quackBehavior;

public abstract void display();

public void performQuack() {
flyBehavior.quack();
}

public void performFly() {
flyBehavior.fly();
}

public void swim() {
System.out.println("All ducks float, even decoys!");
}

}

我们重新实现一下绿头鸭:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MallardDuck extends Duck {

public MallardDuck() {

quackBehavior = new Quack();
flyBehavior = new FlyWithWings();

}

public void display() {
System.out.println("I'm a real Mallard duck");
}
}

我们的绿头鸭代码里有针对实现的代码,就是实例化行为的两行,这貌似违背了了我们之前所述,针对接口编程,而不是针对实现;其实,这里可以改为工厂模式,使得我们的代码彻底的面向接口。但工厂模式不是我们本次的重点,关于这一点我会稍后再略作解释,让我们继续完善我们的代码吧!

之前我们说要把鸭子的行为设计成可以动态改变,现在貌似还差点火候。那么,让我们在Duck类中再加入两个设定行为的方法吧:

1
2
3
4
5
6
7
public void setFlyBehavior(FlyBehavior fb) {
flyBehavior = fb;
}

public void setQuackBehavior(QuackBehavior qb) {
quackBehavior = qb;
}

​ 从此以后,我们可以随时调用这两个方法来改变鸭子的行为。来做个模拟吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MiniDuckSimulator {

public static void main(String[] args) {

Duck mallard = new MallardDuck();
mallard.performQuack();
mallard.performFly();

Duck model = new ModelDuck();
model.performFly();
model.setFlyBehavior(new FlyRocketPowered());
model.performFly();

}
}

output:

1
2
3
4
Quack
I'm flying!!
I can't fly
I'm flying with a rocket!

​ 可见,在运行时想改变鸭子的行为,只需要调用setter方法即可。我们通过把可能变化的行为抽象为接口,使用单独的类去实现它。这样即解决了代码复用的问题,又使得维护变得简单。

是的,这就是策略模式

没错,我们刚刚完成一个策略模式!

是时候了解一下策略模式的具体定义了:

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

翻译一下:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。

让我们把描述问题的措辞稍作改变,不再把鸭子的行为说成是“一组行为”,我们开始把行为想成“一族算法”,算法代表了鸭子能做的事。如今算法和鸭子类之间不再是IS-A的关系,而是HAS-A的关系。

“有一个”关系相当有趣:每一个鸭子都有一个FlyBehavior和一个QuackBehavior,好将飞行和呱呱叫的行为委托给它们代为处理。当你将两个类结合起来使用,如同本例一般,这就是“组合”。这种做法和“继承”的不同之处在于,鸭子的行为不是继承来的,而是和合适的行为对象“组合”来的。

这也是我们通常所说的另一个设计原则:多用组合,少用继承

策略模式就是简单的多态么

纵观策略模式的定义以及各种围绕策略模式的示例,我们很容易产生一个疑问:策略模式就是简单的多态么?策略模式的定义何其标题看不出任何联系,到底何为策略?

从维基百科上策略模式的定义可以看出,策略模式乃是一个方法论,不拘于多态一种实现方式:

Typically, the strategy pattern stores a reference to some code in a data structure and retrieves it. This can be achieved by mechanisms such as the native function pointer, the first-class function, classes or class instances in object-oriented programming languages, or accessing the language implementation's internal storage of code via reflection.

stackoverflow上亦有相同的发问 Is 'Strategy Design Pattern' no more than the basic use of polymorphism? 得票最高的回答也阐述了相同的意思:策略模式,或者说设计本身,它不是指细节代码,而是一种思维方式。

我们虽然在这里用多态的方式实现了策略模式,但策略模式的实现方式绝非多态一种。

另外我观 设计模式之美 中对策略模式的阐述,其称:不使用工厂模式而直接实例化行为对象的情况为简单的面向接口编程,并非严格意义的策略模式。如此观点虽有启发,但仍未消除心中疑虑,因为“策略”一词在我心中还有另一个概念。

策略模式的概念与定义难以让我释怀。我必须自己寻找一个答案,并说服自己去相信它,即便它可能不那么正确。因为我们每个人之所以要不断的思考,就是要缝合自我认知体系里矛盾的地方。

策略一词,在我的知识体系里,是矛盾的!

从Unix设计哲学中取经

在最初接触策略模式时,我很自然的就联想到Unix设计哲学中的一条原则:分离原则。

Rule of Separation: Separate policy from mechanism; separate interfaces from engines.

分离原则:策略同机制分离,接口同引擎分离

policystrategy 同被翻译为“策略”,我以为思想肯定也很接近。但是Gof中描述的算法族,每个具体的算法就是一个策略,让我一度觉得这两个原则之间可能没有关系。

的确,按照定义,这两个原则对策略的理解不是一个层面的东西,就如同英语中的微妙区别一样,一个是具体的行动计划,一个是指导性原则。

但吊诡的是,这两者居然能品出一丝完全相反的味道来。

先来了解一下分离原则。其实它比策略模式要更加普世,虽然分离原则和策略模式同为软件设计原则,但分离原则要更加抽象,而策略模式更贴近于代码,分离原则更偏向于架构。

分离原则讲究把策略同机制分离,策略是针对使用方,机制则说的是实现方。分离原则认为,策略和机制是按照不同的时间尺度变化的,策略的变化要远远快于机制。所以,把策略和机制揉成一团有两个负面影响:一来会使策略变的死板,难以适应用户需求的改变;二来也意味着任何策略的改变都极有可能动摇机制。

相反,将两者剥离,就有可能在探索新策略的时候不会打破机制。另外也更容易为机制写出较好的测试,因为策略都太过短命,不值得花太多经历在上面。《UNIX编程艺术》一书中,举了x图形引擎的例子。让X成为一个通用的图形引擎,而将用户界面风格留给工具包或者系统其它层来决定。GUI工具包的观感时尚来去匆匆,而光栅操作和组合却是永恒的。

让我们回想一下策略模式中的策略,Gof说每个具体的算法类就是策略。我认为以此起**"策略模式"这个名字多少有些以偏概全,未准确传达此模式使“算法族可以互相替换”的主旨。分离原则和策略模式中的策略是不同场景下对不同内容的描述:分离原则的策略是指使用多种不同机制的方法,让机制与使用分离,本质上还是抽离不变与变化的东西**;而策略模式之策略是指一系列做同类事的算法,因为同类,所以可互换,可多态!

所以,忘掉策略模式概念本身吧,它或许不是一个好名字,但却是一个好的、常用的、易用的代码设计方法。只要心中牢记“面向接口,而不是实现编程”,也许一不小心就会写出策略模式了!

总结学到的思想

  1. 面向接口编程,而不是面向实现编程
  2. “针对接口编程”真正的意思是“针对超类型编程”
  3. “针对超类型编程”这句话,可以明确的说成“变量的声明应该是超类型,通常是一个抽象类或者是一个接口。如此,只要是具体实现次超类型的类所产生的对象,都可以指定给这个变量。这也意味着,声明类时不用理会以后执行时的真正对象类型”
  4. 多用组合,少用继承
  5. 唯一不变的就是变化本身
  6. 所有的模式都提供了一套方法让“系统中的某部分改变不会影响其它部分”

参考文献:

  1. 设计模式:可复用面向对象软件的基础
  2. Head First 设计模式
  3. 设计模式之美

变量的迷惑

如果你有其它类C语言的使用经历(c,java,c++,Go等),那么一提到变量,我们会将变量想象成一个box,它代表了计算机中的一块内存,是一个可以存放值的容器:

box

如上图所示,声明初始化一个a=1,就相当于在内存中开辟了一块空间用于存放值1,使用变量名a就可以改变内存中的值a=2。 当把a赋给一个新的变量b的时候,会在内存中为b重新开辟一块空间,并把a的一个副本存入其中。也就是说变量与变量之间完全独立,抱有这种认识的人大多会对 python 中的变量产生很大的误解,不信我们试看下面的代码片段:

1
2
3
4
5
6
7
8
>>> a = 9527
>>> b = a
>>> a,b
(9527, 9527)
>>> id(a),id(b)
(140232581997552, 140232581997552)
>>> b is a
True

上面的代码段初始化了一个变量a并将其赋值为 9527,之后又把a赋给了变量b,打印他们的值都为 9527,到现在 python 中的变量表现和其它语言没什么不同(表面上看起来)。但我们接着打印了这两个变量的地址,你会惊奇的发现,他们竟然相同。使用is来判断,b就是a,也就是说此时在内存中只有一块空间来存放9527这个值。现在我们试着改变一下a的值:

1
2
3
4
5
>>> a = 1024
>>> a,b
(1024, 9527)
>>> id(a),id(b)
(140232581997456, 140232581997552)

我们赋予a一个新的值1024,然后打印了他们的值,python 的表现仍然跟我们“预期”的一样:a的值改变了,b的值没有。,但是在我们打印了他们的内存地址之后,一切看起来并没那么简单。

ab的地址最初都是140232581997552,当a重新赋值之后,b的地址没有改变,而a的地址却变成了140232581997456。也就是说,python的解释器重新开辟了一块内存给了a,这完全颠覆了我们印象中基于box和store对变量的理解。虽然目前看起来还算工作正常,但是我准备再对示例代码做一些改动:

1
2
3
4
5
6
7
8
9
10
>>> a = [1,2,3,4,5]
>>> b = a
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> id(a),id(b)
(140232582690624, 140232582690624)
>>> a == b
True
>>> a is b
True

这一次我们使用了list,一切看起来和刚才一样,ab指向同一块内存,现在我们试着改变list中的元素

1
2
a[1] = 9527
print(a,b)

你认为上段代码的输出会是什么? b中元素的值也会改变么?

1
2
3
>>> a[1] = 9527
>>> print(a,b)
[1, 9527, 3, 4, 5] [1, 9527, 3, 4, 5]

没错,这就是 python 让你惊讶的地方之一,这一次的表现不仅和你的预期不同,甚至和它上一次的表现也不相同。

第一次我们使用的是number,它在 python 中是一个不可变对象,python 中的变量其实是内存对象的一个标签,赋值仅仅是一个绑定的动作,画一个形象的图来表示:

label

当我们使用list时表现又不同,这是因为list在 python 中是一个可变对象。在 python 中一切皆对象,这种特殊的数据模型是造成我们误解的根本原因,接下来我们重点讨论一下 python 中的对象。

一切皆对象

Objects are Python’s abstraction for data. All data in a Python program is represented by objects or by relations between objects. (In a sense, and in conformance to Von Neumann’s model of a “stored program computer”, code is also represented by objects.)
Every object has an identity, a type and a value. An object’s identity never changes once it has been created; you may think of it as the object’s address in memory. The ‘is’ operator compares the identity of two objects; the id() function returns an integer representing its identity.

以上是 python 官网文档中的描述,翻译一下:Python中对象是所有数据的抽象。所有Python程序中的值都由对象或者对象之间的关系表示。Python中每个对象有一个唯一标识identity,一个对象的标识在对象被创建后不再改变。可以认为对象的identity是对象在内存中的地址,其值可以由内置函数id()求得。is操作符可以比较两个对象的identity是否相同,即两个对象是否是同一个。

对于 python 中的变量赋值操作,有两种类比说法。一个是 “boxes vs. label” ,另一个是“names and bindings” 。我们采用“names and bindings” 这种说法,在 python 里一切都是对象,如interger、string、list、dict、set、function等。当我们赋值给一个变量的时候,我们仅仅把变量当成一个名字(name):

<name> = <object>

我们实际上是将一个对象和一个名称绑定,需要注意的是一个对象可以被多个名称绑定,这是最司空见惯的情况,也是最容易引起歧义的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> a = 9527
>>> b = a
>>> a,b
(9527, 9527)
>>> id(a),id(b)
(140232581997552, 140232581997552)
>>> b is a
True
>>> a = "bohu"
>>> b = "bohu"
>>> print(id(a))
140090288720896
>>> print(id(b))
140090288720896
>>> print(a is b)
True

这段代码就展示了一个对象被多个名称绑定的情况,number 9527和字符串bohu是一个数值对象和一个字符串对象,并分别被两个变量绑定。

现在我们使用list来代替numberstring

1
2
3
4
5
6
7
8
>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> print(id(a))
140090289536200
>>> print(id(b))
140090288737736
>>> print(a is b)
False

这个例子中,我们看到ab指向了不同的内存空间,python 的表现之所以有所不同是因为stringnumberimmutable对象,而listmutable的对象,关于python中对象的mutability见下表:

Class immutable
bool Y
int Y
float Y
list N
tuple Y
str Y
set N
frozenset Y
dict N

可见除了listsetdict之外其余都是不可变的,一个immutable的对象被创建之后是不可以改变的。如果你试图通过与之绑定的变量去修改这个对象时,python会创建一个新的实例对象并与原来的变量绑定,之前的对象则伺机被回收。相反,一个mutable的对象是可以被原地改变的,比如之前的例子:

1
2
3
4
5
6
7
8
9
10
11
>>> a = [1,2,3,4,5]
>>> b = a
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> id(a),id(b)
(140232582690624, 140232582690624)
>>> a[1] = 9527
>>> print(a,b)
[1, 9527, 3, 4, 5] [1, 9527, 3, 4, 5]
>>> id(a),id(b)
(140232582690624, 140232582690624)

除非重新赋值,否则ab绑定的对象的内存地址是不会改变的。当你使用b = a时,你并没有成功的copy一个list,你只是把两个name绑定到了同一个list对象之上。因此,正确的理解 python 的对象模型会帮助你正确的调试你的程序。

要理解 python 中的变量,我们就不能把变量当成一个盛放的盒子,我们要把 python 中的变量当做贴在盒子上的标签。我们可以在同一个盒子上贴多个标签,例如:

1
2
3
4
>>> a = "super hero powers"
>>> b = "super hero powers"
>>> print(a is b)
True

当我们执行a = "super hero powers"时,我们说:创建了等号右边的对象,并且把名称 a 绑定到这个对象上。当我们执行a = b时,我们说:把 a 绑定到 b 绑定的对象上

由此可见,在python中:

  • 变量的赋值,只是表示让变量指向了某个对象,并不表示拷贝对象给变量;而一个对象,可以被多个变量所指向。
  • 可变对象(列表,字典,集合等等)的改变,会影响所有指向该对象的变量。
  • 对于不可变对象(字符串、整型、元组等等),所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+= 等等)更新不可变对象的值时,会返回一个新的对象。
  • 变量可以被删除,但是对象无法被删除。

函数调用,传值还是传引用?

先来看官方的一段描述:

Remember that arguments are passed by assignment in Python. Since assignment just creates references to objects, there’s no alias between an argument name in the caller and callee, and so no call-by-reference per se.

参数的传递是通过赋值进行传递(passed by assignment)。也就是说,参数传递时,只是让新变量与原变量指向相同的对象而已,并不存在值传递或是引用传递一说。

1
2
3
4
5
6
7
def my_func1(b):
b = 2

a = 1
my_func1(a)
a
1

这里的参数传递,使变量 a 和 b 同时指向了 1 这个对象。但当我们执行到 b = 2 时,系统会重新创建一个值为 2 的新对象,并让 b 指向它;而 a 仍然指向 1 这个对象。所以,a 的值不变,仍然为 1。

那么对于上述例子的情况,是不是就没有办法改变 a 的值了呢?答案当然是否定的,我们只需稍作改变,让函数返回新变量,赋给 a。这样,a 就指向了一个新的值为 2 的对象,a 的值也因此变为 2。

1
2
3
4
5
6
7
8
def my_func2(b):
b = 2
return b

a = 1
a = my_func2(a)
a
2

当你想获取改变后的值的时候,最好的选择就是返回一个元组来包含多个结果:

1
2
3
4
5
6
7
8
>>> def func1(a, b):
... a = 'new-value' # a and b are local names
... b = b + 1 # assigned to new objects
... return a, b # return new values
...
>>> x, y = 'old-value', 99
>>> func1(x, y)
('new-value', 100)

当传入的参数是一个mutable的对象时,改变对象的值,就会影响所有指向它的变量,因此,我们可以利用这一点达到传引用的效果:

1
2
3
4
5
6
7
8
>>> def func2(a):
... a[0] = 'new-value' # 'a' references a mutable list
... a[1] = a[1] + 1 # changes a shared object
...
>>> args = ['old-value', 99]
>>> func2(args)
>>> args
['new-value', 100]

但我们要注意的是,改变变量和重新赋值的区别

1
2
3
4
5
6
7
8
def my_func(l2):
l2 = l2 + [4]
return l2

l1 = [1, 2, 3]
my_func(l1)
l1
[1, 2, 3]

为什么 l1 仍然是[1, 2, 3],而不是[1, 2, 3, 4]呢?

要注意,这里 l2 = l2 + [4],表示创建了一个“末尾加入元素 4“的新列表,并让 l2 指向这个新的对象。这个过程与 l1 无关,因此 l1 的值不变。当然,同样的,如果要改变 l1 的值,我们就得让上述函数返回一个新列表,再赋予 l1 即可:

1
2
3
4
5
6
7
8
9

def my_func(l2):
l2 = l2 + [4]
return l2

l1 = [1, 2, 3]
l1 = my_func(l1)
l1
[1, 2, 3, 4]

浅拷贝与深拷贝

当我们说深拷贝和浅拷贝时,一般都是针对于集合类型来讲的,如 python 中的listtuplesetdict等。其它语言中的struct类型也会涉及到深浅拷贝之说,通常是指这些集合类型或者结构体中有其它集合的引用。

浅拷贝(shallow copy)

浅拷贝会创建新对象,是指重新分配一块内存,创建一个新的对象,里面的元素是原对象中子对象的引用。注意,其内容非原对象本身的引用,而是原对象内第一层对象的引用。浅拷贝有三种形式:

  • 类型构造器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> l1 = [1,2,3]
>>> l2 = list(l1)
>>> l2
[1, 2, 3]
>>> id(l1),id(l2)
(140232580721664, 140232580722496)
>>> l1 == l2
True
>>> l2 is l1
False
>>> s1 = {1,2,3}
>>> s2 = set(s1)
>>> s2
{1, 2, 3}
>>> id(s1),id(s2)
(140232582029088, 140232580748448)
>>> s1 == s2
True
>>> s1 is s2
False

这里,l2 就是 l1 的浅拷贝,s2 是 s1 的浅拷贝。当然,对于可变的序列,我们还可以通过切片操作符':'完成浅拷贝。

  • 切片操作
1
2
3
4
5
6
7
8
9
>>> l1 = [1,2,3]
>>> l3 = l1[:]
>>> l3
[1, 2, 3]
>>> l1 == l3
True
>>> l3 is l1
False

  • copy 模块中的 copy 函数
1
2
3
4
5
6
7
8
9
10
11
>>> import copy
>>> l1 = [1,2,3,4]
>>> l2 = copy.copy(l1)
>>> l2
[1, 2, 3, 4]
>>> id(l1),id(l2)
(140232580721408, 140232580721664)
>>> l1 == l2
True
>>> l2 is l1
False

因为浅拷贝只是创建一个新对象,集合中的元素内容仍然是原对象中子对象的引用,我们用以上三种方式中的任意一种来观察一下(因为他们都是浅拷贝,结果都是相同的):

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import copy
>>> l1 = [1,2,3,4]
>>> l2 = copy.copy(l1)
>>> l2
[1, 2, 3, 4]
>>> id(l1),id(l2)
(140232580721408, 140232580721664)
>>> l1 == l2
True
>>> l2 is l1
False
>>> id(l1[2]),id(l2[2])
(140232598059200, 140232598059200)

这里l1l2是两个不同的对象,而l1[2]l2[2]是两个变量名称,通过id()可以看到他们两个绑定到了相同的对象之上:

不带可变对象的拷贝

这是没有可变对象的情况下的拷贝,当有可变对象时,也就是说当对象元素中有listsetdict等集合对象时,浅拷贝只是做一个引用绑定,并不会创建新的可变对象:

1
2
3
4
5
6
7
8
9
>>> s1 = ['a','b','c',['d','e']]
>>> s2 = copy.copy(s1)
>>> s3 = copy.deepcopy(s1)
>>> id(s1[0]),id(s2[0]),id(s3[0])
(140232582406832, 140232582406832, 140232582406832)
>>> id(s1[3]),id(s2[3]),id(s3[3])
(140232580722432, 140232580722432, 140232580722624)
>>> id(s1[3][0]),id(s2[3][0]),id(s3[3][0])
(140232582979824, 140232582979824, 140232582979824)

上面的代码片段增加了深拷贝的例子,关于深拷贝我们一会儿再说,这里只看s1s2,其中s1是包含列表的列表,经过浅拷贝之后我们发现:s1[3]s2[3]指向同样的对象。可以说明浅拷贝只是对子列表做了变量绑定,并没有创建新的对象。那么你在修改s1的同时,必然会影响到s2

1
2
3
4
5
6
7
>>> s1[3][0] = "行藏在我"
>>> s1
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s2
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s3
['a', 'b', 'c', ['d', 'e']]

用一张图来描述一下此时的内存图景:

带可变对象的拷贝

深拷贝(deep copy)

深拷贝只有一种形式,copy 模块中的 deepcopy() 函数。深拷贝和浅拷贝对应,深拷贝拷贝了对象的所有元素,包括多层嵌套的元素。因此,它的时间和空间开销要高。

通过上面的浅拷贝示例可知,浅拷贝不会为可变的子对象构建新的对象,这样就会带来修改了新数据之后旧数据也会被修改的副作用。有时候为了避免这种副作用,我们会使用深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> s1 = ['a','b','c',['d','e']]
>>> s2 = copy.copy(s1)
>>> s3 = copy.deepcopy(s1)
>>> id(s1[0]),id(s2[0]),id(s3[0])
(140232582406832, 140232582406832, 140232582406832)
>>> id(s1[3]),id(s2[3]),id(s3[3])
(140232580722432, 140232580722432, 140232580722624)
>>> id(s1[3][0]),id(s2[3][0]),id(s3[3][0])
(140232582979824, 140232582979824, 140232582979824)
>>> s1[3][0] = "行藏在我"
>>> s1
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s2
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s3
['a', 'b', 'c', ['d', 'e']]

还是上一节浅拷贝的例子,我们重点来看s3s3是用深拷贝构建出来的,观察可变子对象的id可以发现它是一个新的对象,拥有全新的内存地址,但是其中的不可变对象仍然共享了原来的对象。

我们通过s1[3][0] = "行藏在我"改变了子列表中的内容之后,深拷贝构造出来的s3并未受到影响,因为s1[3][0]改变的是s1[3]指向的对象本身,而s3[3]指向的是另一个不同的对象,此时的内存图景为:

带可变对象的深拷贝

关于元组copy时的注意事项:

  • 元组只包含非容器类型时(如数字、字符串、和其他'原子'类型的对象),无论是浅拷贝还是深拷贝返回的都是原元组对象的引用。
  • 元组包含可变对象时(如listsetdict等),浅拷贝依然返回引用,深拷贝则会创建一个新的对象和子对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
>>> tup1 = (1,2,3)
>>> tup2 = tuple(tup1)
>>> tup2
(1, 2, 3)
>>> id(tup1),id(tup2)
(140232580721216, 140232580721216)
>>> tup3 = copy.copy(tup1)
>>> tup4 = copy.deepcopy(tup1)
>>> id(tup3)
140232580721216
>>> id(tup4)
140232580721216
>>> tup4 is tup1
True
>>> tup3 is tup1
True
>>> tup2 is tup1
True
>>> tupwithlist1 = (1,2,3,[4,5])
>>> tupwithlist2 = copy.copy(tupwithlist1)
>>> tupwithlist3 = copy.deepcopy(tupwithlist1)
>>> id(tupwithlist1),id(tupwithlist2),id(tupwithlist3)
(140232580724112, 140232580724112, 140232580724672)
>>> id(tupwithlist1[3]),id(tupwithlist2[3]),id(tupwithlist3[3])
(140232580722304, 140232580722304, 140232580743680)
>>> tupwithlist1[3][1] = 9527
>>> tupwithlist1
(1, 2, 3, [4, 9527])
>>> tupwithlist2
(1, 2, 3, [4, 9527])
>>> tupwithlist3
(1, 2, 3, [4, 5])

再论元组

元组是immutable的,却有潜在的被更改的可能性

元组本身是不可变的,但是它包含的值却有可能被更改,特别是当元组hold住一个mutable的对象时,例如list

有了之前把变量名称当做一个对象的标签的论述,我们这里举起例子来就容易多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> dee = ('1861-10-23', ['poetry', 'pretend-fight'])
>>> dum = ('1861-10-23', ['poetry', 'pretend-fight'])
>>> dum == dee
True
>>> dum is dee
False
>>> id(dum), id(dee)
(4313018120, 4312991048)

>>> t_doom = dum
>>> t_doom
('1861-10-23', ['poetry', 'pretend-fight'])
>>> t_doom == dum
True
>>> t_doom is dum
True

我们创建了2个tuple对象,dumt_doom是第一个对象的标签,dee是第二个对象的标签。

dum-t_doom-dee

现在我们为t_doom增加技能:

1
2
3
4
5
6
>>> skills = t_doom[1]
>>> skills.append('rap')
>>> t_doom
('1861-10-23', ['poetry', 'pretend-fight', 'rap'])
>>> dum
('1861-10-23', ['poetry', 'pretend-fight', 'rap'])

dumt_doom都获得了rap技能,原因是他们绑定的是同一个对象, t_doom[1]skills也绑定到了同一个list对象上面:

dum-skills-references

那么我们为什么说此时元组仍是不可变的呢?其实不可变值得是元组的物理内容,元组里包含的是什么?是对于各种对象的引用,dum[1]引用的list对象的改变了,但被引用的对象本身的id并没有变。所以,元组中的可变对象可能会有改动,但是可变对象本身却总保持不变。

参考文章:

  1. Everything Is an Object in Python — Learn to Use Functions as Objects
  2. Python: Everything is an Object, and Some Objects are Mutable
  3. Is Python call-by-value or call-by-reference? Neither.
  4. Python tuples: immutable but potentially changing
  5. Objects, values and types
  6. Programmer's Python - Variables, Objects and Attributes
  7. python变量跟C中变量的区别

平时阅读 C 语言的代码,少不了要在各种形式的 struct 中周旋,特此记录,以备查阅。

声明

struct{ ... } x, y, z;

此种方式指明了类型,并为其声明了变量,分配了存储空间。

但是这种方式没有对结构体类型命名,假如在程序的其它地方再次声明此种类型时会使程序膨胀极难维护。

因此,C 语言提供了两种方式来命名结构体类型:

  1. 结构标记
  2. typedef 定义

结构标记

1
2
3
4
5
struct part {
int number;
char *name;
int on_hand;
};

part 就是创建的标记,之后可以使用它来声明变量了:

1
struct part part1, part2;

part 不是类型名,因此 struct 关键字不能省略!

另外,结构标记的声明可以和结构体变量的声明合并在一起,比如:

1
2
3
4
5
struct part {
int number;
char *name;
int on_hand;
} part1, part2;

typedef 定义

1
2
3
4
5
typedef struct {
int number;
char *name;
int on_hand;
} Part;

类型 Part 的名字必须出现在定义的末尾,此后便可以像内置类型一样使用 Part 了:

1
Part part1, part2;

有两种使用 typedef 定义结构体类型的方法.

第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

struct Point{
int x;
int y;
};
typedef struct Point Point;
int main() {
Point p1;
p1.x = 1;
p1.y = 3;
printf("%d \n", p1.x);
printf("%d \n", p1.y);
return 0;
}

第二种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<stdio.h>

typedef struct Point{
int x;
int y;
} Point;
int main() {
Point p1;
p1.x = 1;
p1.y = 3;
printf("%d \n", p1.x);
printf("%d \n", p1.y);
return 0;
}

一些问题

为什么 C 语言会提供两种方式的类型命名呢?其实 C 语言早期并没有 typedef ,所以标记是结构类型命名唯一的方法。当加入 typedef 时已经太晚了,标记已经无法删除了。

虽然我们可以使用任意一种方式来命名结构体类型,甚至可以同时具有标记和typedef:

1
2
3
4
5
typedef struct part{
int number;
char *name;
int on_hand;
} Part;

甚至标记的名字和typedef的名字都可以一样,但通常并不这么做,因为这在早期的编译器中会出现问题。

但是,有时候我们只能使用结构体标记,那就是结构体成员有自引用的时候,一个典型的例子就是链表:

1
2
3
4
struct node {
int value;
struct node *next;
};

这种情况下,如果没有标记 node 就无法声明 next 的类型。

结构体初始化

声明时初始化

1
2
3
4
5
6
7
8
9
// 声明同时进行初始化
struct student_st{
char c;
int score;
const char *name;
} s1 = {'A', 89, "hello"};

// 使用标记声明并初始化
struct student_st s2 = {'A', 91, "Alan"};

指定初始化

C99 中允许指定初始化,将点号和成员名称组合起来称为指示符,指定初始化的优点是初始化值的顺序不需要和声明时一致。如果初始化中有没有指定的成员,那么这些成员将被设为0。

1
2
3
4
5
6
struct student_st s2 =
{
.name = "YunYun",
.c = 'B',
.score = 92,
};

结构体数组

1
2
3
4
5
6
7
8
9
10
11
12
struct student_st stus[2] =
{
{
.c = 'D',
.score = 94,
/*也可以只初始化部分成员*/
},
{
.c = 'D',
.score = 94,
.name = "Xxx"
},

复合字面量

C99 中可以使用复合字面量来创建没有名字的数组,这通常用于函数的参数传递,以避免先创建变量。

1
total = sum_array((int []){1, 2, 3, 4, 5});

复合字面量的格式为:先在一对圆括号内给定类型名,随后在一对花括号内设定所包含的元素的值。

同样,复合字面量也可以用于”实时“创建一个结构,而不需要将其存储在变量中。生成的结构也可以像参数一样传递,可以被函数返回,也可以赋值给变量。

1
2
3
4
5
// 看如下函数调用
print_part((struct part){528, "Disk drive", 100});

// 变量赋值
part1 = (struct part){528, "Disk drive", 100};

细细体会一下变量赋值的例句,它有些类似于含有初始化的声明,但是并不完全一样,初始化只能出现在声明中,不能出现在赋值语句中。

1
2
struct part1 = {528, "Disk drive", 100}; // ok
part1 = {528, "Disk drive", 100}; // error

最近在看一段源码的时候,发现了一个从未见过的 slice 的用法:

1
2
3
4
5
for batchSize < len(readValues) {
rawValues[address] = readValues[0:batchSize:batchSize]
address = address + 1
readValues = readValues[batchSize:]
}

其中readValues[0:batchSize:batchSize]为一个切片操作,但令人困惑的是其拥有3个索引值;这种书写形式在我读过的有限书籍中从未见过,官方的 [Specification](https://golang.org/ref/spec) 也没有找到相应的描述,所以打算将其弄个一清二楚。

经过一番 google 之后,golang slice, slicing a slice with slice[a:b:c]Re-slicing slices in Golang 两篇问答让我顺藤摸瓜找到了源头,它源自 Go1.2 中的新特性——Three-index slices

我们知道,一个 slice 由三个部分构成:指针、长度和容量:

  • 指针, 指向其第一个元素对应的底层数组元素的地址
  • 长度,对应的是slice中的元素个数,也就是通过下标对slice中的元素进行访问时,不得超过长度的大小,否则会panic
  • 容量,一般是从slice的第一个元素位置到底层数据的结尾位置。

slice[i:j:k] 第二个冒号之后的索引便是容量,默认情况下的容量是这个slice能hold住的最大元素个数,也就是上文提到的从slice的第一个元素位置到底层数据的结尾位置,即使这个slice再被截取,容量也是从起始位置到底层数组的结尾位置。

那什么叫hold住呢?长度以外容量以内的元素又不能通过下标访问,这能叫hold么?当然,长度以外容量以内意味着你可以append,我们试举一例:

1
2
3
4
5
6
var a = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
b := a[2:5]
fmt.Println(b[3]) // panic
b = append(b, 100)
fmt.Println(b[3])
fmt.Println(a) // [1 2 3 4 5 100 7 8 9 10]

可以看到切片b的长度为3,直接通过下标访问b[3]会报运行时恐慌,而它的默认容量是8,所以可以append新值进去,并且最终修改了底层数组的值,通过打印切片a就可以看到底层数组也被修改了,a与b共享底层数组。

接下来我们使用第三个索引来指定切片的容量和长度一样:

1
2
3
4
c := a[2:5:5]
c = append(c, 200)
fmt.Println(c) // [3 4 5 200]
fmt.Println(a) // [1 2 3 4 5 100 7 8 9 10]

可见,切片a的内容并没有变,因为c的容量有限,append操作引起了扩容,从而使得切片c的底层数组与切片a的底层数组分道扬镳。码农桃花源深度解密Go语言之Slice中对于数组扩容有更精彩的图文论述,其中也有data[low:high:max]的论述,只是当时阅读数未曾引起重视。

官网用于说明的例子是:

1
2
var array [10]int
slice = array[2:4:7]

一句话来表述容量被限制之后的结果: It is impossible to use this new slice value to access the last three elements of the original array.

这个特性偶尔会很有用,比如在处理底层的[]byte时,如果很清楚调用者不会去修改slice中的值,那么就可以使用这种方式来更好的保护底层的数组,正如我开篇提到的那段代码一样。

三个索引值,第一个可以省略,如果省略就代表 0.

大学时用 Turbo C 2.0 学 C 语言,只记得是蓝底黄字的丑陋界面,不清楚编译完的程序有何用处,也不知道编译和链接这些基本概念。现在想来只学到了 C 的语法,几年下来也忘得精光。本篇以 Linux 下 GCC 为例,简单描述编译链接的过程。

GCC 隐藏的细节

我们首先使用 GCC 来编译并运行一个经典的hello world程序:

1
2
3
4
5
6
7
#include <stdio.h>

int main(int argc, char **argv)
{
printf("hello world!\n");
return 0;
}

使用 GCC 来编译:

1
2
3
4
{14:59}~/Learing/c/src:master ✗ ➭ gcc hello.c 
{14:59}~/Learing/c/src:master ✗ ➭ ls
a.out hello.c
{14:59}~/Learing/c/src:master ✗ ➭

可以看到在不指定 -o选项的时候,默认生成了一个 a.out文件,这就是最后的可执行程序,我们来执行它:

1
2
{14:59}~/Learing/c/src:master ✗ ➭ ./a.out 
hello world!

程序向标准输出打印了 hello world!,我们的程序执行成功。但是 GCC 期间做了哪些工作?a.out 是一步生成的么?

事实上,这个默认过程至少经历了四个阶段,分别是预处理编译汇编链接,如下图所示:

GCC 编译分解过程

预编译

第一阶段的工作就是预处理,由预处理器完成,处理和源代码相关的头文件,生成一个 .i 后缀的中间文件。C 预处理器(C Pre-Processor)也常简写为 CPP,是一个与 C 编译器独立的小程序,预编译器并不理解 C 语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能。可以使用 GCC 的 -E 选项来控制只进行预编译:

1
gcc -E hello.c -o hello.i

预编译阶段主要是处理源文件中以 #开头的预编译指令。比如 #include#define 等,主要的规则如下:

  • 将所有的 #define 删除,并展开所有的宏定义。
  • 处理所有的条件预编译指令,比如 #if#ifdef#elif#else#endif
  • 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其它文件。
  • 删除所有的注释 ///* */

编译

接下来就是整个程序构建最核心的阶段—编译,通过对预处理阶段产生的结果文件进行一些列的词法分析、语法分析、语义分析以及某些编译器优化之后生成汇编代码文件。编译的过程可以使用如下命令单独进行:

1
gcc -S hello.i -o hello.s

其实目前的 GCC 已经将预编译和编译两个阶段合而为一了,使用一个叫 ccl 的程序来完成这两个步骤,我的机器上这个程序在 /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/cc1 ,我们可以直接调用它来完成预编译和编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{16:12}~/Learing/c/src:master ✗ ➭ /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/cc1 hello.c
main
Analyzing compilation unit
Performing interprocedural optimizations
<*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <remove_symbols> <targetclone> <free-fnsummary>Streaming LTO
<whole-program> <fnsummary> <inline> <free-fnsummary> <single-use> <comdats>Assembling functions:
<materialize-all-clones> <simdclone> main
Time variable usr sys wall GGC
phase setup : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 1220 kB ( 70%)
phase parsing : 0.02 (100%) 0.00 ( 0%) 0.01 ( 33%) 459 kB ( 26%)
phase opt and generate : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 58 kB ( 3%)
preprocessing : 0.01 ( 50%) 0.00 ( 0%) 0.00 ( 0%) 136 kB ( 8%)
lexical analysis : 0.01 ( 50%) 0.00 ( 0%) 0.01 ( 33%) 0 kB ( 0%)
initialize rtl : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 12 kB ( 1%)
TOTAL : 0.02 0.00 0.03 1747 kB

或者,可以直接通过 gcc 和源文件来编译成汇编代码:

1
gcc -S hello.c -o hello.s

通过这些方式都可以得到 hello.s 的汇编文件。事实上 GCC 是个编译工具集,会根据不同的情况调用不同的工具处理每阶段的工作,比如编译时调用 ccl,汇编时调用 as,链接时调用 ld

汇编

正如前文所言,当编译完成得到汇编文件之后,接下来的工作就交给汇编器来执行了,汇编器是将汇编代码转变成机器指令的工具,每一条汇编语句几乎都对应一条机器指令。所以汇编器的活儿相对来说比较简单,只是把汇编指令跟机器指令对照翻译一下,当然翻译完文件就由可读的汇编代码变为只有机器才可以看懂的二进制文件了。对于上面得汇编文件我们可以使用 as 来完成汇编:

1
as hello.s -o hello.o

或者使用 GCC 的 -c 选项,它的意思是编译或者汇编源文件,但不进行链接:

1
gcc -c hello.s -o hello.o

或者直接从 C 文件到目标文件(Object File 的概念非常重要,但此处不展开,留待以后单独讨论):

1
gcc -c hello.c -o hello.o

链接

到这里,我们距离生成最后的可执行文件只有一步之遥,让我们来调用 ld 来生成最后的可执行文件:

1
/usr/bin/ld -static /usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/crtbeginT.o -L /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0 -L /usr/lib -L /lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/crtend.o /usr/lib/crtn.o -o hello

执行 hello 程序:

1
2
{17:00}~/Learing/c/src:master ✗ ➭ ./hello 
hello world!

程序成功执行并输出了 hello world!,但是我们可以看到上面的 ld 命令链接了一大堆的文件才最后生成 hello 可执行文件。

初学者很容易产生的疑问就是:汇编完成的之后的文件不就是二进制文件么?为什么还要进行链接这个步骤呢?链接到底干了些啥?为什么不直接生成最后的可执行文件呢?要说清楚这些问题并不是一件很容易的事,可以说是一件异常困难的事,这里面涉及到静态链接、动态链接、静态库、动态库、运行时库、标准库、链接器等一系列的问题,以至于《程序员的自我修养》用了整整一部书来讲链接这件事情,所以囫囵吞枣看个大概的想法,也是何其难哉!

但这正是我想写这一系列文章的初衷,我希望我能将这些概念总结出来,略去一些细节,只保留轮廓,便于记忆,同时也给初学者以借鉴。

本篇文章先简单描述编译的过程,下一篇文章我们谈静态链接。

0%