兔子先生

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

CPU是一个舞台,操作系统内核是位技艺精湛的指挥家,形形色色的进程就是舞者,它们在内核的指挥下轮番上台表演,演奏一场生命的赞歌!

1

调度是件很神奇的事,一直以来我都对它无比着迷,并不断地钻营个中奥妙。几年下来,略有心得,于是思索着记录下来,以备将来优哉游哉忘乎所以后回来检索之用。

然而吸引我的并不是调度算法,而是调度的时机和原理。因此这篇文章只讨论调度时机和 Linux 内核的调度原理,与调度算法无涉,本文将调度算法当成一个黑盒,优先了解调度行为本身。

计算机操作系统进入多道程序后,需要支持多个程序并发运行,这就需要操作系统必须有能力管理多个程序的运行,必要的时候进行程序切换,使得多个程序轮流获得CPU的使用权。当操作系统需要协调多个程序运行时,就有必要做点什么来保证各个程序可以无冲突并发运行(并发和并行的区别,此处不予讨论)。我们不妨做个不严谨的类比,如果需要使用文字来描述每个任务,那么把所有的任务写在一个文档里显然不是明智之举,即便是计算机新手,也懂得为每个任务单独建一个文档分别管理;当任务变得复杂,发展出很多支线时,一个文档很快又会捉襟见肘,不利于管理了。此时,聪明的做法就是为这个任务创建一个文件夹,每个文件夹中有若干文档,这些文档描述了任务的主线和若干支线,如果有必要它们可以共享文件夹中的图片、音视频等多媒体。

我想你一眼就能看出来,上面的描述是在说进程和线程。不过我类比的重点并不是进、线程本身,而意在说明:要管理多个任务,就必然会采取某种结构来分门别类,分而治之!也就是说,操作系统内核会使用一个结构体来描述进程、线程及其相关的一切。那可以称这个结构体为进程或者线程吗?答案当然是否定的!谈调度自然绕不开进程和线程,因此我们要直面一个难题:你永远无法轻易地说清楚进程是什么!

2

《操作系统导论》将进程定义为:操作系统为正在运行的程序提供的抽象!认为进程只是一个正在运行的程序,并且声明机器状态(程序在运行时可以读取或更新的内容)为进程的一部分,机器状态包括内存和寄存器。

无独有偶,《深入理解计算机系统》对进程的定义是:进程是操作系统对一个正在运行的程序的一种抽象。这和《操作系统导论》中给出的定义如出一辙,但是它紧接着还给出了线程的定义:进程往往不只有单一的执行流,进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。

《深入理解Linux内核》补充说:可以把进程看作充分描述程序已经执行到何种程度的数据结构的汇集。这直接印证了我们之前的类比,从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的实体。这本书更偏爱于将进程描述成拥有多个相对独立的执行流,程序运行的本质就是CPU不断的执行一系列的指令序列,所以看起来就像是不断行进的流。

传统的Unix进程只拥有一个执行流,即便是现代主流语言的编程模式,如果你不采用多线程编程的话,进程也只有一个执行流,我们习惯称之为主线程;一旦使用了多线程的编程范式,进程就会拥有多个独立的执行流,Linux 内核就会为每个执行流分配单独的数据结构来管理资源的使用及其机器状态。实际上,Linux内核中的数据结构并不区分进程和线程,统一都使用task_struct这个结构体来描述一个执行流,程序的调度也是基于这个结构来进行的,而同属于一个进程的线程结构会共享某些资源,比如代码段、虚拟地址空间、打开的文件描述符、信号、堆等,只有堆栈是每个线程私有的。因此,当我们谈论进程时更多的是从资源的角度去思考,而谈到线程时,更多的是从执行流的角度去考虑。

综上,可以给出我心中对于进程的定义:进程是若干个活动的执行流以及各类相关资源的总称,这些资源包括了内核结构、地址空间(内存),寄存器等,其中进程的地址空间包括了多种类型的资源,代码段、数据段、堆、线程堆栈、文件映射等等;有些资源是线程共享的,比如堆、代码段,有些是线程私有的,比如堆栈。这些资源和若干独立执行流共同组成了进程这个抽象概念!

我们即将要探讨的就是:内核是如何将一个执行流从 CPU 上换下,代之以另一个执行流的!

3

这一章节中出现新进程和新建进程两个词语,未免混淆特此说明如下:

新进程:上下文切换时,被选中替换当前进程者

新建进程:使用fork、clone等创建的新进程或新线程

需要明确的一件事情就是内核调度的粒度,你定然听过“线程是调度的基本单位”这样的说法,这种说法固然没有错,但具体每个操作系统的实现却多有不同。以 Linux 为例,其内核角度并不区分进程和线程,用于标识调度单位的结构一律都是task_struct,因此后续的行文不会刻意区分进程和线程。

为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换、任务切换、上下文切换。这是从 CPU 的角度来看,如果站在内核的角度,那么这种行为就是在进行进程调度。

进行任务切换或者调度的根本原因在于 CPU 和寄存器是共享的,大家必须轮流使用!

有意思的是,执行任务切换的并不是一个进程或者内核线程,而是一个函数schedule()。内核中有很多精心定义的点来执行schedule(),我们先来观察schedule()如何执行进程切换,至于调度的时机稍后进行讨论。

进程切换只会也只应发生在内核态,这本身就是内核的职责,需要申明的一点是:执行流进入内核态后,用户态的硬件上下文已经保存在该进程对应的内核态堆栈上了,当该进程从内核态返回用户态时即可恢复原貌,从之前的终止处继续执行。

进程切换由两步组成:

  1. 切换进程地址空间
  2. 切换内核态堆栈和硬件上下文

schedule 会调用 context_switch 执行上下文切换,我们以 Linux 内核 5.10 版本为例,假设此时已经由调度算法挑选出了下一个即将运行的进程next,执行流进入context_switch的代码执行上下文切换:

kernel/sched/core.c

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
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);
arch_start_context_switch(prev);

if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);

next->active_mm = prev->active_mm;
if (prev->mm) // from user
mmgrab(prev->active_mm);
else
prev->active_mm = NULL;
} else { // to user
membarrier_switch_mm(rq, prev->active_mm, next->mm);
switch_mm_irqs_off(prev->active_mm, next->mm, next);

if (!prev->mm) { // from kernel
/* will mmdrop() in finish_task_switch(). */
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}

rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);

prepare_lock_switch(rq, next, rf);

/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();

return finish_task_switch(prev);
}

context_switch的前半部分完成进程地址空间切换,switch_to 完成硬件上下文切换,此处主要关注switch_to如何完成上下文切换,毕竟我们只对执行流的变更感兴趣。

switch_to 是个预定义的宏,因其是和硬件体系结构密切相关的,所以主要部分需用汇编语言实现,我们看一下它的内容:

arch/x86/include/asm/switch_to.h

1
2
3
4
#define switch_to(prev, next, last)					\
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)

__switch_to_asm 即是由汇编实现的程序主体:

arch/x86/entry/entry_64.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
36
37
38
39
40
41
42
43
44
45
46
47
/*
* %rdi: prev task
* %rsi: next task
*/
.pushsection .text, "ax"
SYM_FUNC_START(__switch_to_asm)
/*
* Save callee-saved registers
* This must match the order in inactive_task_frame
*/
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15

/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp

#ifdef CONFIG_STACKPROTECTOR
movq TASK_stack_canary(%rsi), %rbx
movq %rbx, PER_CPU_VAR(fixed_percpu_data) + stack_canary_offset
#endif

#ifdef CONFIG_RETPOLINE
/*
* When switching from a shallower to a deeper call stack
* the RSB may either underflow or use entries populated
* with userspace addresses. On CPUs where those concerns
* exist, overwrite the RSB with entries which capture
* speculative execution to prevent attack.
*/
FILL_RETURN_BUFFER %r12, RSB_CLEAR_LOOPS, X86_FEATURE_RSB_CTXSW
#endif

/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp

jmp __switch_to
SYM_FUNC_END(__switch_to_asm)

首先遵循被调用者原则保存6个寄存器的值,保存的方式就是压入即将被替换进程prev的内核栈,直到此时执行流依然使用prev的内核栈,不过接下来就开始切换内核栈了:

1
2
3
/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp

依照内核函数的调用惯例,%rdi%rsi两个寄存器分别存放调用时传入的第一和第二个参数:__switch_to_asm((prev), (next)))

movq %rsp, TASK_threadsp(%rdi) 的结果是将当前内核栈的栈顶指针寄存器内容保存至prev进程(也就是当前进程)的thread->sp中,thread 是进程描述符task_struct 中一个类型为thread_struct的字段,里面会保存大部分 CPU 寄存器(但不包括 rax、rbx 等通用寄存器,它们的值保存至内核堆栈中)。而movq TASK_threadsp(%rsi), %rsp这一句将被选中进程next中之前被保存的栈顶指针恢复至rsp寄存器,执行完这一条之后执行流的内核堆栈就切换到新进程了。

严格来讲,从上面一条指令之后就是新进程的执行流了,可以说改变内核堆栈就意味着改变当前进程。这归因于和内核堆栈一起存放的一个名为thread_info的结构,内核都是通过它来寻找当前进程的描述符(参考《深入理解Linux内核》88页-标识一个进程)。

接下来,从新进程的内核堆栈中弹出之前保存的6个寄存器的值,然后 jmp__switch_to 函数,__switch_to 函数依然做一些保存老进程上下文和加载新进程上下文的工作,此处不再深入展开,我们仅将注意力集中到jmp这条指令上。

如果你熟悉 2.6 版本的内核,读过《深入理解Linux内核》,你就会奇怪:为何此处切换了新进程的rsp,却没有rip的切换呢?2.6 版本是会将新进程 thread 字段中的ip推入内核堆栈作为返回地址(该指令地址就位于switch_to中),这样在 __switch_toret时就会跳转到thread->ip指向的代码指令了,这也是为什么用jmp 不用 call的原因(call 会将ip 压栈,而jmp只是简单的跳转,不会压栈)。当我读到此处时,禁不住废书而叹,惊讶其设计的巧妙,但同时也生出疑问:既然新进程保存的地址就在附近,为何非要到thread->ip中绕一圈呢?直接把该地址推入内核堆栈效果不也一样吗?可能是内核的设计者也意识到这一点,在 2.6 版本 64 位内核直到最新的5.x版本就放弃了这种做法,不再去刻意的保存和恢复进程的rip 了,因为新进程的起点总是在switch_to函数中。

所以,此处在__switch_to 返回时会跳到开始的switch_to处:

1
2
3
4
#define switch_to(prev, next, last)					\
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)

栈帧一层层解开,会返回到 context_switch 函数调用,继而回到schedule()。可见,任何一个进入内核态调用schedule()执行任务切换的进程,最终都会等到schedule()调用的返回。我们稍后会讲到 Go 运行时的协程调度,其schedule函数是不会返回的,所以它看起来并不像一个函数,这是区别于操作系统调度函数一个重要的点。

分析到此处,我们可能会以手舞之,以足蹈之,感觉终于在指令级别“看到”进程切换的本质了!不过还没到欢欣鼓舞的时候,我们似乎忘掉了一种情形:如果被选中的新进程是新创建的,从来没有被运行过,该又如何呢?__switch_toret时执行流又将流向何方呢?毕竟新进程是没有执行过schedule()的。

依然是内核堆栈救了我们!

要意识到的一点是:新建进程的内核堆栈并不是空的,它 copy 自父进程并经过精心的构造。

clone系统调用为例,copy_thread用发出clone()系统调用时的CPU寄存器的值(此时都在父进程的内核堆栈中)来初始化子进程或者线程的内核堆栈。不过copy_thread会把rax寄存器对应字段的值(这是fork和clone系统调用在子进程或线程中的返回值)强行置为0,这就是为何我们使用fork时要通过返回值来判断此时执行的是子进程还是父进程。既然说到这里,就聊一下clone系统调用的封装接口吧!线程创建的接口是由标准库 glibc 中的包装函数实现的,它处理了返回值的问题,如果是主线程则返回到调用处,如果是新建线程则跳转到任务函数调用处,这是 glibc 封装函数通过推入新线程的堆栈实现的。我们可以观察一下glibc 的 clone 封装代码:

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
75
/* The userland implementation is:
int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
the kernel entry is:
int clone (long flags, void *child_stack).
The parameters are passed in register and on the stack from userland:
rdi: fn
rsi: child_stack
rdx: flags
rcx: arg
r8d: TID field in parent
r9d: thread pointer
%esp+8: TID field in child
The kernel expects:
rax: system call number
rdi: flags
rsi: child_stack
rdx: TID field in parent
r10: TID field in child
r8: thread pointer */

.text
ENTRY (__clone)
/* Sanity check arguments. */
movq $-EINVAL,%rax
testq %rdi,%rdi /* no NULL function pointers */
jz SYSCALL_ERROR_LABEL
testq %rsi,%rsi /* no NULL stack pointers */
jz SYSCALL_ERROR_LABEL

/* Insert the argument onto the new stack. */
subq $16,%rsi
movq %rcx,8(%rsi)

/* Save the function pointer. It will be popped off in the
child in the ebx frobbing below.
这里把线程要运行的函数fn压入新建线程的用户态堆栈中*/
movq %rdi,0(%rsi)

/* Do the system call. */
movq %rdx, %rdi
movq %r8, %rdx
movq %r9, %r8
mov 8(%rsp), %R10_LP
movl $SYS_ify(clone),%eax

/* End FDE now, because in the child the unwind info will be
wrong. */
cfi_endproc;
syscall

/* 比较返回值 */
testq %rax,%rax
jl SYSCALL_ERROR_LABEL
/* 等于零则跳转到 L,否则直接返回 */
jz L(thread_start)

ret

L(thread_start):
cfi_startproc;
/* Clearing frame pointer is insufficient, use CFI. */
cfi_undefined (rip);
/* Clear the frame pointer. The ABI suggests this be done, to mark
the outermost frame obviously. */
xorl %ebp, %ebp

/* Set up arguments for the function call. */
popq %rax /* Function to call. 弹出函数地址 */
popq %rdi /* Argument. */
call *%rax /* 开始调用执行 */
/* Call exit with return value from function call. */
movq %rax, %rdi
movl $SYS_ify(exit), %eax
syscall
cfi_endproc;

开头的注释部分很好地揭示了用户态代码和内核态代码对于传参的不同要求,即寄存器的使用规则。关键点是movq %rdi,0(%rsi)这一句,它将线程要执行的fn地址推入了新进程的堆栈,并在L(thread_start):部分通过下面三条指令启动新线程执行流:

1
2
3
popq	%rax		/* Function to call. 弹出函数地址 */
popq %rdi /* Argument. */
call *%rax /* 开始调用执行 */

Go 并不使用标准的 C 库,其 runtime 重写了所有的系统调用封装函数,从 Go 的 clone 实现来看,也是相似的逻辑。不过,Go 使用 plan9 汇编,在阅读上会有一些不便:

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
// int32 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void));
TEXT runtime·clone(SB),NOSPLIT,$0
MOVL flags+0(FP), DI
MOVQ stk+8(FP), SI
MOVQ $0, DX
MOVQ $0, R10
MOVQ $0, R8
// Copy mp, gp, fn off parent stack for use by child.
// Careful: Linux system call clobbers CX and R11.
MOVQ mp+16(FP), R13
MOVQ gp+24(FP), R9
MOVQ fn+32(FP), R12 // fn 被放入R12寄存器
CMPQ R13, $0 // m
JEQ nog1
CMPQ R9, $0 // g
JEQ nog1
LEAQ m_tls(R13), R8
#ifdef GOOS_android
// Android stores the TLS offset in runtime·tls_g.
SUBQ runtime·tls_g(SB), R8
#else
ADDQ $8, R8 // ELF wants to use -8(FS)
#endif
ORQ $0x00080000, DI //add flag CLONE_SETTLS(0x00080000) to call clone
nog1:
MOVL $SYS_clone, AX // 放入系统调用号,准备进入系统调用
SYSCALL
// 系统调用返回
// In parent, return. 父进程则返回
CMPQ AX, $0
JEQ 3(PC) // 若返回值为0,则跳跃3条指令
MOVL AX, ret+40(FP)
RET

// In child, on new stack.
// 如果是子进程则切换堆栈
MOVQ SI, SP

// If g or m are nil, skip Go-related setup.
CMPQ R13, $0 // m
JEQ nog2
CMPQ R9, $0 // g
JEQ nog2

// Initialize m->procid to Linux tid
MOVL $SYS_gettid, AX
SYSCALL
MOVQ AX, m_procid(R13)

// In child, set up new stack
get_tls(CX)
MOVQ R13, g_m(R9)
MOVQ R9, g(CX)
MOVQ R9, R14 // set g register
CALL runtime·stackcheck(SB)

nog2:
// Call fn. This is the PC of an ABI0 function.
// 调用 fn
CALL R12

// It shouldn't return. If it does, exit that thread.
MOVL $111, DI
MOVL $SYS_exit, AX
SYSCALL
JMP -3(PC) // keep exiting

回到操作系统内核新建进程的话题上来,前面讲过,在内核 3.0 到最新的 5.x 版本不再去刻意的保存和恢复进程的rip 了,因为新进程(被选中的进程)的起点总是在switch_to函数中。那么在新建进程初次运行的问题上,新旧版本虽然看上去不同,但殊途同归,最终都会把执行流引向ret_from_fork(),我们先看看 2.6 版本 32 位内核的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define switch_to(prev,next,last) do {					\
unsigned long esi,edi; \
asm volatile("pushfl\n\t" /* Save flags */ \
"pushl %%ebp\n\t" \
"movl %%esp,%0\n\t" /* save ESP */ \
"movl %5,%%esp\n\t" /* restore ESP */ \
"movl $1f,%1\n\t" /* save EIP */ \
"pushl %6\n\t" /* restore EIP */ \
"jmp __switch_to\n" \
"1:\t" \
"popl %%ebp\n\t" \
"popfl" \
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
"=a" (last),"=S" (esi),"=D" (edi) \
:"m" (next->thread.esp),"m" (next->thread.eip), \
"2" (prev), "d" (next)); \
} while (0)

从注释可以看出,在切换了内核堆栈之后,开始保存旧进程的EIP恢复新进程的EIP,恢复新进程的EIP是通过pushl %6来完成的,这句话的意思是将next->thread.eip的值压入内核堆栈,以便后面的C函数__switch_to返回时跳转。这是比较巧妙的一个地方,对于一个曾经被调度过的进程,其thread.eip中保存的就是1:标号处的指令,这是通过movl $1f,%1指令实现的(此处即上文提到的跳转地址本身就位于switch_to函数中)。问题是对于一个未曾运行过的新进程来说,是没有执行过switch_to代码的,所以此处pushl %6入栈的并不是标号1:处的代码,而是在创建进程时设置的ret_from_fork(),当__switch_to执行到ret 时,从内核堆栈弹出要跳转的地址,这个地址就是ret_from_fork(),新建进程的旅程便由此开始!

64 位的 2.6 内核版本稍微有些区别,但结果都是一样的,这里不再赘述,感兴趣的可以参考Evolution of the x86 context switch in Linux 这篇文章,其详细描述了从内核1.0版本到4.14版本上下文切换部分的设计变更。

回到我们开始讨论的 5.10 版本的__switch_to_asm,当被选中的新进程是一个新建进程时,jmp __switch_to返回后会跳转到哪里呢?答案当然是ret_from_fork(),新进程没有调用过schedule(),故不会回到其调用栈,此时只需搞清楚新进程的内核栈内容就能明白执行流的走向了,新建进程时执行完copy_thread()之后,进程的内核堆栈示意图如下:

图 3-1 新进程的内核堆栈

​ 如图 3-1 所示,弹出6个寄存器之后,就只剩ret_from_fork()的地址了。

4

虽然ret_from_fork()是新建进程的开始,但在真正返回到用户空间之前仍然横着一道关卡,那就是检查是否需要进行再调度。这是有可能的,如果有一个优先级更高的进程需要运行,那么时钟中断处理程序兴许已经在thread_info中做好了标记,等待着schedule()被调用。在真正回到用户空间之前,ret_from_fork()会检查这些标记,如果有必要,就调用schedule()。所以,我们就可以发问了,schedule()返回之后,执行流会去向哪里?

ret_from_fork()而言很简单,如果它真的调用了schedule(),当调度函数返回时,进程会以一个“老程序”的身份初次进入用户空间。因此问题的本质是何时会调用schedule(),换句话说,内核的调度时机有哪些?

Linux 调度时机主要有:

  1. 进程状态转换时刻:进程终止、进程睡眠;
  2. 当前进程的时间片用完;
  3. 进程主动调用
  4. 进程从中断、异常及系统调用返回到用户态时。

时机1,进程要调用sleep()或exit()等函数进行状态转换时,这些系统调用会主动调用调度函数进行进程调度。

时机2,由于进程的时间片是由时钟中断来更新的,因此,这种情况和时机4是密不可分的。

时机3,当进程某些资源无法获取,或者设备驱动程序执行长而重复的任务时,进程主动调用调度函数主动放弃CPU,比如锁、缺页等场景。

时机4,调度的绝大多数占比应属于此种情况,CPU 会在每次执行完一条指令之后检查是否有中断产生,如有则进入中断处理程序。当从中断、异常及系统调用返回到用户态时都会检查调度标志,如果检测为真则进入调度函数。

《操作系统导论》中说道:一旦时钟开始运行,操作系统就感到安全了,因为控制权最终会还给它,因此操作系统可以自由运行用户程序。

可见,在中断返回之前进行调度是一个绝妙的方法。因此,我们可以说:schedule()返回之后的去向并不唯一,有可能是中断、异常和系统调用返回用户空间之前,也有可能是在系统调用进行之中。这也是为何进程切换只会发生在内核态。

我用一幅图来描绘一下,进程的执行流在用户态和内核态之间变换以及进程切换的场景,让我们以指令的视角在进程的用户态和内核态之间,在进程与进程之间进行一场穿梭之旅。

图 4-1 进程执行流

图 4-1 描述了3个进程在两颗CPU上的执行情况,注意这并不是以进程的视角来展现的,各种颜色的执行流仅表示进程处于用户态,灰色的执行流代表内核态,不同的是内核态的前半部分代表当前进程,后半部分则是经过进程切换后代表下一个进程。图中进程B切换为进程A,当执行流进入内核态执行切换时,切换之前内核代表进程B执行,切换之后内核代表新进程A执行。

图 4-1 仅仅展示了进程切换的场景,并不是指进程的执行流进入内核态后一定会发生进程切换

要理解“代表”的含义,就需要解释一下thread_info这个数据结构,我们前面曾提到过它,但未加以详述,此处有必要略作说明。

thread_info被称为线程描述符(不应纠结于概念,把线程理解为一个执行流即可),这个结构包含了指向进程描述符task_struct的指针,因此,内核若想确定当前运行的进程,只需要找到thread_info即可。但问题是如何确定其位置呢?内核的处理方法依然非常巧妙——将thread_info和进程内核堆栈一起存放!放一幅图即可一目了然:

图 4-2 thread_info 结构和内核堆栈存放在两个连续的页框中

图 4-2 所示,这是一块 8k 大小的内存,内核堆栈由高地址向低地址增长,thread_info 存放在最底部,其大小通常是 52 字节,因此,内核堆栈能扩展到8140字节。这个大小看上去很小,至少远远小于进程的用户态堆栈的默认大小(Linux 上用户态堆栈的大小为8M)。这是因为内核控制路径用到的堆栈很少,只需几千字节足矣!

《深入理解Linux内核》说道,从效率的观点来看,thread_info结构与内核态堆栈之间的紧密结合提供的主要好处是:内核很容易从esp寄存器(栈顶指针寄存器)的值获得当前在CPU上正在运行进程的thread_info结构的地址。假设thread_info和内核堆栈的结合体大小就是 8K,那么内核屏蔽掉 esp 的低 13 位有效位就可以获得thread_info结构的基地址。由此可知:切换了内核堆栈就等于切换了当前进程。

还可以从资源使用统计上来理解“代表”的含义。当进程进入内核态时,其对CPU的时间的消耗则记录在进程的sys、hiq、siq 等指标上,由此可以想见一个内核的流氓特征:与我无关的中断处理对于CPU的消耗都算在了我的头上!

之所以强调“代表”,是为了说明进程是相对执行流而言的,而不是相对程序而言。这里的程序指的是躺在硬盘上的二进制数据,或者说用户态的指令集,其实用户态的指令集囊括了磁盘上的二进制数据。换句话说,如果程序是完全静态链接的,程序的二进制就完全包括了用户态指令集,反之,用户态指令集是大于二进制数据中的指令集的,这是由动态链接决定的。

我曾在论坛上与人争论过一个问题:向 redis 发出一个删除大量key的命令,redis 的主线程是否可以说被阻塞了?

很多人的观点是主线程要释放内存会进入系统调用,因释放内存耗时较长故主线程被阻塞。而我的观点是:在删除大量key时,redis 主线程并没有阻塞,阻塞的是发出指令的客户端以及排队发送指令的其它客户端!经过上面的论证,我们知道内核态的执行流一部分代表当前进程,一部分代表切换后的新进程,所以,在被调度之前,不管是用户态还是内核态,都表示这个进程仍在CPU上运行。不能说 redis 线程发起系统调用后陷入了内核,这个线程就被阻塞了,这是不正确的。此时CPU上的执行流依然属于 redis 主线程,其对资源的消耗仍会被记录在该线程名下。redis 主线程一直在努力干活,没有被阻塞,只是这个线程进入到内核态后干的时间比较长,阻塞了发出指令的客户端,同样也阻塞了后续发指令的客户端,而这是由 redis 处理网络请求是单线程模型决定的!

线程是操作系统为用户提供的最轻便的并发模型,它的轻便来自于和进程的对比,二者在资源的使用上不可同日而语。即便如此,线程的资源占用仍然相当可观。从资源的有限性出发,不可能为了并发而创建任意数量的线程。除去堆栈资源累加产生的内存占用之外,过多的线程数量也会加重内核调度的负担,这种负担体现在过多指令浪费在进程切换上,而没有为真正的程序逻辑所用。

对高并发的执着追求,诞生了编程语言世界里五花八门的协程,或者说用户态线程,概念并不重要,重要的是,它们只能在用户态做文章。像Python、Java、C++、Go、Rust 等语言都提供了基于协程的并发模型,这里面由于 Go 是相对比较新生的语言,没有任何历史包袱,从而提供了完美的用户态线程,且将此并发模型内置于语言自身。这样做的好处是,开发并发程序变得异常简单,一个简简单单的go关键字就可以创建一个独立的任务,即一个独立的执行流,如果换做其它语言,想要创建一个独立的执行流只有线程或者进程这种由操作系统提供的原生方式(当然,很多语言也有对应的协程库,因本人未做过详细研究,故只考虑原生的并发模型);然而,为了并发体系的自洽,Go 为其用户屏蔽了操作系统提供的线程模型,虽然用起来简单了,但理解上却多了些许障碍。

5

《操作系统概念》4.3节简单讨论了线程模型,书中说有两种不同的方法来提供线程支持,用户层的用户线程内核层的内核线程,大致有如下三种模型

  1. 多对一模型

    图 5-1 多对一模型

    这种模型将用户态的多个线程映射到一个内核线程上,这种方式最大缺点就是任何一个用户线程发生阻塞调用时,整个内核线程就会被调离 CPU ,致使其它的用户线程失去执行机会。

  2. 一对一模型

    图 5-2 一对一模型

    一对一模型将每个用户线程映射到一个内核线程,如果一个线程阻塞在系统调用上时,剩余的仍然可以运行。这种模型唯一的缺点是创建一个用户线程就需要创建对应的内核线程,可以想见的是,创建内核线程的时间开销和资源开销是相当可观的,换句话说,成本决定了能开启线程的数量。Liunx和windows都实现了一对一的模型。

  3. 多对多模型

    图 5-3 多对多模型

    对多对模型多路复用多个用户级线程到多个内核级线程,通常来说用户线程要比内核线程多的多,这种模型没有上述两种模型的缺点,当一个用户线程发出导致内核线程阻塞的系统调用时,其余的用户线程依然可以被其它内核线程调度。同时,因为用户线程无比轻量,时间和资源成本较少,因此可以开启任意数量的用户线程用于并发,当然量变会引起质变,数量过于庞大的用户线程也会加重资源的消耗,因此会出现了各种各样的协程池用于刹车。Go 语言的并发模型就实现了这种 M:N 的并发模型,稍后我们会讲到,现在先让我来批判一下《操作系统概念》中的概念模糊问题。

不知道你是否被上面的用户线程内核线程映射等概念搅的一头雾水呢?按照其语义,用户线程和内核线程就像是对立的两方,会有某个东西将它们联系起来构成“映射”,或许是内核,又或许是C库,但书中没讲,我能够理解《操作系统概念》是讲操作系统的设计与实现原理,会兼顾大部分操作系统,讲解的也是较为抽象的部分,不过现代绝大部分人只接触过Linux,而Linux内核提供的并发模型就只有一对一这一种,所里书里的内容如今看上去多少有些不合时宜。在 Linux 内核当中甚至并不区分进程和线程,它们统一都由task_struct这一种数据结构表示,并且基于其进行调度,也就是说,在内核看来每个task_struct只有一个执行流,至于在用户态这个大的执行流干些什么,内核并不关心。

要正确理解书中所表达的意图,就需要先把概念捋清楚,我以 Linux 为例来进行说明。

首先,需要把这里的内核线程拿掉,换成操作系统线程,即 Linux 内核提供的线程。我之所以不用内核线程是因为内核线程这个概念在 Linux 中也有对应的存在(参见《深入linux内核架构》2.4.2 内核线程),内核线程是一种只运行在内核地址空间的线程。所有的内核线程共享内核地址空间(对于 32 位系统来说,就是 3-4GB 的虚拟地址空间),所以也共享同一份内核页表,并且没有用户地址空间,这也是为什么叫内核线程,而不叫内核进程的原因。

其次,将用户线程换成协程来表达,这里之所以用协程,完全是因为操作系统线程实在是太耀眼,太深入人心了,以至于人们都忘记了它原本抽象的含义,当需要表达在用户态实现的这个实体时,就有了“用户线程”、“协程”、“go程”等五花八门的名字,特别是“用户线程”,听起来让人如堕五里雾中。

前面讲过,进程是一堆资源和若干执行流的总称,Linux 中的task_struct记录了分配的资源和执行流的状态,是一个勉强能够被称之为进程或者线程的实体,协程也有记录资源和执行流状态的对应实体,从这些“实体”的意义上讲,也可以称协程和操作系统线程之间存在对应关系。现在让我们重新理解一下“对多对模型多路复用多个用户级线程到多个内核级线程”这句话,可以换一种表达:对多对模型指的是在多个Linux操作系统线程之内运行多个协程。我这里使用了“之内”而不是“之上”,是想着重表达操作系统进/线程是一个罐罐儿,用户态的所有花样都是在罐罐儿里玩,并没有超出操作系统进/线程的活动范围。一言以蔽之,用户态程序永远无法逃脱进程地址空间和执行流的手掌心!

或许,我们可以换个角度,抛开这些定义,站在 CPU 的角度去理解问题。所谓线程就是内核维护的一个数据结构,内核依靠这个结构来控制 CPU 上运行的代码,指令无论在用户态还是内核态,都是这个线程,所有的资源消耗都计入此线程,即便是与此线程无关的中断所消耗的资源也被记在该线程名下。协程就是用户空间代码维护的一个数据结构,用户空间代码可以通过控制 ip、sp 等寄存器来控制用户空间执行流的走向,就可以实现在不同的协程间切换,并把执行流的状态记录在对应的数据结构上,重要的是协程代码本身就属于操作系统用户态执行流中的指令。

协程和操作系统线程 M:N 的这种模型,现实中实现并不多,Go 绝对是最耀眼的那一颗!

6

go runtime 中也有和操作系统内核类似的schedule(),它会在最终调用用汇编代码写成的runtime·gogo(buf *gobuf),正如《溯源 goroutine 堆栈》 中提到的,它会将新的 goroutine 恢复执行,最后的指令JMP BX意味着schedule()函数不会返回,schedule()总是会在合适的点被调用,直到选择出可以运行的 goroutine。如果一个 goroutine 自然终止,执行流也会回到事先埋好的点runtime·goexit,而 goexit 最终会调用schedule()。在通往schedule()的调用路径中总会有 runtime·mcall的身影,ip、sp 等寄存器的内容就是在此处被保存,并最终在schedule()中被恢复。

经过上面这些铺垫,我们可以从指令的视角在宏观上来理解一下 go 的协程运作过程:

图 6-1 go协作式调度

仍然以前面展示进程切换时的图作为基础,为了配合 Go 的实际情况,将图中的进程换成线程来讲。现在想象一下,线程 A、B、C 是 go 程序底层的操作系统线程(GMP中的M),g1、g2、g3 为 go 程序在用户空间实现的协程,或者说叫 goroutine。值得注意的是,内核并不知道 goroutine 的存在,它仍然按照自己一贯的行为方式对 A、B、C 三个线程进行调度。

当A线程的时间片用完,或者发出阻塞的系统调用时,就会被内核调度出 CPU,继而把 C 线程调度到 CPU 上来执行。图中显示 A线程被换下 CPU0 的时候,B 线程仍然在 CPU1 上,这意味着其余的 goroutine 依然会得到执行的机会。再看被调度到 CPU0 上的 C 线程,它也是 go 程序底层的线程,这意味着在一个拥有双核的机器上,goroutine 总是有机会运行的,即便有些 goroutine 因为系统调用等某些原因导致其所在的操作系统线程被换下。go 总会保证有两个“活的”的线程一直待在 CPU 上轮番寻找 goroutine 来执行,除非操作系统内核看不下去,换其它的程序线程来执行,但 go 总会保证有两个准备好的线程可以随时被内核调度。

再来切一下近景,把 B 线程放大。在内核看来,黄色部分只代表 B 线程的用户态执行流,但就在这个黄色用户态执行流的内部正在轮番上演形形色色的任务,g1、g2、g3 三个 goroutine 正轮流在CPU上执行,绿色执行流代表 go 的 runtime,正是 runtime 居中调度,指挥得当,才让以 goroutine 为单位的任务都获得执行的机会。内核对这些一无所知,CPU也只会觉得奇怪:这个线程的用户态代码怎么老是频繁的切换堆栈?(见《溯源 goroutine 堆栈》 中对 go 协程堆栈的描述)

这种并发模型的优势显而易见,让我们来直观地感受一下。操作系统线程每次上下文切换需要大约 1000ns 的时间,而硬件有望在每纳秒的时间里执行 12 条指令,也就是说,当任务必须等待时,操作系统就会花费 12k 条指令去做线程切换,却不能将这些指令用在有意义的业务上。而 go 在用户态进行协程切换,极大地缓和了这种浪费,go 进行一次协程切换大概需要 200ns 或者 2.4k 条指令。简言之,go 用尽可能少的 os 级的线程调度来做更多的事情,方式就是在用户空间调度,这是语言级别的一种能力,可不严谨的说,go 的调度比 os 级调度便宜 5 倍,甚至更多!

操作系统内核之所以可以大胆地把 CPU 的使用权交给用户态程序,是因为时钟中断总会让控制权重归内核。那么,完全运行在用户态的 runtime 是如何获得控制权来调度 goroutine 的呢?go1.14 之前确实没办法做到,控制权的转移完全依靠 goroutine 的主动放弃,通过 runtime.Gosched 调用主动让出执行机会,或者当发生执行栈分段时,检查自身的抢占标记,决定是否继续执行;其中第二种情况是通过编译器编译时在函数调用之前插入指令来实现的,也就是说只有在 goroutine 发生函数调用时,runtime 才会获得短暂的执行权来实施调度。不难想见的是,下面这种无函数调用的代码会导致 goroutine 永远霸占 CPU,runtime 根本得不到执行的机会:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 此程序在 Go 1.14 之前的版本不会输出 OK
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
println("OK")
}

换句话说,这种调度方式属于协作式调度,完全依赖执行方的主动弃权;为此,go1.14 基于操作系统信号实现了异步抢占,之所以叫异步,是因为从发送信号到信号被处理这个过程是异步的,并不同步。我这里不准备讲信号的安装、信号的发送逻辑,仅就信号的处理捡扼要处略作说明,感兴趣的朋友可以参考从源码剖析Go语言基于信号抢占式调度这篇文章。

既然 go 的异步抢占处理是基于操作系统信号的,那么在进入go 的处理之前,先来看看操作系统是如何处理信号的吧!

7

请允许我略过对信号繁琐的介绍,直接从进/线程注意到一个信号到来开始。

每个进程从内核态返回用户态之前都会检查TIF_SIGPENDING标志的值,也就是说,每当内核处理完一个中断或异常后,在返回用户态之前都会检查是否存在挂起信号;为了处理信号,内核会调用do_signal()函数,我们假设这个信号安装了专门的处理程序,do_signal()函数必须强迫该处理程序的执行,这是通过handle_signal()进行的。

难点在于,信号处理程序属于用户空间代码,内核不能直接执行用户代码,要执行用户态的信号处理程序,内核必须返回用户态,而一旦返回用户态,内核栈上的内容就会被清空(内核态堆栈包含了被中断进程的硬件上下文),当信号处理程序执行完毕时,又该如何回到正常的执行流程上呢?另外的复杂性是信号处理函数可以执行系统调用,依然要到内核态逛一圈再回到用户态,并且是回到信号处理程序,而不是回到进程原本的正常执行流。

Linux 采用的解决方案是把保存在内核态堆栈中的硬件上下文拷贝到当前进程的用户态堆栈中(稍后会看到go的信号处理抢占程序是如何利用这一点的)。用户态的堆栈也会被修改,使得信号处理函数执行完毕之后,自动调用sigreturn()系统调用把硬件上下文拷贝回内核态堆栈中,并恢复用户态堆栈中原来的内容。

图 7-1 捕获信号

图 7-1 说明了进程处理信号时的执行流,当中断或者异常发生的时候,进程陷入内核。在要返回用户态之前,执行了do_signal()函数,并开始处理信号(调用handle_signal()),在用户态堆栈上建立栈帧(调用setup_frame()setup_rt_frame)。

因为进入内核态时,用户态的寄存器值已被保存到内核态的堆栈上了,所以内核很容易拿到用户态堆栈的地址,并加以修改。建立的栈帧如下图所示:

图 7-2 用户态栈帧

其中sc中保存了进程的硬件上下文,这是从当前进程的内核堆栈上 copy 来的。precode位于栈顶,是信号处理函数的返回地址,将会发出sigreturn()系统调用。

当栈帧构造完成之后,内核需要修改保存在内核态堆栈上用户态寄存器程序计数器的值,让它指向信号处理程序,这样返回用户态之后就会跳转到信号处理程序处执行了:

1
2
3
4
5
6
regs->esp = (unsigned long) frame;
regs->eip = (unsigned long) ka->sa.sa_handler;
regs->eax = (unsigned long) sig;
regs->edx = regs->ecx = 0;
regs->xds = regs->xes = regs->xss = __USER_DS;
regs->xcs = __USER_CS;

这段代码来自《深入理解Linux内核》,大概是 2.6 版本的代码,我找了一下 5.10 版本的内核,其中设置用户栈和寄存器的函数是setup_signal_stack_si,我摘录其中修改内核栈中用户态硬件上下文的内容如下:

1
2
3
4
5
6
7
8
9
10
11
PT_REGS_SP(regs) = (unsigned long) frame;
PT_REGS_DI(regs) = sig;
/* In case the signal handler was declared without prototypes */
PT_REGS_AX(regs) = 0;
/*
* This also works for non SA_SIGINFO handlers because they expect the
* next argument after the signal number on the stack.
*/
PT_REGS_SI(regs) = (unsigned long) &frame->info;
PT_REGS_DX(regs) = (unsigned long) &frame->uc;
PT_REGS_IP(regs) = (unsigned long) ksig->ka.sa.sa_handler;

除了设置堆栈、程序计数器之外也设置了 rdi、rsi、rds 这几个寄存器,这是 x86_64 架构下 C 语言的函数调惯例,三个寄存器分别用于存放函数调用时前三个参数。记住这一点,后面介绍 Go 时会用到。

之后,再经过一系列检查,handle_signal()返回到do_signal()do_signal()返回到用户态,因为程序计数器指向信号处理程序的第一条指令,而栈顶指向已推进用户态堆栈的第一个内存单元。因此,信号处理程序被执行。

信号处理程序结束时,返回栈顶地址,该地址指向栈帧的precode字段所引用的vsyscall页中的代码:

1
2
3
_ _kernel_sigreturn:
popl %eax
movl $__NR_sigreturn, %eax int $0x80

它发出一个系统调用,再次陷入内核,调用restore_ sigcontext( ) 函数,将 sc 中记录的硬件上下文恢复到内核态堆栈,并从用户态堆栈删除之前建立的栈帧,之后返回用户态,程序的执行流开始回到中断之处继续执行。

稍后我们会看到,内核从 sc 恢复的内容是被 go 的 runtime 修改过的!

有这些内容做铺垫,就可以聊一聊 go 的信号抢占了!

8

go 在 M 上通过initsig()来初始化信号,对于需要安装处理程序的信号,会通过setsig来设置对应的动作,真正执行抢占动作的是doSigPreempt,此时的调用栈为:

1
sigtramp-->sigtrampgo-->sighandler-->doSigPreempt

doSigPreempt有个*sigctxt类型的参数,它表示的是我们上一节介绍过的内核保存在用户态堆栈的内核态堆栈内容(比较拗口,需要多读几遍),其中存放的是当前进程用户态的硬件上下文。这个参数是从sigtramp一路传下来的,我们看一下sigtramp的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Called using C ABI.
TEXT runtime·sigtramp(SB),NOSPLIT,$0
// Transition from C ABI to Go ABI.
PUSH_REGS_HOST_TO_ABI0()

// Call into the Go signal handler
NOP SP // disable vet stack checking
ADJSP $24
MOVQ DI, 0(SP) // sig
MOVQ SI, 8(SP) // info
MOVQ DX, 16(SP) // ctx
CALL ·sigtrampgo(SB)
ADJSP $-24

POP_REGS_HOST_TO_ABI0()
RET

sigtramp实际上是真正的信号处理函数,进程从内核态收到信号回到用户态调用的处理函数就是它,注释中表明这个函数以 C 语言的调用惯例被调用,Go 在这里通过PUSH_REGS_HOST_TO_ABI0保存 go 自己调用惯例用的寄存器后,转换成自己的调用规范,等函数调用完毕之后,再通过POP_REGS_HOST_TO_ABI0恢复这些寄存器的值。

还记得上一节介绍 5.10版本的内核修改用户态寄存器时设置的 rdi、rsi、rdx 的值吗?这三个寄存器的值就是内核模仿调用sigtramp时传入的参数,现在 go 需要以自己的调用规约将其放置到堆栈上,来表示 sig、info、ctx 这三个参数(go1.17 改变了调用规约,已经由堆栈传递参数改为寄存器传递了,不知道为何此处仍然使用堆栈传递,我此处引用的代码是版本 1.18.1)。

当调用到doSigPreempt时,会将ctx这个参数传入,其中包含了进程用户态硬件上下文,希望你还记得这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}

// Acknowledge the preemption.
atomic.Xadd(&gp.m.preemptGen, 1)
atomic.Store(&gp.m.signalPending, 0)

if GOOS == "darwin" || GOOS == "ios" {
atomic.Xadd(&pendingPreemptSignals, -1)
}
}

信号处理程序一旦被执行,舞台就交到了 go runtime 手里,ctxt的类型为*sigctxt,指向的是用户态堆栈中存放内核态堆栈内容的地址。然后信号处理程序通过isAsyncSafePoint来判断抢占位置是否安全,并返回安全的抢占地址。如果确认抢占没有问题,接着会调用pushCall方法来修改ctxt中的用户态硬件上下文,用于稍后再一次从内核态返回用户态时模拟出一个用户态程序调用asyncPreempt的假象:

1
2
3
4
5
6
7
8
func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
// Make it look like we called target at resumePC.
sp := uintptr(c.rsp())
sp -= goarch.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = resumePC
c.set_rsp(uint64(sp))
c.set_rip(uint64(targetPC))
}

pushCall干了两件事:

  1. 修改程序计数器的指向为asyncPreempt函数的地址。
  2. 修改栈顶指针,将当前 goroutine 的原本中断地址放入堆栈。

细心的你可能会问:内核在跳转信号处理程序之前不是已经拓展了堆栈,往里面塞了一个frame么?go runtime 在这里基于原始的栈顶再往里塞一个返回地址,不会引起冲突么?

确实不会引起冲突,因为在 X86-64 调用规范中有一个重要标准——红色区域(Red zone)。它指出:在 rsp 指向的栈顶之后的128 字节被保留,不能被信号和中断处理程序使用,因此我们可以在 Linux 的源码中看到如下处理:

1
2
3
4
5
6
struct rt_sigframe __user *frame;
......
frame = (struct rt_sigframe __user *)
round_down(stack_top - sizeof(struct rt_sigframe), 16);
/* Subtract 128 for a red zone and 8 for proper alignment */
frame = (struct rt_sigframe __user *) ((unsigned long) frame - 128 - 8);

内核在构造这个frame的时候留出了 128 字节的空隙,go runtime 见缝插针,将当前 goroutine 被中断时的下一条指令地址放入堆栈。这一套移花接木的功夫打完,信号处理函数执行完毕返回内核态,内核重新恢复原内核态堆栈上的内容,此时的内容是被 go runtime 修改后的。之后,执行流从内核态返回用户态,内核态堆栈被弹出,相关寄存器被恢复,程序计数器指向asyncPreempt,开始运行用户态代码。下面是asyncPreempt的汇编代码,我省略了大部分寄存器的保存和恢复指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
PUSHQ BP
MOVQ SP, BP
// Save flags before clobbering them
PUSHFQ
// obj doesn't understand ADD/SUB on SP, but does understand ADJSP
ADJSP $368
// But vet doesn't know ADJSP, so suppress vet stack checking
NOP SP
MOVQ AX, 0(SP)
......
MOVUPS X15, 352(SP)
CALL ·asyncPreempt2(SB)
MOVUPS 352(SP), X15
MOVUPS 336(SP), X14
......
ADJSP $-368
POPFQ
POPQ BP
RET

asyncPreempt保存执行现场后,调用了asyncPreempt2,这里要提一下,CALL ·asyncPreempt2(SB) 指令先将下一条指令的地址入栈再进行跳转,这样栈顶的地址就是asyncPreempt2返回时的地址。

asyncPreempt2会调用mcall函数,最终会执行调度函数schedule(),还记得吗?schedule()不会返回,执行完runtime·gogo(buf *gobuf)后,新的 goroutine 就在 CPU 上运行了,所以我们要看一下mcall函数保存的现场:

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
// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall<ABIInternal>(SB), NOSPLIT, $0-8
MOVQ AX, DX // DX = fn

// save state in g->sched
MOVQ 0(SP), BX // caller's PC
MOVQ BX, (g_sched+gobuf_pc)(R14)
LEAQ fn+0(FP), BX // caller's SP
MOVQ BX, (g_sched+gobuf_sp)(R14)
MOVQ BP, (g_sched+gobuf_bp)(R14)

// switch to m->g0 & its stack, call fn
MOVQ g_m(R14), BX
MOVQ m_g0(BX), SI // SI = g.m.g0
CMPQ SI, R14 // if g == m->g0 call badmcall
JNE goodm
JMP runtime·badmcall(SB)
goodm:
MOVQ R14, AX // AX (and arg 0) = g
MOVQ SI, R14 // g = g.m.g0
get_tls(CX) // Set G in TLS
MOVQ R14, g(CX)
MOVQ (g_sched+gobuf_sp)(R14), SP // sp = g0.sched.sp
PUSHQ AX // open up space for fn's arg spill slot
MOVQ 0(DX), R12
CALL R12 // fn(g)
POPQ AX
JMP runtime·badmcall2(SB)
RET

这两句是保存程序计数器的值到g->sched:

1
2
MOVQ	0(SP), BX	// caller's PC
MOVQ BX, (g_sched+gobuf_pc)(R14)

可见,保存的地址是栈顶的内容,而此时栈顶内容是函数asyncPreemptCALL ·asyncPreempt2(SB)的下一条指令,即MOVUPS 352(SP), X15;也就是说,当该 goroutine 再次被调度时,会从asyncPreempt2中继续执行,然后返回到asyncPreemptasyncPreempt返回时从堆栈弹出将要跳转的地址,而这个地址就是 go 见缝插针塞进来的地址,这看起来就像是 goroutine 调用asyncPreempt一般。执行流走到这里,才真正意义上完成了 goroutine 的恢复执行。

由此观之,asyncPreempt更像是 Linux 内核中的schedule()调度发生在函数执行过程中,而函数执行完毕要等到下一次被调度的时候才会发生。 而 go 借助信号机制所实现的抢占,无非就是依靠信号处理程序这一次控制权埋点,以便在执行流最终从内核态返回时执行asyncPreempt代码,从而再一次收获 CPU 的控制权。

是时候再从指令的视角在宏观上来理解 go 的信号抢占流程了:

图 8-1 go 异步抢占

图 8-1 显示了通过异步抢占执行流从 g1 切换到 g2 的过程:

  1. g1被时钟中断,从内核返回时发现有抢占信号。
  2. 执行流从内核态返回到用户态,执行信号处理程序(第一个绿色的 go runtime 执行流)。
  3. 信号处理程序执行完毕返回内核,内核做一些恢复后,再次返回到用户态。
  4. 从内核态返回后的执行流被 go runtime 窃取,转而执行调度(第二个绿色的 go runtime 执行流)。
  5. g2 被选择,换上 CPU 执行。

9

文章写到这里,基本上把我想讲的已经讲完了,也算基本上完成了自己“观其大略,本其脉络”的目标,我曾在《从CPU的视角说起》 一文中说道:我目前所寻求的信息,意在建立计算机系统的世界观与 Go 语言的世界观,是在陷入具体细节之前为自己提供一个大致的轮廓,让自己对计算机运行的脉络有一个关键性的认识。 即便如此,里面也不可避免的出现很多具体而微的内容。我略过了很多环节,并不是因为它们不重要,是因为它们是细节,是更丰富的东西,也是我尚未探索的东西。

苏轼有自己的一套读书方法叫做“八面受敌”,他在写给侄女婿王庠的《又答王庠书》中作了详细介绍:“但卑意欲少年为学者,每一书皆作数过尽之。书富如入海,百货皆有,人之精力,不能兼收尽取,但得其所欲求者尔。故愿学者每次作一意求之。如欲求古今兴亡治乱、圣贤作用、但作此意求之,勿生余念。又别作一次,求事迹故实典章文物之类,亦如之。他皆仿此。此虽迂钝,而他日学成,八面受敌,与涉猎者不可同日而语也。”

这段话可谓深得我心,“人之精力,不能兼收尽取,但得其所欲求者尔”、“故愿学者每次作一意求之”,我欲寻求堆栈的本源,翻尽家中藏书,完成了《学渣三部曲》,又执着于调度,翻阅同样的书,才有了这一篇万字长文,此虽迂钝,而他日学成,八面受敌,与涉猎者不可同日而语也。

最后再聊一聊“程序”,第4节曾讨论过“程序”一词的含义,平时我们习惯将开发最终交付的制品称为“程序”,或有“我写了某某程序”之类云云,“程序”若是躺在计算机硬盘中的二进制文件,那当它被调入内存醒来时,一定会哀叹自己生命的不完整,因为每当它想“耳听之而为声,目遇之而成色”的时候,面前总会横着一道“系统调用”的鸿沟,以至于它永远无法亲自触摸“大自然”的无尽藏。换句话说,我们到底创造了什么?一条流动的进程中,有多少是属于我们这样平凡之人的?刨去内核指令,刨去运行时、库以及不可胜数的框架代码,我相信已所剩无几!

我之所以无法骄傲,是因为站在巨人的肩上!

参考文献

  1. 操作系统导论
  2. 深入理解计算机系统
  3. 深入理解LINUX内核
  4. 深入 Linux 内核架构
  5. 操作系统概念
  6. The Definitive Guide to Linux System Calls
  7. 深入golang runtime的调度
  8. 从源码剖析Go语言基于信号抢占式调度
  9. Misc on Linux fork, switch_to, and scheduling
  10. Evolution of the x86 context switch in Linux
  11. Go 语言原本
  12. Scheduling In Go : Part II - Go Scheduler

通过 从CPU的视角说起穿越虚拟内存的迷雾 两篇文章我们知道,所谓进程堆栈不过是应用程序向内核申请了一块连续内存后,设定相应的寄存器,从而将这块内存当做堆栈来使用,典型的用法就是用于函数调用。

我们在上一篇讨论了进线程的堆栈,现在继续探索 go 中的协程栈。如果吊一下书袋的话,口称 go 协程是不严谨的,go 的协程不同于其他语言的协程,go 的协程是一种有栈协程,每一个协程都有自己的协程堆栈,因此 go 官网发明了一个新词 goroutine,以区别于普通的 coroutine。我们接下来就聊聊 goroutine 的堆栈。在此之前,先来回顾一下上一篇中对进线程堆栈位置的总结。

本文基于 Linux 平台 x64 架构,使用 go 1.18 源码,禁用 cgo

1· 进线程堆栈

图 3-1 位于不同区域的线程 stack

图 3.1 为 64 位虚拟地址空间布局图,粉色标识说明了线程堆栈可能存在的位置,总结下来,不外乎以下三种情况:

  1. 主线程堆栈位于用户空间顶部,但 clone 时,子进程的主线程实际使用的堆栈未必如此。
  2. 有可能分配在 mmap 区域。
  3. 有可能通过 C 库 malloc 分配在 heap 区域。

2. goroutine 的堆栈

或许你已经知道 goroutine 的堆栈是从 heap 上分配的,但如果你足够好奇,你就会为 heap 在虚拟地址空间中的位置而发狂。

go 重写了运行时,如果不使用 cgo 的话,编译完成的 go 程序是静态链接的,不依赖任何C库,这使它拥有不错的可移植性,在较新内核上编译好的程序,拉到旧版本内核的操作系统上依然能够运行。在这一点上,rust 并没有多少优势,反而新生语言 hare 表现足够强劲。

不依赖 C 库,意味着 go 对 heap 的管理有自己的方式。 那么, go 管理的 heap 是否与之前内存空间布局图中的 heap 位置相同就要打一个大大的问号了。要搞清楚这个问题,我们需要到 runtime 的源码中一探究竟,且要挖到 go 与内核的接口处,找出其申请内存的方式方可。

本文并不打算分析 go 的内存分配器,也不打算介绍堆栈的分配算法,仅仅为了解决 goroutine 堆栈在虚拟地址空间中位置的疑惑。想了解内存管理和堆栈分配算法的读者可以参考详解Go中内存分配源码实现一文教你搞懂 Go 中栈操作

先从普通 goroutine 的创建开始吧!

在 go 中,每通过go func(){}的方式开启一个 goroutine 时,编译器都会将其转换成对 runtime.newproc的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
// 切换到线程堆栈创建 g
systemstack(func() {
newg := newproc1(fn, gp, pc)

_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)

if mainStarted {
wakep()
}
})
}

newproc 仅仅是对 newproc1 的包装,创建新 g 的动作不能在用户堆栈上进行,所以这里切换到底层线程的堆栈来执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Create a new g in state _Grunnable, starting at fn. callerpc is the
// address of the go statement that created this. The caller is responsible
// for adding the new g to the scheduler.
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
_g_ := getg()

if fn == nil {
_g_.m.throwing = -1 // do not dump full stacks
throw("go of nil func value")
}
acquirem() // disable preemption because it can be holding p in a local var

_p_ := _g_.m.p.ptr()
// 从 P 的空闲链表中获取一个新的 G
newg := gfget(_p_)
// 获取不到则调用 malg 进行创建
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
......
}

newproc1 方法很长,里面主要是获取 G ,然后对获取到的 G 做一些初始化的工作。当创建 G 时,会先从缓存的空闲链表中获取,如果没有空闲的 G ,再进行创建。所以,我们这里只看 malg 函数的调用。

在调用 malg 函数的时候会传入一个最小堆栈大小值:**_StackMin**(linux 平台下为 2048)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Allocate a new g, with a stack big enough for stacksize bytes.
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(_StackSystem + stacksize)
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
// Clear the bottom word of the stack. We record g
// there on gsignal stack during VDSO on ARM and ARM64.
*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
}
return newg
}

malg 会创建新的 G 并为其设置好堆栈,以及堆栈的边界,以供日后扩容使用。这里重点看 stackalloc 函数,堆栈的内存的分配就是由它来完成的,函数的返回值赋给新 Gstack 字段。

Gstack 字段是一个 stack 结构体类型,里面标记了堆栈的高地址和低地址:

1
2
3
4
5
6
7
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}

我们接着看这个 stack 是怎么创建出来的。

stackalloc 的函数比较长,里面涉及到大堆栈和小堆栈的分配逻辑,这里就不贴大段的代码了。这个函数不管是从 cache 还是 pool 中获取内存,最终都会在内存不够时调用 mheapallocManual函数去分配新的内存:

1
mheap_.allocManual(_StackCacheSize>>_PageShift, spanAllocStack)

到这里就遇见 go 管理的 heap 了,关于 heap 的位置我们稍后再讨论,现在继续挖 allocManual 直到我们找到系统调用为止。

1
2
3
4
5
6
func (h *mheap) allocManual(npages uintptr, typ spanAllocType) *mspan {
if !typ.manual() {
throw("manual span allocation called with non-manually-managed type")
}
return h.allocSpan(npages, typ, 0)
}

allocManual 只是对 allocSpan 的简单封装,这里简单提一下 go 对内存管理的最小单位是 mspan,它包含若干连续的页。

allocSpan 的逻辑较多,主要是从 heap 中分配 npages 个页来填充 span。一般随着程序的运行,内存的不断申请,heap 中会有很多空闲的页用来供给后续的内存申请。现在我们需要查看 cache 不足的情况,当 heap 中的 page 不够的时候,就需要推动 heap 增长了,allocSpan 通过调用 mheap.grow 来达成这一点。

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
75
76
77
78
// Try to add at least npage pages of memory to the heap,
// returning how much the heap grew by and whether it worked.
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
assertLockHeld(&h.lock)
ask := alignUp(npage, pallocChunkPages) * pageSize
totalGrowth := uintptr(0)
// This may overflow because ask could be very large
// and is otherwise unrelated to h.curArena.base.
// curArena 无需初始化,但问题是怎么判断 Arena 边界呢
end := h.curArena.base + ask
nBase := alignUp(end, physPageSize)
if nBase > h.curArena.end || /* overflow */ end < h.curArena.base {
// 尝试分配新的 Arena,但有可能跨越 hint 区域,所以全额申请
// Not enough room in the current arena. Allocate more
// arena space. This may not be contiguous with the
// current arena, so we have to request the full ask.
av, asize := h.sysAlloc(ask)
// 此时已经将需要的内存 reserve 了
if av == nil {
print("runtime: out of memory: cannot allocate ", ask, "-byte block (", memstats.heap_sys, " in use)\n")
return 0, false
}

if uintptr(av) == h.curArena.end {
// 说明是连续的,拓展此 curArena 的边界
// The new space is contiguous with the old
// space, so just extend the current space.
h.curArena.end = uintptr(av) + asize
} else {
// 感觉像是这一次不够分配的,但也别浪费,把剩余的内存标记为已使用,加入到一个地方以供分配
// The new space is discontiguous. Track what
// remains of the current space and switch to
// the new space. This should be rare.
if size := h.curArena.end - h.curArena.base; size != 0 {
// Transition this space from Reserved to Prepared and mark it
// as released since we'll be able to start using it after updating
// the page allocator and releasing the lock at any time.
sysMap(unsafe.Pointer(h.curArena.base), size, &memstats.heap_sys)
// Update stats.
atomic.Xadd64(&memstats.heap_released, int64(size))
stats := memstats.heapStats.acquire()
atomic.Xaddint64(&stats.releagrowsed, int64(size))
memstats.heapStats.release()
// Update the page allocator's structures to make this
// space ready for allocation.
h.pages.grow(h.curArena.base, size)
totalGrowth += size
}
// Switch to the new space.
// 把 curArena 切换到新的地址
h.curArena.base = uintptr(av)
h.curArena.end = uintptr(av) + asize
}

// Recalculate nBase.
// We know this won't overflow, because sysAlloc returned
// a valid region starting at h.curArena.base which is at
// least ask bytes in size.
nBase = alignUp(h.curArena.base+ask, physPageSize)
}

// 更新 base
// Grow into the current arena.
v := h.curArena.base
h.curArena.base = nBase

// 把分配的那块内存标记为 Prepared
// Transition the space we're going to use from Reserved to Prepared.
sysMap(unsafe.Pointer(v), nBase-v, &memstats.heap_sys)

// ...... 省略部分代码

// Update the page allocator's structures to make this
// space ready for allocation.
h.pages.grow(v, nBase-v)
totalGrowth += nBase - v
return totalGrowth, true
}

curArena的空闲内存(内核返回的内存空间往往会比请求的多一些)不足以满足分配时,调用mheap.sysAlloc来申请更多的空间。

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
75
76
77
78
79
80
81
82
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
assertLockHeld(&h.lock)

n = alignUp(n, heapArenaBytes)

// First, try the arena pre-reservation.
v = h.arena.alloc(n, heapArenaBytes, &memstats.heap_sys)
if v != nil {
size = n
goto mapped
}

// Try to grow the heap at a hint address.
for h.arenaHints != nil {
hint := h.arenaHints
p := hint.addr
if hint.down {
p -= n
}
if p+n < p {
// We can't use this, so don't ask.
v = nil
} else if arenaIndex(p+n-1) >= 1<<arenaBits {
// Outside addressable heap. Can't use.
v = nil
} else {
v = sysReserve(unsafe.Pointer(p), n)
}
// 如果不相等,则说明 mmap 在建议的地址上没能分配成功
if p == uintptr(v) {
// Success. Update the hint.
if !hint.down {
p += n
}
// 成功后,hint 的地址也跟着更新
hint.addr = p
size = n
break
}
// 此时,丢弃这次分配的内存,尝试下一个 arenaHints, 也就是下一个 1T 区间
// Failed. Discard this hint and try the next.
//
// TODO: This would be cleaner if sysReserve could be
// told to only return the requested address. In
// particular, this is already how Windows behaves, so
// it would simplify things there.
if v != nil {
sysFree(v, n, nil)
}
h.arenaHints = hint.next
h.arenaHintAlloc.free(unsafe.Pointer(hint))
}

if size == 0 {
if raceenabled {
// The race detector assumes the heap lives in
// [0x00c000000000, 0x00e000000000), but we
// just ran out of hints in this region. Give
// a nice failure.
throw("too many address space collisions for -race mode")
}

// All of the hints failed, so we'll take any
// (sufficiently aligned) address the kernel will give
// us.
// 所有的 hint 都失败了,然后让内核自动分配一个定量内存
v, size = sysReserveAligned(nil, n, heapArenaBytes)
if v == nil {
return nil, 0
}

// Create new hints for extending this region.
hint := (*arenaHint)(h.arenaHintAlloc.alloc())
hint.addr, hint.down = uintptr(v), true
hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
hint = (*arenaHint)(h.arenaHintAlloc.alloc())
hint.addr = uintptr(v) + size
hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
}
// ......省略大段代码
return
}

这里真正申请内存的操作是 sysReserve,让我们来一睹究竟:

1
2
3
4
5
6
7
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
return nil
}
return p
}

熟悉的 mmap 映入眼帘!我们已经抵达了内核的大门,查看其定义发现,它包裹了一个sysMmap函数,该函数就是发起mmap系统调用的所在,它是由汇编语言写成,Linux 下函数体位于 sys_linux_amd64.s 中:

1
2
// sysMmap calls the mmap system call. It is implemented in assembly.
func sysMmap(addr unsafe.Pointer, n uintptr, prot, flags, fd int32, off uint32) (p unsafe.Pointer, err int)

mmap调用中的 flag _PROT_NONE, _MAP_ANON|_MAP_PRIVATE表示申请的内存块是无文件背景的匿名映射,这里在调用时传入了一个提示地址,用于告知内核尽量从要求的地址开始分配。

内核当然不能保证这一点,但 go 也足够倔强,如果不能保证连续增长,就另找一段空间开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 如果不相等,则说明 mmap 在建议的地址上没能分配成功
if p == uintptr(v) {
// Success. Update the hint.
if !hint.down {
p += n
}
// 成功后,hint 的地址也跟着更新
hint.addr = p
size = n
break
}
// 此时,丢弃这次分配的内存,尝试下一个 arenaHints, 也就是下一个 1T 区间
// Failed. Discard this hint and try the next.
//
// TODO: This would be cleaner if sysReserve could be
// told to only return the requested address. In
// particular, this is already how Windows behaves, so
// it would simplify things there.
if v != nil {
sysFree(v, n, nil)
}
h.arenaHints = hint.next
h.arenaHintAlloc.free(unsafe.Pointer(hint))

sysAlloc 返回之后,就意味着已经从内核申请到了一块空间。回到 mheap.grow的代码,会看到调用了 sysMap 再次向内核申请内存,sysMap 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {
sysStat.add(int64(n))

p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
if err == _ENOMEM {
throw("runtime: out of memory")
}
if p != v || err != 0 {
print("runtime: mmap(", v, ", ", n, ") returned ", p, ", ", err, "\n")
throw("runtime: cannot map pages in arena address space")
}
}

可见,也是一个mmap系统调用,但传入的 flag 不同,多了一个 _MAP_FIXED

查看 mmap 的手册便会明白,在不提供_MAP_FIXED 的情况下,内核会尽量从给出的地址分配空间,但避免冲突是第一位的,所以结果并不总能如意。而_MAP_FIXED保证了这一点,即使在请求的地址处已有其它映射的情况下也会覆盖之前的映射。

mmap 文档中也对 _MAP_FIXED 使用提出了警示,而 go 在这里使用是完全没有问题的,因为事先已经向内核申请了该块内存了,在里面隔上一刀根本不需要睁眼。

我们拿到了一块连续的内存,是时候从 allocSpan 返回了,如此 stackalloc 就为新 G 申请到了一块连续内存用作堆栈。

从 goroutine 的新建一直到内核的大门,我们发现了用于申请内存的方式是 mmap,但mmap从进程虚拟地址空间的哪个位置分配内存呢?runtime 源码中给与的提示地址又是从何而来呢?

3. mmap 申请内存的位置

mmap 既是一个系统调用,也是进程虚拟地址空间中的一个区域,让我再次援引《深入 Linux 内核架构》中的一幅图:

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

书中介绍了 2.6 版本的内核内存布局,其中 mmap 区域是和 heap 相对增长的,内核会留出足够的空间给主线程 stack,这样便可最大化的利用内存空间,好在 stack 通常不会很大。

但是 mmap 并非只能在概念上划出的区域进行分配,它甚至可以在用户空间内任意地方分配内存,这当然也包括传统的 heap 区域!还记得 _MAP_FIXED 吧?我打赌它绝对能让你的程序 crash 掉!

heap 是用来为进程动态分配内存的,传统的定义是:堆是一段长度可变的连续虚拟内存,始于进程的未初始化数据段的末尾,随着内存的分配和释放而增减

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

改变 heap 大小的系统调用是 brksbrk ,而 go 主要使用 mmap 来维护堆,这就说明 go 堆和传统的堆位置是不同的。位置虽然不同,但使命毫无二致,让我们来看一个 go 程序的内存布局:

00400000-004bd000 r-xp 00000000 103:02 8916313      playground/helloworld/hello/hello
004bd000-00574000 r--p 000bd000 103:02 8916313      playground/helloworld/hello/hello
00574000-0058f000 rw-p 00174000 103:02 8916313      playground/helloworld/hello/hello
0058f000-005c4000 rw-p 00000000 00:00 0 
c000000000-c000200000 rw-p 00000000 00:00 0 
c000200000-c017e00000 rw-p 00000000 00:00 0 
c017e00000-c018000000 rw-p 00000000 00:00 0 
c018000000-c018400000 rw-p 00000000 00:00 0 
c018400000-c01c000000 ---p 00000000 00:00 0 
7fef44906000-7fef449ba000 rw-p 00000000 00:00 0 
7fef449d2000-7fef47c19000 rw-p 00000000 00:00 0 
7fef47c19000-7fef57d99000 ---p 00000000 00:00 0 
7fef57d99000-7fef57d9a000 rw-p 00000000 00:00 0 
7fef57d9a000-7fef69c49000 ---p 00000000 00:00 0 
7fef69c49000-7fef69c4a000 rw-p 00000000 00:00 0 
7fef69c4a000-7fef6c01f000 ---p 00000000 00:00 0 
7fef6c01f000-7fef6c020000 rw-p 00000000 00:00 0 
7fef6c020000-7fef6c499000 ---p 00000000 00:00 0 
7fef6c499000-7fef6c49a000 rw-p 00000000 00:00 0 
7fef6c49a000-7fef6c519000 ---p 00000000 00:00 0 
7fef6c519000-7fef6c579000 rw-p 00000000 00:00 0 
7ffc335d5000-7ffc335f7000 rw-p 00000000 00:00 0                          [stack]
7ffc335f8000-7ffc335fc000 r--p 00000000 00:00 0                          [vvar]
7ffc335fc000-7ffc335fe000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

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

除了代码段不足 2M 的区域之外,似乎 c000000000 最值得怀疑,而且这份映射当中没有看到 heap 身影,这直接印证了上述猜想。关于 c000000000 我们要去源码中寻找答案,且看内存分配器的初始化:

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
func mallocinit() {
// ...... 省略部分代码

// 只看 64 位系统的初始化部分
// Create initial arena growth hints.
if goarch.PtrSize == 8 {
// On a 64-bit machine, we pick the following hints
// because:
//
// 1. Starting from the middle of the address space
// makes it easier to grow out a contiguous range
// without running in to some other mapping.
//
// 2. This makes Go heap addresses more easily
// recognizable when debugging.
//
// 3. Stack scanning in gccgo is still conservative,
// so it's important that addresses be distinguishable
// from other data.
//
// Starting at 0x00c0 means that the valid memory addresses
// will begin 0x00c0, 0x00c1, ...
// In little-endian, that's c0 00, c1 00, ... None of those are valid
// UTF-8 sequences, and they are otherwise as far away from
// ff (likely a common byte) as possible. If that fails, we try other 0xXXc0
// addresses. An earlier attempt to use 0x11f8 caused out of memory errors
// on OS X during thread allocations. 0x00c0 causes conflicts with
// AddressSanitizer which reserves all memory up to 0x0100.
// These choices reduce the odds of a conservative garbage collector
// not collecting memory because some non-pointer block of memory
// had a bit pattern that matched a memory address.
//
// However, on arm64, we ignore all this advice above and slam the
// allocation at 0x40 << 32 because when using 4k pages with 3-level
// translation buffers, the user address space is limited to 39 bits
// On ios/arm64, the address space is even smaller.
//
// On AIX, mmaps starts at 0x0A00000000000000 for 64-bit.
// processes.
for i := 0x7f; i >= 0; i-- {
var p uintptr
switch {
case raceenabled:
// The TSAN runtime requires the heap
// to be in the range [0x00c000000000,
// 0x00e000000000).
p = uintptr(i)<<32 | uintptrMask&(0x00c0<<32)
if p >= uintptrMask&0x00e000000000 {
continue
}
case GOARCH == "arm64" && GOOS == "ios":
p = uintptr(i)<<40 | uintptrMask&(0x0013<<28)
case GOARCH == "arm64":
p = uintptr(i)<<40 | uintptrMask&(0x0040<<32)
case GOOS == "aix":
if i == 0 {
// We don't use addresses directly after 0x0A00000000000000
// to avoid collisions with others mmaps done by non-go programs.
continue
}
p = uintptr(i)<<40 | uintptrMask&(0xa0<<52)
default:
p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
}
hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
hint.addr = p
hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
}
}
}

注释部分第一条便说:从地址空间的中间开始向上增长,很容易获得连续的区域,且不会和其它映射部位发生碰撞。

因此 go 选择了从 0x00c0开始,并且用一个 for 循环生成了 128 个提示地址,组成链表初始化到 mheap_.arenaHints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0x7fc000000000
......
0x10c000000000
0x0fc000000000
0x0ec000000000
0x0dc000000000
0x0cc000000000
0x0bc000000000
0x0ac000000000
0x09c000000000
0x08c000000000
0x07c000000000
0x06c000000000
0x05c000000000
0x04c000000000
0x03c000000000
0x02c000000000
0x01c000000000
0x00c000000000

这 128 个起始地址除了最后一个之外,其余皆可向上增长 1TiB 的空间,最后一个距离用户空间顶部仅剩 256 GiB。

0x00c000000000 距离用户空间的开始有 765 GiB,这也是为什么不会和其它映射部位发生碰撞的原因!

mallocinit 初始化了mheap_.arenaHints,还记得 mheap 为增加 heap 而申请内存时的方法吗?

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
// Try to grow the heap at a hint address.
for h.arenaHints != nil {
hint := h.arenaHints
p := hint.addr
if hint.down {
p -= n
}
if p+n < p {
// We can't use this, so don't ask.
v = nil
} else if arenaIndex(p+n-1) >= 1<<arenaBits {
// Outside addressable heap. Can't use.
v = nil
} else {
v = sysReserve(unsafe.Pointer(p), n)
}
// 如果不相等,则说明 mmap 在建议的地址上没能分配成功
if p == uintptr(v) {
// Success. Update the hint.
if !hint.down {
p += n
}
// 成功后,hint 的地址也跟着更新
hint.addr = p
size = n
break
}
// 此时,丢弃这次分配的内存,尝试下一个 arenaHints, 也就是下一个 1T 区间
// Failed. Discard this hint and try the next.
//
// TODO: This would be cleaner if sysReserve could be
// told to only return the requested address. In
// particular, this is already how Windows behaves, so
// it would simplify things there.
if v != nil {
sysFree(v, n, nil)
}
h.arenaHints = hint.next
h.arenaHintAlloc.free(unsafe.Pointer(hint))
}

mmap 的调用都是围绕着 arenaHints 来进行的,并且每次申请成功后都会更新 hint 的 addr,这样就实现了连续增长,直到失败。如果失败了,就从下一个 1TiB 的区间再次开始!

4. g0 堆栈

看过了普通 goroutine 堆栈的分配之后,再来简要说一下 g0 的堆栈。g0 是个比较特殊的 goroutine 它只是协助 runtime 来执行,但不承载任何执行函数,与普通的用户 goroutine 有所区别。在一定程度上,可以把它类比成操作系统上每个线程的内核栈,每当 runtime 获得控制权的时候就会将堆栈切换到 g0 代表的堆栈上。

go 的 GPM 模型此处不作介绍,建议阅读Scheduling In Go : Part II - Go Scheduler 来了解并发模型。我们只说其中的 M,每个M 都有一个 g0 堆栈,用于执行 runtime 代码,其中较为特殊的 M0 (即 go 进程的主线程,每个 go 程序仅有一个 M0)的 g0 堆栈是通过汇编语言进行初始化的。

我们先来看看 go 程序的入口地址:

richard@Richard-Manjaro:~ » readelf -h carefree 
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x463f20
  Start of program headers:          64 (bytes into file)
  Start of section headers:          456 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         7
  Size of section headers:           64 (bytes)
  Number of section headers:         23
  Section header string table index: 3

读取 ELF文件头可知,入口地址为0x463f20,因为禁用了 cgo,没有动态链接库,所以 Entry point 指示的地址既是程序的入口地址。继续看一下该地址指示的代码:

richard@Richard-Manjaro:~ » lldb ./carefree 
(lldb) target create "./carefree"
Current executable set to '/home/richard/carefree' (x86_64).
(lldb) image lookup --address 0x463f20
      Address: carefree[0x0000000000463f20] (carefree.PT_LOAD[0]..text + 405280)
      Summary: carefree`_rt0_amd64_linux
(lldb) 

_rt0_amd64_linux 即为程序的入口,当运行程序时,shell 会 fork 一个子进程出来,之后执行 execve() 系统调用来装载 go 的可执行文件,当内核装载完毕之后,会将 CPU 的程序计数器设置为此入口点,之后 go 程序开始执行。

_rt0_amd64_linux 是对 asm_amd64.sruntime·rt0_go 的调用,看一下runtime·rt0_go 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
SUBQ $(5*8), SP // 3args 2auto
ANDQ $~15, SP
MOVQ AX, 24(SP)
MOVQ BX, 32(SP)

// create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
// 初始化 g0
MOVQ $runtime·g0(SB), DI
LEAQ (-64*1024+104)(SP), BX
MOVQ BX, g_stackguard0(DI)
MOVQ BX, g_stackguard1(DI)
MOVQ BX, (g_stack+stack_lo)(DI)
MOVQ SP, (g_stack+stack_hi)(DI)

这段代码设置 g0 堆栈的方式是使用线程堆栈的栈顶指针减少 64KB + 104B 作为 g0 堆栈的低端,当前线程堆栈的栈顶为 g0 堆栈的高端。执行完成后,g0 的堆栈便被初始化为 64KB 了。令人惊讶的是,这居然是在系统线程的 8M 堆栈(Linux 的默认线程堆栈为 8 M)中分配的。

再来看一下其它新建 Mg0,go 通过 runtime.newm 来新建操作系统线程,顺藤摸瓜会发现其最终执行的系统调用为 clone:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
/*
* note: strace gets confused if we use CLONE_PTRACE here.
*/
if false {
print("newosproc stk=", stk, " m=", mp, " g=", mp.g0, " clone=", abi.FuncPCABI0(clone), " id=", mp.id, " ostk=", &mp, "\n")mp.g0.stack.hi
}

// Disable signals during clone, so that the new thread starts
// with signals disabled. It will enable them in minit.
var oset sigset
sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
sigprocmask(_SIG_SETMASK, &oset, nil)

if ret < 0 {
print("runtime: failed to create new OS thread (have ", mcount(), " already; errno=", -ret, ")\n")
if ret == -_EAGAIN {
println("runtime: may need to increase max user processes (ulimit -u)")
}
throw("newosproc")
}
}

clone 中堆栈起始地址传入的是 mp.g0.stack.hi,即该 Mg0 的堆栈高端地址,看一下 g0 的初始化,相应的代码在 runtime.allocm 中:

1
2
3
4
5
if iscgo || mStackIsSystemAllocated() {
mp.g0 = malg(-1)
} else {
mp.g0 = malg(8192 * sys.StackGuardMultiplier)
}

可见后续 g0 分配就是通过 malg 来进行的,该函数我们之前已经介绍过了,此处只要明白分配的堆栈大小为 8K 即可。由此可知,除了 m0g0 在传统的主线程堆栈区域外,后续 M 的堆栈都是分配自 go 堆中,其可能的区域自不待言,我们已在上一节论述过了。

5. goroutine 的堆栈切换

当 goroutine 被 runtime 调度到 CPU 上时,不仅要将程序计数器设置为该 goroutine 的执行函数地址,而且要切换到该 goroutine 的堆栈上执行后续操作,我们这一节就来看看 goroutine 的堆栈是如何切换的。堆栈的切换和调度密切相关,但此处只讨论和堆栈有关的内容,不再深入调度相关的细节。

m0 在初始化好一系列条件之后,会调用 runtime·mstart 从而真正的让 M0 跑起来,后续新建 M 时向 clone 传入的运行函数也是 runtime·mstart,而 runtime·mstart 最终会进入调度函数 runtime.schedule, 而 schedule 的工作就是千方百计的寻找空闲的 G 将它送到 CPU 上运行。当最终找到这个 G 的时候,会调用一段用汇编代码写成的函数 runtime·gogo(buf *gobuf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX // make sure g != nil
JMP gogo<>(SB)

TEXT gogo<>(SB), NOSPLIT, $0
get_tls(CX)
MOVQ DX, g(CX)
MOVQ DX, R14 // set the g register
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
MOVQ gobuf_pc(BX), BX
JMP BX

runtime·gogo 会调用 gogo,传入的参数是 g 结构体中和调度相关的一个字段 gobuf:

1
2
3
4
5
6
7
8
9
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}

其中有程序计数器和堆栈栈顶指针等重要的值,这些值都是该 goroutine 被调度出 CPU 的时候保存进来的,是 goroutine 的执行现场。gogo 会将现场恢复,这包括程序计数器和栈顶,之后这个 goroutine 就又从上次中断的地方跑起来了。

6. 总结

本文以探求 goroutine 堆栈在进程虚拟地址空间中的位置为诉求,对源代码进行有目的的展开,并最终找到内存分配的内核接口 mmap

mmap 的使用太过灵活,以至于非要刻板的对应到虚拟内存布局中的位置显得有些棘手,因为 go 堆 接管的是整个虚拟内存的用户空间,但我们仍然可以从其内存分配的设计思想中窥得一二。

go 堆的起始位置在用户空间的中段,确切的说是距离起始端 768 GiB 的地方开始,而从用户空间 128 TiB 的角度来看,这远远算不上中间,仅仅是相对于传统 heap 来说的。我想这也是 go 对于历史的一种尊重,好在 64 位模式下虚拟地址空间的跨度足够大,可以做出很灵活的设计。

go 堆把后续的空间划分成了 128 份,几乎每份都有 1TiB 的大小,然后默默地从地址 0x00c000000000 处向上增长,因为00 c0 既不是有效的 UTF8 编码,又有足够的辨识度。

参考文献

  1. 深入理解计算机系统
  2. 程序员的自我修养
  3. Linux/UNIX系统编程手册
  4. 深入 Linux 内核架构
  5. Linux 系统编程
  6. mmap
  7. 深入golang runtime的调度
  8. go-scheduler
  9. Go 语言原本
  10. 一文教你搞懂 Go 中栈操作
  11. 详解Go中内存分配源码实现
  12. Scheduling In Go : Part II - Go Scheduler

希望能给 Go 后学带来更多有意义的内容

effective go 中文版 是我个人的文档轮子。为了帮助需要的人准确理解原文,特采纳中英双语格式,目前翻译初稿已经完成。

互联网上已有 effective go 的中文版,甚至还不止一版,那为什么还要再造这个轮子呢?首先,我个人的初衷是为了锻炼自己的英文理解与翻译水平,同时也能磨砺中文的语言组织能力,更是为了把 go 语言的基础夯实。

其次呢,我在读第一遍英文时,碰到有疑问的地方在中文版里往往得不到想要的答案,后来自己思考也解决了一些疑惑,有些问题呢是出自翻译的问题,比如最后一章有一句话:The program here provides a nicer interface to one form of data: given a short piece of text, it calls on the chart server to produce a QR code, a matrix of boxes that encode the text.

冒号之后的内容姑且不论,它是对 a nicer interface 的详细阐述。我们看前半句中的 one form of data 到底指指什么?从字面意思看,或者由翻译软件翻译的话意思应该是:一种数据类别。我所见的两个翻译版本分别翻译成了 一种数据格式某种形式的数据,可见就是照字面翻译,只是为了语句通顺问题,各自进行了调整。但即便是进行了调整,整个句子依然说不通,此程序为一种数据格式提供了更好的的接口 其意几何呢?

其实,下文中文档便给出了详细代码,html 的代码中有一个输入表单。我们知道 form 不仅有形式、类别、种类的意思,还具有表格的意思,并且在前端领域我们习惯于术语表单。所以此处分明是一个数据的表单之意!再者说,form 之前使用了数量词 one 而非定冠词 a ,我认为这是另一条佐证,这句话的本意是:本程序为表单数据提供了更加友好的接口

除翻译问题外,春秋笔法,微言大义似乎也能造成困扰。effective go 虽为入门级必读资料,其内容算不上艰深,然而某些细微之处,于新手而言并不见得就能轻易领会,所以我在某些地方也注释了自己的心得。这看起来颇有几分古人注书的感觉,只不过我注的内容未必都对,所注条目也不甚多,但总归是自己花了精力的所思所想,相必对有些人有用也未可知,因此闲暇之余,又翻译了一版,很期待各位能够给予斧正。

因初稿未经校对,其中定有不少讹误疏漏之处,希望所有有兴趣的伙伴都能参与进来,不论是书写还是理解上的问题,都欢迎提交 issue 或者 pr,我定我会及时处理。

  1. 介绍
  2. 格式化
  3. 注释
  4. 命名
  5. 分号
  6. 控制结构
  7. 函数
  8. 数据
  9. 初始化
  10. 方法
  11. 接口和其它类型
  12. 空白标识符
  13. 内嵌
  14. 并发
  15. 错误
  16. 一个 web 服务

go 1.18 于近日发布,带来了 go 历史上最大的一次语言级改变——泛型!但本文只聚焦于本次发布中标准库 bufio 包中的一个小小的改变——Writer.AvailableBuffer。go 每次版本发布都会伴随着标准库的些许变动,本次发布即在 bufio 包中增加了一个 Writer.AvailableBuffer 方法。

该方法的添加源于一条名为 bufio: add Writer.AvailableBuffer 的 issue。作者认为 go 中很多 appendX 类型的 API 在与 bufio.Writer 一起工作的时候比较低效,原因在于 Write(p []byte) (nn int, err error) 方法只接受 []byte ,却不向外提供 []byte。这在一定程度上需要调用者自行分配内存,然后传给 write 使其再进行 copy,所以这里固定有一次内存的 allocationcopy

作者的提议是:由 bufio 的 Writer 向外暴露自己的 buffer 以为 appendx 函数使用:

1
2
3
4
5
6
// AvailableBuffer returns an empty buffer with b.Available capacity.
// This buffer is intended to be appended to and
// passed to an immediately succeeding Write call.
func (b *Writer) AvailableBuffer() []byte {
return b.buf[b.n:][:0]
}

AvailableBuffer 向外暴露了一个空的 []byte, 但是其 capacity 是和 Writer 的 buffer 余量相同的,这意味着返回的 []byteWriter 共享同一个底层数组。那这样做的好处是什么呢? 其好处就是在理想情况下,能够避免那固有的一次 allocationcopy,且看如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"bufio"
"os"
"strconv"
)

func main() {
w := bufio.NewWriter(os.Stdout)
for _, i := range []int64{1, 2, 3, 4} {
b := w.AvailableBuffer()
b = strconv.AppendInt(b, i, 10)
b = append(b, ' ')
w.Write(b)
}
w.Flush()
}

该例循环体内使用的 buffer b 都返回自 AvailableBuffer,此时没有额外的内存分配。理想情况下 append 的 byte 数量不超过 Writer 的 buffer 余量时, copy 会立即返回,因为 bWriter 使用的同一个底层数组,不需要 copy,因此连 copy 的操作都省掉了。

极端情况下,由于 append 的扩张导致 b 的底层数组重新分配,那么 Write 也只是回到了其最初的工作方式。在很大程度上,该调整还是让 Writer 变得高效了不少!

那有什么不好的地方吗? 唯一缺点应该就是暴露了 bufio.Writer 内部的 buffer。然而该包中的其它类型,诸如 ReaderScanner 已经向外提供了 Reader.PeekReader.ReadSlice、以及 Scanner.Bytes 等对底层数组不安全的访问方法,故也不能独怪其罪。至少并没有破坏包的整体风格,而其利弊皆在人之为用!

文中的示例基于 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 中内容的方式并无二致,都是使用的标准的内存寻址方式(注:stack 相对 heap 对 cpu cache 更加友好,从程序运行的空间局部性来讲,CPU Cache 命中率会高一些)。

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. 设计模式之美
0%