CSAPP Notes: ECF & I/O
Chapter 13. Exceptional Control Flow
对应书中第 8 章。
异常控制流是现代计算机系统的一个相当重要的部分。
13.1 Control Flow
控制流:从机器打开到关闭的过程中,处理器只做一件事:读指令、执行指令,一个周期做一个指令。多核的机器则每个核心依次交替执行指令。这些指令序列被称为控制流。硬件正在执行的实际指令序列就被称为物理控制流。
改变内存中控制流的方法:分支 & 跳转,过程调用 & 返回(Branches & Jumps & Procedure call and return);
都是对于程序状态变化的处理。
但以上的简单的改变控制流的方法对于处理复杂的系统级别的状态变化时,就显得非常拙劣。(例如 OS 协同软硬件的通信,如果还是以 if-else 的方法,那将会非常差劲);
什么是 “系统级别的状态变化”?
- 数据从磁盘 / 网卡到达内存中;
- I/O 设备输入 Ctrl+C;
- 系统分时复用的时钟到期了,接下来要打断当前执行的进程;
- 除零指令;
- ……
这些事件不能指望应用程序的开发者来解决(应用程序的开发者只负责开发正常的程序控制流),而这应该是 OS 需要处理的事情。为了高效处理以上在执行程序中出现的或意外、或故意的系统级状态变更的情况,OS 有一套策略:异常控制流(Exception control flow,简称 ECF)来处理上述情况,这样很多事件就无需应用开发者来考虑了。
13.2 Exception Control Flow: Overview
异常控制流的重要特征之一在于,它们会改变系统级别的状态,而且存在于计算机系统的各个层级:
首先是底层级 ECF 的机制:
- Exception(异常,又称 Hardware ECF(硬件异常控制流),和我们平时编程的软件异常处理不是一个概念)
- 作用:响应某些底层系统事件(A System Event)的控制流的变化;
- 实现方法:硬件与操作系统的配合;
再看高级别(既指抽象层面,又指逻辑层面,这意味着下面的机制可能利用到,或者包含了上面的 Exception)的 ECF 的机制:
- Process Context Switch(进程上下文切换)
- 作用:使操作系统在两个进程间无缝切换;
- 实现方法:硬件时钟和操作系统的配合;
- Signals(信号)
- 作用:应用、操作系统、硬件三者之间的异常(不是错误,而是指在上层应用程序正常控制流以外的部分)通信;
- 实现方法:操作系统控制;
- Nonlocal Jumps(非本地跳转)
- 作用:应用程序开发者层面(而非操作系统层面)主动更改程序正常控制流(上面介绍的分支跳转、调用返回),无视正常控制流的规则(例如不需要等到一个函数返回,就跳到另一个函数执行);
- 实现方法:因为是用户级别,所以由 C library 提供:
setjmp()
、longjmp()
;
了解完计算机中各个层面的 4 大类 ECF 机制,我们开始深入探讨各个机制的运作原理。
13.3 Exception Control Flow: Exception
13.3.1 Definitions
一个异常就是为了应对一些(软件 / 硬件的)事件,控制流由程序转移到 OS kernel 的过程。
- 什么是操作系统内核(OS Kernel)?
内核是操作系统在内存中驻留的部分,你可以理解成当前加载到内存的、运行中的操作系统代码;
- Exception 定义中的 “事件” 具体有哪些?
- 除零、算数溢出、page fault(页错误)、I/O 请求完成、键盘设备输入 Ctrl + C;
- ……
13.3.2 Process Procedure
Exception 的处理过程如下:
如图所示,因为以上的事件(event)而改变了系统状态,执行到 $I_{current}$ 的用户代码被立即暂停,此时 exception 将控制权从用户代码转移到内核态代码。这部分内核代码被称为 exception handler(异常处理程序);
接着,内核执行异常处理程序代码来处理这个事件,过程被称为 exception processing(异常处理);
处理结束后,通常有 3 种情况:返回到原先被打断的指令位置($I_{current}$,已执行)、返回到被打断的指令的下一条($I_{next}$,未执行)、终止原用户程序执行。
13.3.3 Implementations of Exception
前面介绍过,Exception 是由 OS 和硬件共同实现的,那么具体实现是什么?
事实上,控制流想要改变,必须依靠硬件改变程序计数器(PC,或者说前面提到的 %rip
)。由于 Exception handler 的代码又位于 Kernel code 中,所以实现就很清楚了:
OS 负责组织 Exception Handler 的代码,来处理可能的 Exception;
硬件负责在 Event 出现的时候改变
%rip
,使控制流转向 Exception Handler;
等等,还有一个问题,OS 会预先编写很多类 Exception Handler 以应对不同 Exception 的情况,那么硬件在 Event 发生时,怎么知道转向哪一个 Exception Handler?所以还有一条、再改正一条:
硬件负责在 Event 出现时按种类改变 Exception Table Base Register,通过这个寄存器取得 Exception Table 中存放的 Exception Handler 的地址(硬件规定是虚拟地址),把取得的地址置于
%rip
中,完成转向;OS 负责在 Kernel 中组织 Exception Table(异常表),告诉硬件何种 event 对应何种 Exception Handler 的地址;
什么是 异常表?
OS 为了将每种 Event 产生的 Exception 与 Exception Handler 对应起来,将每种类型的事件进行编号。每种类型的事件对应位于的 异常编号(Exception Number,又称为中断向量,Interrupt Vector),这个编号被作为一个跳表的索引,而表中装的是各个对应的 Exception Handler 的地址。这个表就称为 异常表。
13.3.4 Types of Exceptions
上面我们提到过,在 Exception 的处理过程中,最后可能会发生 3 种情况($I_{current}$、$I_{next}$、abort),这是因为具体发生的 event 不同,其 Exception Handler 的处理方式也不同。所以,我们有必要了解一下 Exception(或者说对应的 event)有哪些种类,exception handler 的默认行为又有哪些。
Asynchronous Exceptions(异步异常):又称 Interrupt(中断)
- 引发的 Event 的种类:来自处理器外的事件。通常是 I/O 设备发出的;
- 例子:
- I/O 设备中断事件(数据从磁盘、网卡等外部设备已到达内存的通知,键盘 Ctrl+C 等);
- 系统分时复用时钟中断(Timer Interrupt),OS 在硬件时钟中定时,要求从用户程序切换到内核中,以便让 OS 取得控制权(这个是为进程上下文切换提供条件,让系统决定是否要进行进程上下文切换);
- 触发方法 / 系统状态如何改变:电脉冲通知处理器的 中断引脚;
- 触发后默认行为:
- 可能与当前运行程序无关事件,从 $I_{next}$ 继续向下运行(recoverable);
Synchronous Exceptions(同步异常)
- 引发的 Event 的种类:因为处理器执行某条指令而造成的事件;
而同步异常又可以分为几个种类:
Traps(陷阱)
触发方法:执行程序故意触发系统级别 Exception;
例子:system calls(系统调用)、breakpoint traps(程序断点)、特殊指令;
什么是系统调用?
应用程序的某些功能可能需要使用一些硬件设备,例如 I/O 设备。而这些驱动硬件的程序则嵌在 OS Kernel 中。
但另一方面,由于安全问题,人们在系统中划分了若干特权级:Ring 0 ~ Ring 3,数字越大,权限越低。应用程序运行在 Ring 3 级别,因此不能直接调用内核函数、无法访问内核数据(位于 Ring 0 级别),因此无法直接使用 I/O 设备。
那么应用程序为了完成一项使用 I/O 设备的工作,只能调用 OS 准备的专门的接口(称为系统服务)来通知 OS 协助完成。这个过程被称为 系统调用。
系统调用的详细过程?
首先,系统调用就是调用系统接口的过程,咱首先得了解 x86-64 架构下系统调用有哪些接口。以 x86-64 Linux 为例:
每一个系统调用接口都有一个唯一的编号,这个编号由 OS 分配,例如
read
是 0 号;以打开文件这个 I/O 操作为例,应用程序想要将磁盘中的数据读到内存中,那么:
程序先调用 C library 封装好的函数
open/fopen
;C library 的
open/fopen
经过几层包装,接着调用系统接口__open
(上面的 2 号系统调用),其汇编代码如下:1
2
3
4
5
6
700000000000e5d70 <__open>:
...
e5d79: b8 02 00 00 00 mov $0x2,%eax # openis syscall #2
e5d7e: 0f 05 syscall # Return value in %rax
e5d80: 48 3d 01 f0 ff ff cmp $0xfffffffffffff001,%rax
...
e5dfa: c3 retq我们可以看到,系统调用的 calling conventions 与普通函数不一样:
- 第一传入参数必须是 系统调用编号,并且存放在
%rax
中,这里是 2,表示 2 号系统调用; - 其他传入参数依此放在:
%rdi
、%rsi
、%rdx
、%r10
、%r8
、%r9
; - 返回值也放在
%rax
中; - 使用
errno
宏来记录系统调用的状态或错误情况; %rcx
、%r11
可能被破坏:%rcx
存放 $I_{next}$ 地址方便返回,%r11
用于存放rflags
,也即当前程序的 conditional codes;
- 第一传入参数必须是 系统调用编号,并且存放在
当处理器执行到系统调用汇编指令时,触发
Trap
(Exception),相应值传入 Exception Table Base Register;硬件通过该寄存器找到 Exception table 中的相应 Exception handler(x86-64 通常是 software interrupt exception handler),将%rip
地址改为该 handler 的地址,于是控制流转向 OS Kernel;OS Kernel 处理系统调用的 exception handler 会按之前的 conventions 读取各参数值,执行对应系统调用服务。完成后,先切换特权级等信息,再读取
%rcx
和%r11
中的信息,将控制流转交给 user mode 中原程序的 $I_{next}$;最终系统调用成功返回的话,open 返回一个 file descriptor(文件描述符,一个区别已打开文件的小整数编号),以供后续读写调用使用。
触发后默认行为:从 $I_{next}$ 继续向下执行(recoverable);
Faults(错误)
触发方法:程序执行了一条指令,无意引发了硬件或软件层面的问题,从而产生 Exception;
例子:page fault(页错误,possibly recoverable)、protection fault(访问权限错误,unrecoverable),floating point exceptions(浮点异常,例如除零错,unrecoverable);
什么是页错误 和 访问权限错误?
首先计算机中存在两个部分物理内存和虚拟内存。操作系统为了将离散、有限的资源抽象为连续、近乎无限的资源给应用程序使用,并将各个程序隔离开,使用了复杂的策略。
在内存的使用层面,操作系统和硬件配合引入了 “虚拟内存” 的概念,操作系统维护了一个从物理内存(简称 PM)到虚拟内存(简称 VM)的映射(还记得 Memory Hierarchy 最后介绍的 TLB cache 吗?就是为这个准备的),将主存(main memory)上离散的空间映射到连续的虚拟内存空间上。
这个 “映射” 存在处理器芯片的 MMU(Memory Managing Unit) 中,这个映射的数据结构被称为 页表。为了充分利用主存(物理内存)的空间,页表将 PM 和 VM 切分为很小的块(大家能懂往瓶子里装石子、沙子、水的道理吧?),这些很小的数据块被称为 页,而页表就是将这些虚拟内存页映射到物理内存页,如下:
那么这样能同时完成两个目标:
- 充分利用有限物理空间,为应用程序提供连续的虚拟空间;
- 每个应用程序间的虚拟内存很容易实现隔离(页表数据不同就行,这样哪怕相同的虚拟地址,映射到的物理地址也不同),相互不影响对物理内存的访问。
每个应用程序所能看到的就是完整的虚拟内存,其中有独立的运行时栈。运行在 CPU 上的应用程序也直接使用虚拟地址,因为 VMA 出 CPU 前会经过 MMU 转换为物理地址,再向硬件请求。
但是!为了节约空间,操作系统不会一次性将全部的虚拟内存(地址 0 ~ FFFFFFFF)全部用页表映射上物理内存(一来大小不够,二来浪费资源),只是先为虚拟内存的必要部分(例如程序栈的 data 段、code 段、shared libraries 段、stack 段等)分配物理内存、记录在页表上。其余部分被称为未被分配的段。这个有 2 种可能:
- 应用程序没有权限(执行 kernel space 段,或执行了标记为不可执行的栈区),或这里确实不应该有数据;
- 应用程序确实向操作系统申请了大量空间,不过有些还没使用过,OS 自己还没有将这段区域通过页表建立与物理内存的映射。
所以,当程序指令访问上述 2 种段的时候,硬件发现在页表中找不到对应的物理地址,于是发出一个 Synchronous Exception,其类型是 Page Fault。
如果是因为访问了上面第一种情况的 “未分配的段”,那么在进入 Exception Handler 后,操作系统发现程序确实不应该访问这里,那么操作系统向原进程发送 SIGSEGV 信号 / SIGGPF 信号(Segmentation Fault 软件信号,或者 Protection Fault 软件信号,前面介绍过,这些信号是另一种 ECF 机制,下下节讨论),控制流直接离开原程序(abort),属于 unrecoverable 类型;
如果是因为访问了上面第二种情况的 “未分配的段”,那么操作系统发现是自己没分配,于是在 Exception Handler 中,OS 会将一段新的物理地址分配给虚拟内存,记录在页表中,回到 $I_{current}$ 的位置,属于 recoverable 类型;
触发后默认的行为:回到 $I_{current}$(recoverable),或者终止(unrecoverable);
Aborts(终止)
触发方法:程序执行了一条神奇的指令,硬件层面严重错误,操作系统对应的 Exception Handler 也没辙,默认行为就是终止程序;
例子:illegal instruction(非法指令,通常因为低特权程序执行了高特权指令,或者压根汇编指令就有问题)、parity error(硬件奇偶校验错误)、machine check(硬件检查未知错误);
后两种可能是机器被宇宙射线击中,发生了 bits flop,或者硬件电路出问题了;
默认行为:abort(unrecoverable);
13.3.5 Summary of Exception
13.3 中,我们介绍了非常底层级的一种硬件 ECF 机制:Exceptions,请大家回忆:Exception 的 概念、处理过程、实现方法、种类以及各种类之间的处理模式。
Exception Type | Interrupt | Traps | Faults | Aborts |
---|---|---|---|---|
Recoverable | ✔ | ✔ | Possibly | ❌ |
Return to | I next | I next | I current / abort | abort |
这种低层级的控制转移可以由操作系统和硬件联合实现,也是其他高级 ECF 机制的基础。
13.4 Exception Control Flow: Process Context Switch
要了解进程上下文切换,首先要了解什么是进程。
13.4.1 Process
定义:一个进程是一个正在运行的程序的实例。
与程序(program)不同,程序可以看作存在于
*.c
文件中的、存在于二进制文件的.text
区域的、存在于已加载内存的字节中。进程是计算机科学中最为影响深远的思想之一。
进程的 3 种状态:Running、Blocked(Stopped)、Terminated
进程提供的 2 个关键抽象:
Logical Control Flow:
- 每个进程都感觉自己独占了 CPU 资源,不用担心寄存器、CPU 的重要数据被更改;
- 这种逻辑上的控制流的隔离机制来源于 OS 内核和硬件提供的重要 ECF —— Process Contest Switching;
啥是 Logical Control Flow,在本节结束后你就会知道。
Private Address Space:
- 每个进程都感觉自己独占了主存资源,不用担心别的程序未经同意访问自己的资源;
- 这种隔离机制来源于 OS 和硬件提供的重要抽象:Virtual Memory(前面说过);
以上的两个抽象为操作系统提供了多进程执行与并发(Multiprocessing & Concurrency)的能力。
基于上面 2 条关键抽象,我们的进程满足:
- 有整套独立的虚拟内存空间,互不干扰;
- 有看起来能够持续执行的 CPU 及稳定的寄存器资源;
13.4.2 How does Multiprocessing work on single processor ?
那么,操作系统是如何同时运行多个进程的呢?我们以仅有一个处理器核为例。
我们在 13.3.4 中曾经提到过 “系统时钟中断和分时复用”。操作系统想要随时能够取得控制权,就需要借助硬件时钟,每隔一段时间触发一次时钟中断(interrupt)的 exception,让程序从用户态回到内核态,由操作系统判断情况,是否要进行一些调度或者切换等处理操作。
为了充分利用 CPU 等资源,同时运行多个进程,操作系统对每个进程的单次执行时间设置较短(大约 1 ms 量级),一旦该进程执行时间片耗尽,那么操作系统会借助时钟中断的机会触发一种高级的 ECF: Process Context Switch,将处理器上下文数据切换到另一个进程继续执行。
这里 “进程的上下文数据” 就是 能够让系统处理器从其他进程回到当前进程所需额外的数据。由于每个进程独享一段虚拟内存,所以原本在虚拟内存中的上下文数据无需另外保存。所以,进程的上下文数据一般指 处理器芯片中的各个寄存器的值、内核数据结构(页表、进程表、文件表等,都放在 kernel space 中)以及各进程虚拟内存总体的物理位置。
那么这样也不难理解为什么 OS 和硬件能做到在各个进程之间的无缝切换了。我们小小总结一下:
什么是 Process Context Switch?
进程上下文切换是指 OS 和硬件 控制处理器保存在当前进程的上下文数据,并切换到另一个进程继续执行的过程。这种交替执行的方式被称为 interleaving;
为什么需要 Process Context Switch?
这能够让操作系统利用有限的 CPU 执行多进程任务(multiprocessing),充分利用系统资源,也不至于让一个进程卡死就波及到其他进程的执行。也就是说,interleaving 可以实现 multiprocessing;
什么时候出现 Process Context Switch?
- (必定)当 Hardware Timer 触发了时钟中断 Exception;
- (必定)当该进程出现耗时的系统调用时,这种系统调用被称为 “慢系统调用”(通常是类似
read/write/sleep
的系统调用),哪怕该进程时间片没耗尽,操作系统依然会 suspend 这个进程(进程转为 blocked 状态); - (可能)当该进程出现了普通的系统调用,控制权流转到 OS Kernel 中,操作系统会根据情况(例如优先级等情况,在操作系统课程中的
scheduling
一节介绍)选择是否进行进程上下文切换。
我们发现这三种原因全都是 Exception,因此说底层 Hardware ECF 为高级 ECF 提供了基础条件。
底层的 Process Context Switch 如何实现 / 进行的?
step 1. 因为上面所述的 3 种原因之一,当前进程执行到某一位置时暂停,并暂时转移到内核态,控制流交给 OS;
step 2. OS 判断当前是否应该进行 Process Context Switch,如果不是,则退出内核态,恢复原程序执行。不过大部分情况是应该进行切换的,因此进入下一步;
step 3. 确认要进行进程切换后,操作系统会评估各个进程的优先级、进程状态等信息,按照评估结果决定切换到哪个进程上,这个做出决定的内核程序段被称为 scheduler;
step 4. 操作系统在切换前找到原进程的信息,将上下文的处理器中的各个寄存器值存入该进程的虚拟内存中,然后转到 scheduled process 中,从 scheduled process 的虚拟内存中读出处理器的上下文数据,继续执行 scheduled process,过程如下:
在 Context Switch 中需要注意的是:
- OS Kernel 并不是一个单独的进程,OS Kernel 的数据存在于每个进程中,作为它们的一部分。通常数据位于各进程虚拟内存 栈区的下层(用户态不可访问);
13.4.3 Concurrency & Parallelism & Interleaving
之前我们讨论的实现 multiprocessing 的方式是 interleaving,而计算机科学中还有两个概念叫 并发(concurrency)和 并行(parallelism),它们之间的关系是什么?我们来对比一下:
- 并发:指在一个较短的时间内同时执行多条任务或进程,它是一种执行策略,我们可以由多种方案来实现这个策略;
- 交织(interleaving):指交替(在时间上 sequential)执行多条任务或进程,但 “appeals to”(看起来像是)同时在执行所有任务,这个名词通常被用作多任务或分时复用的系统中,表明处理器短时间内在不同任务间切换以达到同时执行的效果。
- 并行:指严格同时地执行多条任务或进程,主要的目标是利用多核 / 多处理器共同工作的杠杆作用来同时地执行任务,提升系统性能;
我们将上面的概念解析成几个容易理解的观点:
- 并发是一个很广的概念,它可以通过多种机制实现,比如 并行(simultaneous execution)、交织(sequential execution with rapid switching),所以说 并行和交织都是实现并发的途径;
- 交织则强调在一个处理器上,充分利用有限资源执行多个任务,而并行则强调在多个处理器相互协作,同时处理不同的任务,达到 1+1 > 2 的效果;
可能还有同学会问,那多进程(multiprocessing)和它们又有啥关系?
实际上,我们上面讨论的都是抽象层面的策略和方案,它们可以针对计算机系统中的进程,也可以针对其他的任务。所以并发、并行、交织都是实现多进程的思路之一。
13.4.4 Concurrent Process
理解了并行、并发、交织的概念后,我们再来看进程的并发。进程的并发有以下重要的概念:
首先我们在 16.1 中介绍过,物理控制流(大家回忆一下)是指硬件上正在执行的实际指令序列。现在,我们将每一个进程都看作一个 Logical Control Flow(逻辑控制流),所谓逻辑控制流就是从这个进程的开始到最终 terminated 的全过程(包括中间 blocked 的部分)。画一对图大家就理解了:
如上图,假设在一个处理器上,进程 A、B、C 的物理控制流如图所示。我们可以看到这采取了一种多进程交替执行的方式实现了进程的并发。所以它所对应的逻辑控制流是:
我们可以形象地理解,进程逻辑控制流就是将它的物理控制流从头至尾连接起来。
注意:如果
graph 2
是物理控制流,那么这个行为就是多核并行了。
在这个基础上,我们做出如下定义:
- 如果两个进程的逻辑控制流在时间上相互重叠(overlap in time),那么称这两个进程是并发执行(concurrently)的;
- 否则称这两个进程是顺序执行(sequentially)的;
13.4.5 Process Control
在了解很多进程的理论后,我们需要转向实践层面的学习。现代 Linux 系统提供了很多控制进程的系统级函数(C library 中包装的系统调用接口),这些函数操作进程的过程称为 “进程控制”,而这些系统级函数最终大多数都会进行系统调用。
以 x86-64 Linux 为例,大多数系统函数的规范是:
如果执行出错,则返回 -1,同时设定全局宏
errno
来提示出错的原因。因此在使用系统级函数时,有一个约定俗成的硬性规定(hard and fast rule):当调用系统级函数后,必须检查其返回值,已确认其是否正确执行。
某些特别的系统级函数除外,例如
exit
、free
返回void
类型;
首先遇到的第一个进程控制的系统级函数,用于复制 / 创建进程:
1 | pid_t fork(void); |
作用:复制一个与当前进程一模一样的进程。复制出的进程被称为原进程的子进程,父进程的含义不解释。
注意,一模一样是进程的虚拟内存中的几乎所有数据都一致,但父进程和子进程虚拟内存相互独立(相当于 deep copy);
- 虚拟地址、进程总体程序栈数据都一样;
- 文件标识符直接可以继承使用,也就是可以访问任何父进程已打开的文件;
- 父、子进程间 PID、页表等信息不一致;
返回值:在父、子进程中分别返回一次。
- 当创建进程失败后,返回 -1;
- 当进程创建成功,且当前进程为父进程时,返回子进程的 PID(正整数);
- 当进程创建成功,且当前进程为子进程时,返回 0;
⚠ 使用提示:
此方法创建的新进程与原进程的运行的顺序和同步性完全不能保证(即在逻辑控制流中没有明确先后关系)。因此输出时序具有随机性,在编程时不应该假设二者的执行顺序;
适宜使用树状拓扑图的结构(这被称为 进程图,process graph)分析存在多个 fork 进程控制的情况;
进程图的使用方法
一个进程图是分析并发程序的语句部分执行顺序的有力工具。
- 进程图是一个有向无环图(DAG,没有自环、重边),图的每个顶点代表每条语句的执行情况;
a -> b
表示 a 语句发生在 b 语句之前,二者在逻辑控制流上有明确先后关系;- 进程图的边可以标注当前情况下变量的值;
- 进程图的点可以标注当前语句的输出或其他信息;
- 进程图的任意一种拓扑排序与一种可能的执行顺序一一对应(所有拓扑排序则代表所有所有可能的执行顺序);
这里一个常见考题:给出一个含有很多
fork()
的程序,要求写出输出情况;
其次,我们还可以获取进程号(PID):
1 | pid_t getpid(void); |
- 二者作用:获取当前进程 / 获取父进程 PID;
- 返回值:当前进程 / 父进程 PID;
系统级函数还可以终止一个进程:
1 | void exit(int status); |
- 作用:以
status
状态数终止这个进程; - 约定:
status
状态数 0 表示正常退出,非零代表异常退出; - 返回值:不返回任何值,一个进程只会执行一次;
系统级函数还可以让一个进程主动进入 stopped 状态并持续一段时间:
1 | unsigned int sleep(unsigned int secs); /* in <unistd.h> */ |
- 作用:让当前进程进入 stopped 状态 suspend 起来,不接受 OS 调度,直到
sec
秒后恢复 running 状态; - 返回值:进程实际还剩多少秒没有 sleep(因为中途可能会被信号等 ECF 机制打断);
系统级函数也可以让一个进程无限期进入 stopped 状态,直到向其传入任意信号:
1 | int pause(void); |
- 作用:当前进程进行系统调用,直接进入 stopped 状态;
- 返回值:如果控制流能回来,那么总是返回 -1;
此外,系统函数还提供了 回收子进程(Reaping Child Process) 的能力。
为了了解这是什么意思,我们有必要 recap 一下在 16.4.1 中介绍的 Process 的概念。从编程人员的角度来看,一个进程具有 3 种状态(运行、阻塞 和 终止)。现在我们结合之前了解到的 Process Context Switch 和进程调度的基础知识,再重新认识一下这 3 种状态:
Running:处于该状态的进程可能正在被执行,也可能是退出了阻塞状态,等待被 OS 调度执行;
Stopped:处于该状态的进程已挂起(suspended),并且无法被 OS 调度,除非有信号通知;
Terminated:处于该状态的进程已经永久结束运行;
进程终止的 3 点原因:
- 收到一个默认行为是终止进程的信号;
main
流程执行完毕;- 进程种的程序主动调用
exit
函数;
我们发现,改变进程状态的方法是操作系统与应用程序间的高级 ECF:信号;而信号的发出又需要 硬件 ECF(Exception)作为条件。
下一节会提及高级 ECF 信号的机制,举个例子,Ctrl + C -> Interrupt Exception -> Kernel 发出 SIGINT -> 信号提醒进程进行默认行为)。
除此之外,我们还看到,进程的状态中有一个是 terminated,这说明操作系统在一个进程终止后,会一直保存这个进程的状态和部分数据(包括退出状态、多种 OS 表),直到它被 “回收(reaped)”。那么为什么进程结束了还需要等待回收?为何不直接清除这个进程的数据?
这是因为,我们通常需要知道进程退出的一些状态信息(正常退出,还是因为其他原因退出),如果 OS 在进程结束后就直接清除数据,那么我们无从知晓其中的信息。
通常情况下,在一个进程从永久退出执行,直到被回收的时间段内,我们将这个进程称为 zombie(僵尸进程,在 ps
命令下显示 <defunct>
)。
那么进程最终应该由谁回收?答案是父进程或者是系统根进程 init
(PID = 1)。父进程如果想要等待在子进程退出后获取子进程的退出状态,那么就需要使用系统级函数 wait
和 waitpid
。准确地说,它们并不是 wait 进程结束,而是 wait 进程状态的改变;
1 | pid_t wait(int *child_status); /* Equivalent to waitpid(-1, &s, 0); */ |
由于 wait
是对 waitpid
的默认包装,因此我们这里仅介绍 waitpid
;
第一参数
pid
指定父进程要等待的子进程的 PID;如果传入 -1,则表示等待任意一个子进程状态改变即返回;如果传入 0,则表示等待任意一个与调用进程同进程组的子进程状态改变即返回;“状态改变”,而不是永久终止(terminated)。
第二参数
child_status
应该传入一个整型地址,函数返回时会将子进程的状态码(和退出码不一样,需要专门的宏进行读取)填写进去;如果传入 NULL,则表示不关心子进程的状态,只是等待指定的子进程状态改变;读取该参数的宏位于
<sys/wait.h>
头文件中:WIFEXITED(child_status)
:返回子进程是否被正常终止(调用了_exit(2)/exit(3)
或从main
返回);WEXITSTATUS(child_status)
:返回子进程的退出状态(当且仅当WIFEXISTED
为真的时候有效);WIFSIGNALED(child_status)
:返回子进程是否被信号终止;WTERMSIG(child_status)
:返回终止子进程信号的编号(当且仅当WIFSIGNALED(child_status)
为真时有效);还有
WCOREDUMP(child_status)
、WIFSTOPPED(child_status)
、WSTOPSIG(child_status)
、WIFCONTINUED(child_status)
等,大家可以用到再查;第三参数
options
常用的参数有 4 个,可以由按位或运算符连接:0:指定
waitpid
函数阻塞,直到子进程状态改变、获取信息后才返回;WNOHANG
:指定waitpid
函数非阻塞,如果子进程状态还没改变,那么立即返回;WUNTRACED
:指定waitpid
在子进程不是因为ptrace
而进入 stopped 状态时也直接返回;什么是
ptrace
?ptrace
是 Linux 的另一个系统调用,为一个进程提供了观察和控制另一个进程的执行过程的能力,同时也提供检查和改变另一个进程的内存值以及相关寄存器信息。大名鼎鼎的
gdb
和系统工具strace
都是基于ptrace
完成调试工作 和 逆向工程的。这里的
ptrace
的系统级函数签名如下:1
2
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);事实上,当
ptrace
被调用来 attach 一个进程时,首先进入 syscall(Exception),操作系统在 exception handler 中的默认行为是通过信号沟通ptrace
所在进程 和 attach 的进程,最后该进程会进入Traced
状态(一个与 Stopped 状态几乎相同的状态,都是暂时中止进程执行和调度),等待ptrace
所在进程的调试工作。WCONTINUED
:指定waitpid
在子进程接受到信号SIGCONT
而从 stopped 状态恢复为 running 状态时,也直接返回;
返回值:如果成功执行,则返回状态改变的子进程 PID(如果指定了
WNOHANG
,并且指定的子进程存在,但是子进程还没改变状态,那么直接返回 0);如果执行错误,那么返回 -1,错误码位于errno
;
这两个函数不仅可以查看子进程的退出状态,而且也告诉操作系统,“如果子进程结束,则可以直接回收该进程了”。
测试发现,如果
waitpid
是非阻塞的话,子进程结束仍然不能直接被回收。
如果父进程不想了解子进程的退出情况(没有调用上述两个函数),或者在子进程结束前就退出了(那么这时,子进程被称为孤儿进程,orphaned child process),那么操作系统会自动将回收权交给 init
根进程,等待孤儿进程结束后直接回收。
这种回收的流程可以总结为:
⚠ 一种可能的内存泄漏的情形:
在一个长期运行的服务器上,系统可能运行了大量的进程。对于一些服务程序,
init
根进程不会回收这些 zombie 进程,因为服务器是长期运行的,也等不到父进程结束的情况。如果父进程不显式回收子进程,那么会导致 zombie 进程堆积,内存溢出,甚至内核崩溃。
⚠ 另外,还有一个要点,即 waitpid
只能等待父进程的直接子进程(immediate child process),它无法等待子进程的子进程的结束并返回信息的。
最后,系统级函数还提供了更改当前进程运行的程序的功能(毕竟之前只提供了 fork()
通过复制创建进程)。
1 | int execve(const char *pathname, char *const _Nullable argv[], |
这条系统级函数也是系统调用的接口,可以在当前进程中加载并运行指定程序。
第一参数
pathname
:可执行文件名,它可以是二进制文件,也可以是由#!interpreter
开头的脚本(Linux 约定俗成,在脚本文件前注释解释器名,例如#!/bin/bash
);第二、参数
argv[]
和envp[]
分别是参数列表和环境变量列表,可以为空;envp
的设置就是简单字符串:"<name>=<value> ..."
,或者使用getenv
、putenv
、printenv
进行解析和设置环境变量字符串;注:
envp[]
会在执行前由 OS append 几个参数,参数就是系统环境变量;返回值:仅在执行错误(找不到指定文件)时返回 -1;
⚠ 注意,在当前进程运行该指令后,如果正确执行,那么程序控制流永远不会回到该指令的下一行,程序整体会在虚拟内存中完全被替换为新运行的程序,即完全覆盖当前程序虚拟空间,包括 code 区、data 区、stack 区等等,仅保留 PID、file descriptor 和 signal context(信号上下文)。
这意味着,正确执行的情况下,execve
不会有返回值。
这还意味着,如果你想开发一个 Linux shell,那么你使用 execve
时,应该 fork
一个子进程出来,然后在子进程中执行该命令。否则当前程序会被覆盖。
当新的程序被从可执行文件加载到虚拟内存后,程序的虚拟内存的结构应该是这样(不包括 kernel space):
有同学可能会问,为啥不做一个同时创建一个进程并运行指定程序的系统调用呢?实际上,设计者从实用性和冗余性两个方面考虑,
fork
非常有用,例如对于一个并发服务器而言,想要创建多个副本来响应 client,那么只需要 fork 就行;而且你可以在运行指定程序前、fork 之后的时间内可以做一些其他自定义的准备工作,非常灵活。
13.4.6 Summary of Process & Process Control
在本节中,我们学习了一种高级的 ECF 机制:Process Context Switch。为了深入讨论这个话题,我们首先了解了进程的概念(一个正在运行的程序的实例),以及进程重要的 2 个抽象——对 CPU(logical control flow 是连续的)和 主存(private address space 是独享的)的抽象。
另外,我们从 “系统如何充分利用有限资源实现多进程并发” 的问题入手,了解了当今 Linux 操作系统利用 Timer Interrupt Exception(分时复用和时间片轮转)和 系统调用 达到 Process Context Switch 的目的,进而实现多进程执行的 interleaving。于是 进程上下文切换的 What、Why、When、How 四个问题都得到了解答。
其中,我们还详细比较了 interleaving、parallelism、concurrency、multiprocessing 的概念的异同,分析了怎么看 Physical Control Flow、怎么画 Logical Control Flow、怎么判断进程是 concurrent 还是 sequential 执行的。
最后一部分,在实际编程层面,我们介绍了控制 process 的、包装了系统调用的系统级函数,它们分别有:fork
、getpid
、getppid
、exit
、sleep
、waitpid(wait)
、execve
。我们重点分析了这些函数的参数、返回值的含义,以及调用时的注意事项和技巧。
例如分析多重 fork
的进程图,fork
和 execve
的独特返回方式,waitpid
的复杂参数,最重要的点之一还是 “为什么要有进程回收、怎么进行进程回收” 的问题。
不过,我们在这节多次提到信号的概念,足以说明信号在操作系统与应用程序间的 ECF 的重要性。下节我们就讨论高级 ECF 中的信号(signal)机制。
13.5 Exception Control Flow: Signals
信号是由 OS 进行管理的软件信号,信号的种类和规范都由 OS 制定,完成 OS 和应用程序间的通信,实现高级的 Exception Control Flow;因此,我们需要先了解一下 OS(以 x86-64 Linux 为例)进程的继承结构,这样有助于在分析信号的时候不至于晕头转向。
13.5.1 Linux Process Hierarchy & Shell Example
在 Linux 中,只有一种方法创建新的进程 —— fork
;而由 fork
创建的进程有明确的父子关系,因此,Linux 中的所有程序所在进程实际上形成了一个层次结构:进程树。
在 Linux 启动的第一个进程是 init
根进程(PID = 1),其后所有生成的进程均为 init
的子进程。init
进程一般会创建 2 类进程:
Daemon
:守护进程,是一类长期运行的程序,用来提供服务(例如对于一个 web 服务器,那么可能运行的服务是 apache 服务httpd
);login shell
:登录进程,这个进程运行的是命令行程序,为用户提供命令行接口,这样用户在登录后,可以以特定用户的身份,通过输入命令的方式与程序进行交互;例如用户输入
echo "Hello, Linux."
的指令并回车,发生的是如下情况:
知道了这个道理,再结合上一节我们接触到的 process control 的系统级函数,我们就可以开始自己尝试用 C 写一个系统命令行了。我们来看看这个小型的命令行程序:
1 |
|
细心的同学会发现,这个程序有个严重的 bug —— 它只对运行在 foreground(前台)的程序进行回收,对运行在 background(后台)的程序则仅仅是打印一条信息,就不再关心了。前一节我们讨论过,像 shell 这样长期运行的程序如果不回收其子进程,会导致 zombie 的堆积,引发内存泄漏的问题。
那么问题来了,对于运行在后台的子进程,我们不想等待,但该如何知道它何时结束,并且回收呢?这个问题的解决方案就是利用 Exception Control Flow,因为只有它能够不按照应用程序原先控制流,而是转向 OS Kernel,让 OS Kernel 配合通知我们的 shell 后台子进程的情况。这就是本节的主角,也是这类问题的解决方案 —— 信号(高级的 ECF 机制之一)。
13.5.2 The Features of Signals
- 定义:信号是一种软件产生的,用来通知进程,系统中某一种事件发生的小型信息。
我们发现,信号的定义很像之前的 Hardware ECF(Exception),它们都是某种事件发生时触发的信息通知机制,但是后者是硬件和操作系统层面(包括 Asynchronous Exception 的硬件引脚 Interrupt 和 Synchronous Exception 的处理器触发进入 Exception table),前者则完全是由操作系统软件产生的软件层面的信息,抽象层级更高。
发送方:通常是 OS Kernel,有时是一个 Process 请求内核(利用
syscall
这个 Exception)来向另一个进程发送(所以本质上只有 OS Kernel 有权限发送信号);接收方:总是一个 Process;
发送时机:总是因为处理器触发了一个 Hardware ECF,进入某个 Exception Handler 中的行为,可能有以下两个原因:
- System call(
trap
):有几种向另一个进程发送特定信号的系统调用:kill
(不是专门 terminated 进程的系统调用,而是用来发信号的,名字起的不好!),和之前我们接触过的ptrace
等; - 其他 unintended hardware ECF(例如 Interrupt(典型是
Ctrl+C/D/Z
键盘 I/O)、Fault(常见是 Page Fault、Floating Point Exception)等等);
- System call(
接收时机:???(我们在下一节介绍)
内容:它真的很小,仅仅是一个小整型 ID(1 ~ 30),来代表信号的种类;
不过每个整型在 OS 中对应唯一的信号类型和含义。下面是常见的几个信号:
ID Name Default Action Corresponding Event 2 SIGINT Terminate User typed ctrl-c 9 SIGKILL Terminate Kill program (cannot override or ignore) 11 SIGSEGV Terminate & Core dump Segmentation violation 14 SIGALRM Terminate Timer signal 17 SIGCHLD Ignore Child stopped or terminated 我们解释一些上面的信号。
首先,
SIGINT
就是我们对前台运行的程序触发Ctrl+C
按键后,首先发生 Asynchronous Exception(Interrupt),通知处理器,处理器按照 Exception 处理流程转移到 OS Kernel 中处理 Keyboard Interrupt 的 Exception Handler 中,发现这是个Ctrl+C
,因此向目标进程发送一个SIGINT
信号,然后把 Control Flow 交还 user mode 的 $I_{next}$;同时,目标进程收到信号会进行默认行为 —— terminated(可以 override,即自己设计接收信号的 procedure);对于
SIGKILL
过程类似上面的情况,但是它一般利用kill -9
指令,进行了系统调用,通过trap
来发送信号;对于
SIGSEGV
,一般是处理器访问非法地址后出现page fault
,可能发送这个信号;对于
SIGALRM
,一般可以用作自定义信号,利用硬件时钟进行一些任务(例如设置与超时相关的行为);对于
SIGCHILD
,一般在一个进程的子进程的状态改变后,OS Kernel 会向该父进程发送这条信号,但默认行为是忽略。如果父进程是一个 long-run program 并且不使用阻塞的waitpid
,那么我们在父进程中应该主动捕获这个信号,防止发生子进程的内存泄漏;除此之外,我们在前面还见到过
SIGFPE
、SIGGPE
等等信号,用到时再查也不迟;
13.5.3 Implementations of Sending a Signal
那么,通过上面的解释,我们大致知道了,OS Kernel 大多是借助 Exception(Hardware ECF)这个时机来实现信号的发送的,但具体是如何进行的呢?
实际上,OS Kernel 向某个进程发送信号是通过改变目标进程上下文的状态数据来实现的。
Linux 官方文档原文:
Kernel sends a signal to a destination process by updating some state in the context of the destination process.
对,仅仅是目标进程的上下文的某个数据改变了,仅此而已。
那么,目标进程的响应机制呢?主要有以下几种:
Ignore:忽略该信号;
Terminate:该进程终止,或被迫终止(不是中止,stopped);
Catch:捕获信号并处理(这里捕获信号并处理的程序称为 Signal Handler,它与 Exception Handler 不一样,它位于用户态代码中——也就是说,咱可以自己在 C 程序中设计 Signal Handler);
对于 catch 这个选项而言,我们还要了解一下 signals 到达目标进程后的过程:
但是,在信号到达后、进程 A 收到(就是感知到)并处理之前的一段时间内,由于某个给定的时刻,只能有一个类型的待处理信号(因为信号是一个整型,发送信号就是改变一个数据,没有队列这个数据结构,所以重复发就会覆盖,下面会看到这种数据结构)。所以有两个问题:
- 我们不能继续给这个进程发送信号,那么 OS 应该怎么知道目标进程有没有收到?
- 进程 A 是按照普通控制流正常运行的,怎么让它去临时接收这个信号呢?
实际上,这引入了信号的 2 个概念:Pending 和 Blocked;不仅如此,我们还要了解 进程组的概念,然后我们才能完整地解释信号的收发过程。
下面是 13.5.3 的知识补充:
13.5.3.1 Signal Concepts: Pending & Blocked
其实信号也存在状态,其中两个特殊的状态是 pending 和 blocked:
- 如果一个信号被 OS Kernel 发送,但是还没有被目标进程接收(感知并处理),那么这个信号处于 Pending 状态;(重复一遍,没有队列数据结构)
- 如果一个信号被 OS Kernel 发送,但是目标进程显式地阻塞特定信号的接收(感知但保留信号不处理),那么这个信号处于 Blocked 状态,除非该进程主动 unblock;
从这里我们可以知道,pending 状态的 signals 至多只会被目标进程接收一次,但 blocked 状态的 signals 可以接收多次;
我们说过,信号只是一个表示信号类型的小整型,存不了其他数据,因此信号的状态是由 OS Kernel 维护的。
OS Kernel 中保存了各进程的 Pending / Blocking bits 组成的 bit vectors,位于每个进程的上下文虚拟内存的 kernel space 中。维护方法如下。
对于 Pending Signals;
- 当一个另一个进程的 OS Kernel 向当前(目标)进程发送第 k 号信号,那么当前进程的 OS Kernel 会设置当前进程的 Signal Pending Vector 的第 k 位为有效位(假设是 1);
- 当当前进程接收了这个信号 k,那么当前进程的 OS Kernel 会设置当前进程的 Signal Pending Vector 的第 k 位为无效位(对应是 0);
对于 Blocked Signals:
Signal Blocked Vector 相当于是对 Signal Pending Vector 的掩码(被称为 signal mask,信号掩码)。
如果 block / unblock 特定信号,那么目标进程会使用 sigprocmask
(又一个系统调用)显式设置掩码位对应的 Pending Signal 是否有效。
1 | /* Prototype for the glibc wrapper function */ |
具体用法见 13.5.5 节;
13.5.3.2 Process Concept: Process Groups
在 Linux 下,每个进程都明确属于一个进程组。那么如何为一个新产生的进程分配进程组?
实际上,进程组的分配有专门的系统调用(又来几个系统调用):
1 | pid_t getpgrp(void); /* Equivalent to getpgid(currentPID); */ |
上述函数执行失败返回值均为 -1,均会设置
errno
,注意检查。
注意,进程组号 PGID 和进程 PID 是共用类型 pid_t
的。
此外,创建一个进程,默认的进程组号与父进程的进程组号相同。进程属于且仅能属于一个进程组;
这样的话,可以方便我们向在同一进程组中的所有进程都发送信号。例如,如果我们使用系统调用 kill
来给一个进程或进程组发送信号(以包装好的系统命令为例。当然,C library 也包装了一个系统级函数 kill
,能够在 C 中向另一个进程发送信号):
1 | kill -9 12345 |
上面的指令就表示请求 bash 调用系统调用 kill
,向进程号为 12345
的一个进程发送 9 号信号(SIGKILL
);
1 | kill -9 -12344 |
上面的指令由于进程号前加了一个短 dash,因此被解释为 进程组号,上面的行为是向进程组号为 12344
的所有进程发送 9 号信号。
然后我们回过头解释一下 Ctrl+C/Z
是如何发送信号的。如下图。
首先,我们在 bash 中输入的指令和我们之前了解的一样,分为前台和后台任务;bash shell 在 fork
+ execve
一个指令的时候,会根据指令后面有没有 &
来判断这是条前台还是后台任务。如果是前台任务,那么创建进程后还会进行系统调用,将前台任务放到前台进程组中。前台进程在一个 shell 主进程中只有一个,所以一般这个前台进程组由 shell 进程直接管理。
如果我们按下 Ctrl+C/Z,那么 Interrupt Exception Handler 的默认行为就是向 shell 进程发送 SIGINT/SIGSTP
信号;而 shell 定义了对于该信号的 Signal Handler,也就是向前台进程组中所有进程发送 SIGINT/SIGSTP
(不影响后台进程)。这就完成了一次键盘 I/O 信号的发送和传递。
注:
SIGSTP
信号的默认行为是让目标进程的状态 suspend 到 stopped 状态,直到接收到SIGCONT
信号后才恢复。
Back to 13.5.3
了解了信号的状态和管理的知识 和 进程组的知识,我们再回到 13.5.3,看看完整的信号处理过程应该是什么样子的。
考虑一个会经常出现的情形:某一个进程 A 正在执行,进程 B 的 OS Kernel 想向进程 A 发送一个第 k 号信号,怎么办?OS 是这么做的:
step 1.
在某次 Process Context Switch 中,OS 的 scheduler 决定切换到 Process B,于是按照 Process Context Switch 的规范进行切换(保存当前进程上下文数据到当前进程的虚拟内存中,读 Process B 的进程上下文数据并加载到处理器中);在运行 B 的代码前,OS Kernel 会按照想要发送给 A 的信号(k)来更改 A 的 Kernel space 中的 Pending Vector(将第 k bit 置为有效位 1) 然后转入用户态,正式运行 Process B 的代码;
step 2.
在执行 Process B 一段时间后,处理器触发了 Exception(Interrupt、Trap、Recoverable Fault),于是控制流重新进入内核态。假如 scheduler 决定切换到另一个进程(也就是目标进程)A,那么在正式切换前,Kernel 会检查进程 A 的 signal 情况:使用 pnb = pending & ~blocked
计算出上次 Kernel 发给这个进程的信号的集合,pnb
就是所有未被阻塞的信号的 bit vector;
- 如果
pnb == 0
,那么说明当前没有收到未阻塞的信号,OS Kernel 会继续进行 Context Switch 操作,切换到 Process A; - 否则,OS Kernel 会选择
pnb
中最低非零位的 bit(假设第 k 位)作为信号接收。那么,OS Kernel 将 pending vector 第 k 位置为无效位(0),并且执行对应 k 号信号的 Signal Handler,此后重新回到 step 2 计算并检查pnb
,直至pnb == 0
;
这样信号的发送和接收的流程就形成了闭环:
- 信号接收时机:在 Process Context Switch 进入目标进程前统一检查并接收;
于是我们可以说:
信号的发送和接收依靠 Process Context Switch 实现,而 Process Context Switch 又是依靠 Hardware ECF(Exception)来实现的,三者抽象层级依次升高,密不可分,分别构成了硬件与操作系统、操作系统与应用程序间的 ECF 交互机制。
13.5.4 Signal Handlers & Default Action
好了,到目前为止,我们已经大致将信号机制的脉络捋清了。还有一点,我们之前就很想了解的:怎么自定义 signal handlers 呢? 这就需要我们更改一个进程收到某个信号的默认行为(Default Action)了。
提供修改信号 Default Action 的还是一个系统调用:signal
(名字和 kill
一样有误导性。这里 signal
系统调用不是发送信号,而是设置进程对于信号的 default action);
1 | handler_t *signal(int signum, handler_t *handler); |
注 :
handler_t*
类型被typedef
为一个函数指针类型void(*)(int)
;
第一参数
signum
:要修改该进程默认行为的信号编号(在<signal.h>
中有宏定义可供使用);第二参数
handler
:类型为void(*)(int)
的函数指针,即为 signal handler(其参数也为信号编号);如果是要设置 Ignore / Terminate 为默认行为,那么它们(函数指针)在
<signal.h>
还有特定的宏:SIG_IGN
、SIG_DFL
;返回值:如果执行成功,则返回传入的 signal handler 指针;否则返回宏
SIG_ERR
(0);
可是问题又来了。我们知道,signal handler 会在 Process Context Switch 前被调用,调整程序的 control flow。因此,signal handler 用户态代码 和 普通代码一样,都可以作为并发流(concurrent flow)。以前我们接触的并发流都是不同进程间的 control flow。不同进程间资源不共享,它们间的切换依靠 Process Context Switch;
但是!一旦引入了 signal handler,signal handler 本身是和原进程并发的,而它又会共享原进程的一切资源,这可能出现问题(什么问题?在 16.5.6 节讨论)
我们结合 signal handler,顺便复习一下 16.5.3 中的内容,从另一个角度来看完整的信号收发过程和并发的情况:
- 首先我们知道,如果有一个进程 B 想要想要给 Process A 发送信号,那么在某次从 Process A Process Context Switch 到 Process B 前(置位操作不一定正好发生在 Process Context Switch 时,可以在此之前),Kernel 就为 Process A 的 Pending Vector 置了有效位(如上图最上面的箭头);
- 随后通过 Process Context Switch 后,控制流进入 Process B;
- 一段时间后,如果再次出现了一个 Process Context Switch,决定向 Process A 跳转,那么在进入 A 正式执行前,会检查其
pnb
向量是否为 0(如上图下面的箭头),然后按 default action 进入位于 user code 中的handler
(signal handler)中; - 当
handler
执行完成后,控制流再次进入 kernel code 进行一些准备(例如恢复 $I_{next}$ 数据); - 最后控制流真正回到 user code 的 $I_{next}$ 的位置继续执行;
13.5.5 Nested Signal Handlers
⚠ 这会出现一种情况 “Nested Signal Handlers”,因为在运行在 signal handler 时,毕竟也是 user code,也有可能发生 process context switch:
为啥要讨论这种情况?因为 OS Kernel 对已经正在处理同类型信号时(即位于该信号的 Signal Handler 时),会自动阻塞该进程对同类型信号的接收。这种信号阻塞方式称为 Implicit blocking(隐式阻塞);
例如上图,如果程序位于第(4)步,那么它既会阻塞
S
信号,又会阻塞T
信号的接收。
而我们之前 16.3.5.1 介绍过的 sigprocmask
系统调用,则是程序可以显式地修改对信号的阻塞情况。
我们再回顾一下它的声明:
1 | /* Prototype for the glibc wrapper function */ |
这里的
sigset_t
就是之前我们说的 Pending Bit Vector 的数据;
- 第一参数
how
可不是信号编号,因为信号编号应该是set
的 bit 位;它是sigprocmask
要执行的行为,具体也是由宏定义的:SIG_BLOCK
:将指定参数set
的有效位加入现在的 blocking bit vector,阻塞指定信号(blocked |= set
);SIG_UNBLOCK
:将指定参数set
中的有效位从现在的 blocking bit vector 移除,停止阻塞某种信号(blocked ^= set
);SIG_SETMASK
:直接将参数set
作为 blocking bit vector(blocked = set
);
- 第二参数
set
是一个与 pending bit vector 格式相同的数据,作用在第一参数中能看到,具体我们怎么操作这个向量并且填上去,还有专门设置这个 vector 的函数:sigemptyset
:返回一个空的 pending bit vector 数据;sigfillset
:返回一个每个信号位都有效的 pending bit vector 数据;sigaddset
:返回一个在输出 pending bit vector 基础上置位指定信号位的新的 pending bit vector 数据;sigdelset
:同理,删除;
- 第三参数在接下来的例子中就能看到。
这个虽然很多,但很重要!!!在 Shell Lab 中会用到。
为什么我们要用到它们呢?直接交给 OS Kernel 隐式阻塞不好吗?我们不妨考虑一个场景,如果程序中有一串代码不希望被用户用 Ctrl+C 打断,那么我们就需要暂时显式地阻塞 SIGINT
信号,下面就是例子:
1 | sigset_t mask, prev_mask; |
看完这串代码你大概能明白 sigprocmask
的第三个参数的含义了:以指针传入,存储以前的信号掩码信息,便于之后的恢复工作。
13.5.6 Safe Signal Handling [⚠IMPORTANT]
讨论完前面的知识,相信大家心里有点数了——信号非常难缠,不仅约定、机制和系统调用接口贼多,而且你必须小心再小心,否则会出大问题:
不正确地处理信号会导致一些系统级问题,包括但不仅限于:
内存泄漏(zombie 堆积等原因);
共享内存访问冲突(死锁 dead lock、段错误 Segmentation fault 等问题都会出现。由于 signal handlers 与普通 user code 共享内存,但它们是并发的,很像一对并发的线程,thread),你要像写多线程程序一样小心,保证线程安全性;
为什么?这还是因为 signal handlers 和 普通程序构成了并发流。
因为在 signal handler 执行前后,原程序都停留在 $I_{current}$ 的位置上,这样在 Logical Control Flow 上看,它们是并发的,几乎和两个线程的效果一样。
如果原程序正在改变一个诸如链表一样的数据结构,结果被 Exception 打断,进入了 signal handler;那么如果 signal handler 也改变了这个链表,当控制流回到原程序时,原程序并不知道自己的链表被改变了,因为从他的角度看,它还一步指令都没执行呢!
⚠ 相信我,这种 Bug 非常难找,因为就算
gdb
也只能逐线程、逐进程地看。所以在写 signal handlers 的时候,请一定注意访问全局 / 其他共享变量时的安全问题!!!
信号除了类型,没有其他语义。这是因为多个信号可能在接收时发生覆盖。因此我们不能用信号进行计数(即计信号发送了几次)!
不同版本的 Linux 中,信号的语义不同,难以移植;
- 某些老旧系统在触发自定义的 signal handler 后,会重新变为 default action,需要重新设置。不过在 Linux 上不用担心;
- 某些系统根本不会在进程处理该类型信号时隐式阻塞;
为了解决这个问题,我们可以利用
sigaction
进行覆写处理。某些慢系统调用(类似
read
这样一定会触发 Process Context Switch 的)会使得errno
变为EINTR
;因为如果触发慢系统调用的进程先于系统调用退出,那么系统调用会发生错误,并且设置
errno
为EINTR
;所以如果你在程序中发现这种错误,就重新进行这个系统调用;
那么怎样做是安全的呢?
Signal Handlers 写的越简单越好,尽量能不往里面加代码,就不加;
比如,你可以在一个信号函数中只是设置一个全局变量,然后立即返回;
仅使用 异步信号安全(async-signal-safe)的 函数;
什么是异步信号安全的函数?
它是指,一种函数是可重入(reentrant)的,也就是说,它访问的所有变量(包括指针的指向)都在自己的栈帧上。这样的函数在多线程、进程信号 handle 的时候,一定不会出现共享内存访问冲突,啥时候运行都不会改变语义。
事实上,POSIX 标准中保证了以下几种(共 117 种)函数一定是异步线程安全的:
_exit
(和exit
不一样!它是系统调用接口,exit
是 C library 包装的系统级函数)、write
、wait
、waitpid
、sleep
、kill
、……但不幸的是:
printf
、sprintf
、malloc
、exit
这些涉及 I/O 访问的、改变进程状态的系统调用或系统级函数大多都不是异步信号安全的,请谨慎在 signal handlers 里添加!为什么它们不安全?因为它们会使用锁来对 I/O 设备或者变量进行读写,在 “多线程” 一章你会明白,这样很容易导致经典的死锁。
所以……你一般没法在 signal handlers 里面打印输出内容……除非你能设计出一个能够对 signal handler 安全的 I/O 库。
每次进入、退出 signal handlers 时应该保存、恢复
errno
变量,有助于系统错误追踪;因为在 signal handlers 被中断后,其他的 signal handlers 可能会更改掉
errno
;在读写共享 / 全局变量时,请阻塞所有其他信号!!!这步操作保证当前 Signal Handler 不会被同进程的其他 signal handler 打断,相当于在多线程程序中加入读写锁;
如果你的 signal handlers 要用到一个全局变量,请将它声明为
volatile
。这一步也是在多线程编程中常见的。我们复习一下 C 中的关键字
volatile
,这个关键字可以:阻止编译器优化由该关键字修饰的变量,即始终不将它放入寄存器中,每次读取都从内存中进行。这样做的好处是,多线程程序中的volatile
变量不会发生值修改不同步的情况。我们考虑以下情况:如果一个 signal handler 是修改某全局变量
flag
然后返回;该进程程序主体会定时检查这个flag
,做出相应动作。如果我们在声明
flag
的时候,不加这个关键字,那么很有可能flag
的值会被编译器解释成直接放到寄存器中,然后仅仅修改寄存器中的值。这样主程序可能永远也收不到 signal handler 更改的flag
。如果你的全局变量是 仅读写的简单类型(数组、标准库中的容器是聚合类型,不是简单类型),除了加上
volatile
,还建议使用sig_atomic_t
类型。这就相当于多线程编程中的原子操作(atomic<>
);对于一些慢系统调用,如果希望提升程序健壮性,应该在执行完检查是否
errno == EINTR
,查看中途慢系统调用是否会被 Process Context Switch 阻断产生偶然错误;
那么一个超级有难度的考题就来了——判断给定程序的异步信号安全性。来看下面一个程序:
这个 fork14()
想将自己创建的所有子进程通过信号的方式回收。这样做对吗?
1 | int ccount = 0; |
很遗憾,这样写可以说没有一点异步信号安全性。能够成功回收应该是小概率事件。
我们从简入繁地分析一下:
- 上面的信号处理程序没有保存和恢复
errno
,这样会造成不必要的错误追踪的麻烦; - 很显然,
printf
不是信号安全的,极其容易发生死锁。要么不用,要么换成异步信号安全的函数; ccount
变量没有添加volatile
关键字,可能被编译器优化,甚至在child_handler
减少了ccount
后,main
都无法感知;- 最严重的一个问题是,任何时候,都不应该用收到信号的次数来作为真正发送信号的次数。因为前面介绍过信号的发送和接收的过程:从信号发送给进程,到进程真正接收,中间至少间隔 2 次 Process Context Switch。在此期间,由于没有队列数据结构,所有重复的相同信号都会被覆盖成一次信号。此外,在进程处理该信号的同时,还有隐式阻塞的问题,也会产生相同信号覆盖的情况。
现在我们来改正。当我们收到一个 SIGCHILD
信号时,应该假设有多个子进程都结束了(因为无法计数):
1 | void child_handler2(int sig) { |
再来看一个更难的例子:
1 | void handler(int sig) |
其实这里的错误很难发现。在我们很早以前说 fork
的时候就讲过,不能保证父子进程的运行的时间顺序。这里可能会发生一个问题:在父进程 addjob
之前,execve
的子进程可能已经结束。这就意味着,父进程将一个已经结束的 job 加入 job list 中,这样任务队列永远不会为空。
如果要想 debug 找出,难点在于,你很难弄清楚各个父子进程间的 interleaving 的执行关系。所以在写信号的时候,一不留神就可能写出一个很恶心的 bug,还找不出来。
改正方法是,我们没法控制子进程和父进程执行的顺序,但我们可以控制 signal handler 执行的时机。我们在创建子进程前,阻塞所有信号(为什么?)。 在子进程中,在任务开始前,解除阻塞(又是为什么?)。
在创建子进程前阻塞所有信号,是为了让父进程在将任务添加到任务列表后,再考虑信号接收问题,防止 signal handler 在父进程还没加入任务列表时就被触发;
在创建子进程后,子进程开始后、execve
前解除阻塞,是因为要让 SIGCHILD
信号放出去,否则切换执行程序后就没有机会了。
于是改正后的程序长这样(handler
没有问题):
1 | int main(int argc, char **argv) |
了解了这个方面的知识,我们可以明白一件事,我们在写一个这样的前后台程序的时候,如果不放心,可以用 进程图 模拟一下,确认自己的程序在各种极限情况下都能正常工作。
还有一个问题。在 shell lab 中,其实是不允许在 main
中写 wait
的,因为普通的 shell 程序都会将前台子进程的控制也交给信号,这样可以将前后台的处理方式大致统一。那么,怎么设计主程序能够显式的等待信号呢?别看这个好像很好办,实际要考虑的东西多得惊人。例如下面的一个程序:
1 | volatile sig_atomic_t pid; |
如果能够写出上面的程序,那么前面的内容你都已经掌握了。这是个正确的程序,但是美中不足的是,父进程在等待子进程的时候,使用 while (!pid);
,这样的做法比较低效,大部分 CPU 资源都浪费在无意义的 while
循环中了。
那么这个时候有同学可能会说,这简单,我可以在 while(!pid)
循环中加入一个 pause
系统调用,这样有 SIGCHILD
或者 SIGINT
触发后,程序可以自动从 pause
中退出,然后判断一遍 pid
:
1 | while (!pid) pause(); |
很可惜,这样可能会造成死锁。我们不妨画一个进程图,发现如果子进程向父进程发送信号,而且 Process Context Switch 的位置位于 while (!pid)
和 pause()
之间,那么,程序会先接收并处理 SIGCHILD
/ SIGINT
然后进入 pause
。想象一下,如果这正好是最后一个给父进程发信号的进程呢?那么父进程会永远 stopped 在 pause
中。
同学还想了,那我换成 sleep(1)
不就不会死锁了吗?行是行,但是每隔一秒才检查一次子进程会严重拖慢程序运行速度。而设置为其他的固定时间,要么太慢(速度问题),要么太快(和没有 sleep
的效率一样低下)。
那么我们可不可以不那么快地恢复对 SIGCHILD
的响应(第 25 行)?让 “恢复 SIGCHILD
响应” 和 “pause
执行” 成为一对原子操作。那么答案就是新的系统调用:sigsuspend
;
1 | int sigsuspend(const sigset_t *mask); |
它等价于以下 3 条指令的 整体原子操作:
1 | sigprocmask(SIG_BLOCK, &mask, &prev); |
这样,我们只需要在阻塞 SIGCHILD
的时候,在 while
循环中调用 sigsuspend(&prev)
,这样 “取消 SIGCHILD
阻塞” 的行为 和 “pause()
执行” 的行为就原子化了,无需担心中间的死锁问题。这样如果有信号,并且交由 signal handlers 处理后,重新开始对 SIGCHILD
的阻塞,并检查 pid
,完美实现要求。于是改进代码如下:
1 | volatile sig_atomic_t pid; |
13.5.7 Summary of Signals
在讨论信号有关概念之前,我们先认识了 Linux 中的 “进程树” 这种进程层次结构。我们了解到,Linux 一号进程是 init
进程,它是所有进程的父进程。init
进程则会启动两类进程,一种是 Daemon
守护进程,另一种是 Login Shell
命令行。
我们利用之前对于进程控制的知识尝试写了一个小的 Demo,却发现对于运行在后台的子进程,我们没有办法 handle 它们。为了解决这个问题,我们引入了 Linux 系统中的信号的概念。
信号是一个小整型,由 OS Kernel 发出,用来通知进程事件发生、要求处理的一种高级 ECF 机制。
信号的发送和接收的非同时性决定了信号必须在 OS Kernel 中以一种数据结构存储起来,以供目标进程对信号的接收。这种数据结构非常朴素,只是一个 Pending bit vector 和一个 Blocked bit vector,这样的存储方式注定了 信号在接收过程中可能被覆盖,因此接收信号的次数不能代表信号发送的次数;
信号机制运作的流程看起来很简单:
某一时刻,位于某个进程的 OS Kernel 向 Process A 发送一个信号,于是改变了 Process A 的 Pending bit vector;
当某次 Process Context Switch 即将切换到 Process A 前,OS Kernel 检查
pnb = pending & ~blocking
的情况;发现有信号,那么对每个信号都进行处理:进入对应的 signal handler 并重置该位 pending bit vector;由于 signal handler 位于用户态,共享了原程序的一切内存,因此 signal handler 和 原程序在 Logical Control Flow 上成为一对共享资源的并行流,这个情况与多线程等效,但给信号处理和数据访问带来了极大的不安全性。
signal handler 执行中,由于是用户态代码,所以仍然可能会出现 Process Context Switch,因此免不了有 Nested Signals Handlers 的情况。不过,一般的 OS Kernel 会帮助我们将正在处理的相同信号阻塞起来(隐式阻塞),防止多次调用相同的 signal handler;
在 signal handlers 调用完成后,控制流先回到 OS Kernel 恢复 $I_{next}$ 等必需数据然后继续原程序 $I_{next}$ 执行。
只是,别小看这个 同进程并行流的不安全性,如果在书写 signal handlers 时处理不当,那么可能造成相当大的危害。
我们从处理多线程程序安全性的同样思路出发,提出了以下几点避免异步信号冲突的解决方案:
- signal handlers 尽量保持简洁,使用 异步信号安全 的函数;
- 每次进入、退出 signal handler 时要及时保存、恢复
errno
,因为 signal handler 内部可能有系统调用错误,为了防止系统错误难以追踪,我们最好这么做; - 在同时被原程序、Signal Handler 使用的共享变量前应该声明
volatile
关键字,对于仅读写的简单类型最好使用sig_atomic_t
类型; - 在原程序、Signal Handler 操作共享变量时,应该阻塞其他所有信号,防止同进程的 interleaving 造成共享资源访问冲突;
- 不以信号接收次数来计信号发送次数;
- 对于含有
fork
+ 信号的程序设计,如果拿不准,建议画进程图,因为你不能假设父子进程的先后顺序,及时进行信号阻塞。这通常是造成共享资源访问冲突的常见点; - 最后,对于显式等待信号的问题,我们可以使用
sigsuspend
系统调用,保证 取消阻塞和暂停等待两步操作的原子性。
此外,我们还认识了进程组,一种关联多个进程,可以同时向进程组中所有进程发送信号的机制。
最后,列举一下我们在这一节中学习到了哪些系统调用(包括系统级函数):setpgrp
、setpgid
、getpgrp
、getpgid
、signal
、sigprocmask
、sigemptyset
、sigfillset
、sigaddset
、sigdelset
、sigsuspend
;
13.6 Non-local Jump
Powerful (but dangerous) user-level mechanism for transferring control to an arbitrary location.
略
Chapter 14 System Level I/O
本章将讨论操作系统较为底层的 I/O,在 Unix 和其他类型的操作系统上一样支持。
14.1 Unix I/O Overview
我们先讨论 Unix 上的 I/O 的原因是,比起其他的操作系统,Unix 中的 I/O 更加简单并且一致。我们都知道在 Unix 类系统上,一切皆文件,而这些文件本质上上一个 m bytes 的字节序列(a sequence of bytes),不去区分文件的类型,所以 Unix 操作系统实际上基本不了解文件内部的详细结构。它将文件看作存放在磁盘或外部存储介质上的某段数据,并且提供打开、写入、关闭等标准操作。
正因如此,即使是一些 I/O Device,甚至是操作系统内核也能抽象表示为具体的文件。
/dev/sdaN
: Unix 的 N 号磁盘分区;
/dev/ttyN
:Unix 的 N 号终端(为何叫 TTY?因为早期人们多使用 “电传打字机(teletype)” 用于描述打字机与计算机的接口);
/boot/vmlinuz-xxx-generic
:Unix 的内核镜像文件;
/proc
:Unix 的内核数据结构;
/var/run/*.sock
、/run/*.sock
、/dev/shm/*.sock
:Unix 的网络套接字文件;补充:什么是套接字(socket)?
在网络一章会深入讨论。简单来说,就是在互联网规范中,当机器通过互联网通信时,消息是一段通过写入套接字这个数据结构来发送的,另一端通过读取套接字的内容来接收的。
这样的简洁明了的抽象(elegant mapping of files to devices)就允许 Unix 类操作系统内核以一套简单的接口完成对文件和设备的访问。这套简单、核心的接口被成为 Unix I/O:
打开、关闭文件:
open()
、close()
;读写文件:
read()
、write()
;当前文件位置(注意,不是当前文件路径:是 current file position,而不是 current file path);
作用:indicates next offset into file to read or write(提示下一次向文件中写或读的字节偏移量,即下次从哪里读写);
接口:
lseek()
,改变当前文件指针的指向;特征:只有某些文件有这个接口。因为对那些文件而言,没法移动、备份和恢复先前的已读入的数据,也无法提前接收未写入的数据。
例如 socket 文件就是没有这种接口的,因为它无法在时间上进行跳转,只能在数据包进入时对其读写;
由于这些文件本质上还是不同的具体事物,在客观上有内在差别(就像一些类都继承于一个公共类,但它们终究需要实现不同功能)。这些文件属性上的差别则可以抽象为文件类型。
Unix 中的文件类型有以下几种:
- Regular file:普通文件(存储于磁盘驱动器上);
- Directory:一组特定文件的索引文件,其中的条目描述了其他文件的位置和属性;
- Socket:与另一台机器上的一个进程沟通的文件;
- Named pipes(FIFOs,先入先出型数据结构):管道流文件。Unix 上一些程序的输出,同时也是另外一些文件的输入;
- Symbolic links:符号链接。Unix 上又称软链接(与硬链接相对);
- Character and block devices:字符设备与块设备。其抽象在操作系统的设备访问层,其名称与实际物理设备特性无必然联系。
- 操作系统能够随机访问固定大小数据块(chunks)的设备称为块设备(例如磁盘、软盘、CD、flash memory 等);
- 操作系统只能按照字符流的方式有序访问的设备称为字符设备(例如串口、键盘等);
本章着重讨论前三种文件,因为它们比较常见。
14.2 Unix’s Files
14.2.1 Regular Files
普通文件可以包含任何类型的数据。一般情况下,操作系统并不会试图分析文件内部的细节,因此操作系统内核并不知道文本文件(plain text)和二进制文件(binary)之间的差别。
注意,区分文件内容是文本还是二进制数据通常发生在应用程序层级(更高级别)。
文本文件和二进制文件的差别:仅含有代表 ASCII / Unicode 字符的数据的文件被称为文本文件,否则被称为二进制文件;
二进制文件可以是:actual object file、图片音视频文件等等,它们包含直接以某种形式编码的字节序列;
文本文件有个重要特征,就是它们可以看作由一系列文本行(text lines)构成的文件。通常情况下,文本行是一个以 newline 字符结尾的字符(char)序列。
事实上,newline 字符在不同操作系统平台上定义不同,例如 Unix 上将
0xa
(\n
)代表的字符 line feed(LF)character 作为换行符,而 Windows 则约定以两个字符0xd 0xa
(\r\n
)代表的字符 Carriage Return && Line Feed(CRLF)character 作为换行符。二者的区别与历史中的 typewriter(打字机)有关,因为换行(垂直运动,即 Line Feed)和回车(水平复位运动,即 Carriage Return)是打字机作为机械设备在换到下一行必须要做的两个运动,Windows 保留了这层含义。
14.2.2 Directories
在 Unix 操作系统(或者说操作系统中的文件系统)中,每个目录文件包含了一个 “链接” 数组,每个 “链接” 建立了一个从文件名到文件的映射关系。
每一个目录文件包含至少两个 entries:.
(a link to itself,链接到自身)和 ..
(a link to the parent directory in the directory hierarchy,链接到目录层次中的上层目录);
Unix 的文件系统层次结构与 Linux 相近,因此 Unix 文件系统层次结构就不再赘述。
而 当前工作目录(current working directory,cwd
)是由 OS Kernel 维护的数据,每个进程下的不一定一致。可以通过使用 cd
改变当前进程的该数据;
在 Unix 和其他多数操作系统中,Pathnames(路径名)是文件层次结构中导向某个特定文件的导航方式,可以由目录文件的链接名称组成。
14.3 Basic Operations
本节的系统级函数和宏大多在 <fcntl.h>
(file control)中;
14.3.1 Opening Files
针对各类文件的基本操作方式之一是打开文件。它的实质是提醒 OS Kernel 已经做好访问该文件的一切准备。对此 Unix 有一个系统级函数 open
,常用声明如下:
1 | int open(const char* pathname, int flags, mode_t mode); |
第一参数可以是绝对路径,也可以是相对路径;
第二参数是 2 的某次幂的标志量(flag),允许按位运算。说明文件的打开方式(结合一些头文件,可以得到相关宏定义,例如
O_RDONLY
只读等);第三参数是文件权限位,即读取 / 创建文件的权限,可取的参数见官方文档:
返回值体现了一个非常重要的思想:文件描述符。如果文件打开错误,则返回 -1(I/O 操作的失败情况比普通情况多很多,一定在实际使用描述符前检查是否成功打开);
什么是文件描述符(file descriptor)?
文件标识符是用来标识当前程序正在操作的某个已打开文件的小整型(这个小整型只有 0 ~ 1024 的范围)。
它们是按照打开顺序依此编号(从运行程序开始编号),所以大部分机器有最大打开文件数量的限制。这意味着打开了超过限制数量的文件将会造成文件资源泄漏。其中机器各方面的限制可以由以下指令查看:
1
ulimit -a
另外一个值得注意的点是,在每个进程一创建的时候就会有 3 个已创建的文件:0(
stdin
)、1(stdout
)、2(stderr
),它们都是由 Unix Shell 打开并创建的(请回忆前一章的进程树并考虑为什么)。其他具体内容将在 17.5.2 中讨论。
注:本章的系统级函数都非常底层,有些函数直接使用整型文件描述符。为了使用规范,如果你想用 stdin/stdout/stderr
这类文件时,请不要直接使用 0/1/2
,更建议使用宏 STDIN_FILENO/STDOUT_FILENO/STDERR_FILENO
;
14.3.2 Closing Files
关闭文件的系统级函数声明如下:
1 | int close(int file_descriptor); |
你可能会好奇,为什么这个也要有返回值,难道关闭一个文件也会报错?答案是肯定的。
在多线程编程中(就像之前提到的,它们会共享内存和数据结构),可能会出现关闭一个已经被关闭的文件的情况,这种情况也会发生问题。
14.3.3 Reading Files & Writing Files
Unix 系统级函数提供了一种块读取和块写入的方式,即从当前文件指针位置开始,向后指定长度的空间的数据读入缓冲区 / 向文件写缓冲区内容,并且更新文件指针:
1 | ssize_t read(int file_descriptor, void *buffer, size_t buf_size); |
值得注意的是,如果正确运行,那么 read
/ write
的返回值是实际读取 / 写入的字节数(因为从当前文件指针到最后不一定有 buf_size
大小的数据,这种情况称之为 short read / short write);错误则返回 -1;而(对于 read
而言)如果返回 0,说明已经到达 EOF
;
这里介绍一个之前提到的非常有名的工具
strace
;这个工具的功能非常强大,不过现在我们先介绍一些简单的使用:
1 strace <prog>以上指令在运行指定程序的同时,会跟踪并向
stdout
打印程序使用到的所有系统调用(system call)情况;有时候看到很乱的情况,可能是终端上
stderr
和stdout
交织输出的原因。如果加上参数:
1 strace -e trace=<syscall_name[,...]> <prog>那么就仅仅追踪指定名称的系统调用情况。
那么,什么时候会出现 short read / short write?
- 读文件时遇到 EOF;
- 从终端读一个文本行;
- 读写网络套接字;
- ……
其实,short read / short write 一直是应用程序层面较为棘手的问题,所以我们一般看到很多涉及 I/O 的库几乎都将这个低层级的 I/O 接口封装起来。
为什么棘手?
其实,short read / write 在普通程序中的问题还影响不大,因为大多数是向磁盘中写文件,而向磁盘写不会出现 EOF,因此没有 short write;从磁盘读发生 short read 只要跳出读循环就行,影响也不大)。
但是在网络套接字的读写方面影响很大。考虑一个场景,你要用 socket 发一个网络包,但是写不下的值还要判断,而且还有可能因为其他偶然原因触发
EINTR
,并且在循环中重新拿出来再发一次。不仅如此,网络向 socket 中发送可能是一部分一部分收到的,因此可能短读后还能继续读!
14.4 The RIO Package
为了解决 short read / short write 对应用程序编程开发带来的麻烦,有一种对 I/O 接口的封装方式是 CMU 教授开发的 RIO Package. 这个包提供了对于底层的 Unix I/O 的包装,能够使得程序在处理 I/O 方面有较强的健壮性(robust)和较好的效率,尤其是后面章节要讨论的、受 short read / write 影响较大的网络编程。
RIO Package 提供了 2 种不同级别的 I/O 文件接口。
其中,较低级别的 I/O 接口只是对上面说到的低级 Unix I/O 进行了简单的封装,以应对 short read / write 的情况;函数 rio_readn
和 rio_writen
就是这样较低级别的封装。
1 | ssize_t rio_readn(int fd, void *usrbuf, size_t n); |
这两个函数属于对二进制数据的非缓冲式的读入和输出(unbuffered input & output of binary data),在未读够 / 未写够指定字节数的数据之前不会返回。对于 rio_readn
,如果是处理具有很多数据的网络套接字,那么在当前套接字读完、总体数据未读完的情况下挂起并等待;如果是读到了与给定字节数不同的时候出现 EOF,那么会返回错误;
对于 rio_writen
,对于使用者的情况会比 rio_readn
简单些,所以也只需要包装到这个层次即可,因为使用者只需担心网络问题,这个函数本身会在要求的字节数内通过循环发送套接字(因为一个 socket 规范只有 1500 bytes 左右,具体大小取决于它位于哪个协议层);
另一类是带缓冲区的 I/O 接口(rio_readinitb
、rio_readlineb
、rio_readnb
),比前一类封装更高级一些,也是很多库对于 Unix I/O 常见的包装形式。它们的做法是在用户代码空间创建一个小型缓冲区(mini-buffer),用来存放已读入但未被应用程序使用的 bytes,或者为程序创建一块空间以便未来输出到文件或网络中;
这种缓冲区的思想也存在与计算机的相当多的方面。
带缓冲区的 RIO 有两种,一种是基于文本的,另一种是基于字节(二进制数据)的。如下代码注释,可见,在网络套接字的文本行阅读方面,rio_readlineb
非常有用。
1 | void rio_readinitb(rio_t *rp, int fd); |
这种 RIO 库的健壮性还在于,rio_readlineb
和 rio_readnb
允许对一个文件描述符进行交织运行(多线程中对一个文件描述符),但别和 rio_readn
连用;
至于含缓冲区的 RIO 的实现也不难,它的目标就是设计一个读取内容的缓冲地带,让重复的读取内容不至于每次访问 I/O 都使用系统调用;举个例子:
如上图,假设程序想要读取系统上的一个 Unix file,那么该文件从头至 current file position 就是我们想要的 buffered portion;在读取的时候,rio_readnb/rio_readlineb
会按 buffered portion 的大小在 user code space 中创建一个同等大小的缓冲区(上图 buffer),由 rio_buf
指针管理这片空间的起始地址,由 rio_bufptr
管理当前程序读到 buffer 的哪里;而 rio_cnt
则代表当前还有多少数据没有读入缓冲区;
因此,根据 rio buffer
的使用分析,我们发现维护 rio_t
的结构体应该是这样的:
1 | typedef struct { |
更多的内容大家可以通过阅读 RIO Package 的源码获取,并且可以在此基础上包装属于自己的 routines;
14.5 File Metadata, Sharing and Redirection
14.5.1 Metadata
几乎所有操作系统平台上,每个文件中都有一个非常重要的部分是文件元数据(file metadata)。所谓 metadata 就是文件中实际包含的数据的信息,例如应用层级的文件类型信息、文件权限信息(R/W/X)、文件所有权信息、创建/访问/修改时间……
什么?你说 Windows 上创建一个文本文件,然后把后缀名删了,好像就没有了?其实操作系统在创建文件、在显示到桌面之前就将文件元信息设置好了,不信你看看右击属性。
在 Unix 系统中,这种 metadata 以一个结构体 stat
进行存储,这种结构体类型也是 C library 函数 stat
、fstat
(查看文件元数据的函数)的返回值类型:
1 |
|
如何用这些数据?一般情况下,我们可以借助一些 C library 内置宏来检查数据的含义。这里不多赘述,以一个程序为例:
1 | int main(int argc, char *argv[]) { |
知识补充:Unix 系统中的 man page 的正确使用方法。
我们都知道,Unix 中的
man
指令相当于一个帮助文档,通常情况下,man <fname>
会进入fname
所添加的帮助文档的第一章中。你可以使用man <chapterN> <fname>
来指定查看第几章的fname
文档。在 man 程序管理帮助文档的规范中,每章的内容有明确的使用范围:
man 的第一章通常介绍
fname
作为一个系统指令(编译为了一个二进制文件放在系统中)的使用方法,通常包含一些该命令的命令行技巧和参数;man 的第二章通常介绍
fname
作为一个系统级函数/系统调用在源码中的使用方法,通常包含了一些该函数的 API 文档内容和声明;有些
fname
既在 Unix 中包装成了二进制的程序供命令行使用,又在头文件和系统的链接库中存在,以供源码使用(例如kill
),所有会同时存在这两章。man 还有更多的章节,一般到第 8 章,其中的含义可以自行搜寻。
14.5.2 File Sharing & File Descriptor
之前我们用了很长时间的 “file descriptor” 这个名词,接下来将讨论一下所有的文件在程序中如何用 file descriptor 进行标识,或者说,背后的机制是怎样的。
⚠ 这里是考试的难点!!!光听老师讲、看这部分的信息,想把题目做对是不够的!需要自行研究习题和历年考题。
OS 内部很多内部数据结构都与具体执行中的进程有关,例如前面提到的页表、用户栈、OS Kernel 等等,它们都存放在这个进程对应的虚拟内存中。
文件描述符也不例外,每个进程都会在其虚拟内存中维护唯一一个描述符表(descriptor table)。
还有两种非常特殊的数据结构,一种是 文件表(open file table),另一种是 虚拟结点表(v-node table);它们被计算机中所有进程共享;
如图所示:
很早之前我们就了解过,文件描述符为 0、1、2 的特殊含义,这里不再赘述。
我们需要注意以下几点:
v-Node Table 就是 Unix files 的
stat
结构体的表,每个 v-Node Table 与物理存储器上的文件一一对应(双射),无论文件是否被打开;Descriptor Table 各个 entry 的内容存放的是指向各个 Open File Table 的指针,也表示当前进程已打开但未关闭的文件。而描述符相当于是对 Descriptor Table 的索引;
Open File Tables 由 OS Kernel 维护。每个 Open File Table 的第一个字段即为指向 v-Node Table 的指针,与每个 v-Node Table 的关系必然是函数关系,但既不是单射也不是满射;
即:
对任意一个 Open File Table 而言,它必然指向一个唯一的 v-Node Table(即物理文件);
存在两个 Open File Table 它们指向同一个物理文件(也就是同一个 v-Node Table),但他们本身不一定完全相同,因为它们的字段
File pos
是分别由各个打开进程维护的。这意味着程序调用了两次
open
,但是参数是同一个 filename(再次强调:可能在不同的进程中,而且File pos
不一定相同),如下图:
允许一个 v-Node Table 不被任何 Open File Table 指向。
这意味着这个物理文件还没有被程序的任何进程打开过。
每个 Open File Table 的第二个字段是
refcnt
(reference count,引用计数),这个字段表明这个 Open File Table 被描述符表中的多少个 entry 指向;为什么要有这项数据?
因为在程序中,可能出现多进程(尤其是
fork()
产生)共享文件资源的情况,这时 OS 需要追踪内存分配,如果程序结束时,OS 需要回收这些部分(引用次数为 0 时不会立即清除)。堆的内存管理也有这种机制,不过比这个复杂很多。
有个相当重要的点,回忆一下,之前讨论 fork
的时候提到,创建的子进程总是可以直接继承使用父进程的文件描述符,达到二者共享文件资源的目的。但是,之前说描述符表是由每个进程的 OS Kernel 单独维护的。
那么这样的情况下,父子进程共享的文件资源是如何实现的?如下图过程:
总结一下父子进程共享文件描述符的要点:
- 子进程完全复制父进程的描述符表;
- 父进程描述符表中指向的所有文件表的引用计数各加 1;
因此我们得知,父子进程共享的不是物理文件,而是 Open File Tables(共用了文件指针);这意味着父子进程任意一方读写文件,二者的文件指针一起变化;
⚠ 最重要的是,每个进程都必须显式调用 close
(除了 0、1、2 号文件),才能最终使引用计数为 0,操作系统才能回收。
14.5.3 I/O Redirection
再思考一个问题,Unix Shell 是如何实现 I/O 管道流和重定向的功能的呢?实际上,这个功能的实现也与文件描述符表有关。我们以一个例子为例:
1 | ls > foo.txt |
这个指令将 ls
命令输出的结果重定向到 foo.txt
文件中,实际在 shell 的源码中应该使用了一个特殊的系统级函数(再次提示,系统级函数是系统调用的封装):dup2
;
1 | int dup2(int oldfd, int newfd); |
- 作用:先将描述符为
newfd
的文件关闭以释放资源,再将描述符为oldfd
的 descriptor table entry 复制到指定描述符为newfd
的 entry 中;- ⚠ 如果
oldfd
是无效的描述符(即 descriptor table 在该位置的 entry 不指向有效的 open file table),则dup2
执行错误,这个时候newfd
对应的资源不会关闭; - ⚠ 如果
oldfd
是有效的描述符,但是newfd == oldfd
,那么dup2
什么都不会做,直接返回newfd
;
- ⚠ 如果
- 返回值:如果正确执行,则返回更新后的
newfd
;如果执行错误,则返回 -1 并且设置errno
;
切记,这条指令相当危险,除非你是在设计与操作系统层级很近的应用程序(例如 shell),否则不用轻易使用它,因为它能轻易将你绕晕,不知道哪些文件还没有释放。
14.5.4 Standard I/O
这里的库是 C standard library 包装的一些更高层级的 I/O 接口,它和之前我们接触到的 Unix I/O、RIO 的关系如下:
这些标准 I/O 实际上是 C 的一部分。先归一归类,大家都比较熟悉了:
- Opening and closing files(
fopen
、fclose
); - Reading and writing files(
fread
、fwrite
); - Reading and writing text files(
fgets
、fputs
); - Formatted reading and writing(
fscanf
、fprintf
);
不仅如此,你见到的很多 standard I/O 都带有 buffering,所以规避了很多低层级的操作。
那么 standard I/O 的 buffer 和 RIO 的 buffer 有什么不同呢?
在功能上,standard I/O 对于终端文件(terminal)或普通文件的访问方面包装显然要远远优于 RIO;但是 standard I/O 没有考虑到一些网络套接字方面的细节和小问题,所以在网络套接字的读写方面用起来还是 RIO 更胜一筹。
在 buffer 设计上,standard I/O buffer 有一套 flush 机制。
对于写的情况,standard I/O 的 buffer 仅当出现以下情况之一时,才将 buffer 整体写入 Unix file,这么做可以减少系统调用次数,提升程序性能:
- 标准输出函数(
fprintf/sprintf/...
)的结尾含有\n
换行(换行结尾); - Standard I/O 内部的 buffer 已经写满(缓冲占满);
- 执行标准输出函数的进程从
main
函数退出了(程序结束); - 程序显式地调用
fflush(FILE*)
刷新缓冲区(手动刷新);
而 RIO 设定了固定大小的 buffer,并根据用户输入的读取或写入的大小分次进行系统调用,二者各有利弊。
综上,RIO 比 standard I/O 更适宜用在网络套接字读写方面,而 standard I/O 则在其他大部分文件读写的情况下表现更加优秀。
14.6 Summary of System I/O
本章开始,我们介绍了 Unix File 的概念和常见类型。对于 Unix File 的基本操作,则被操作系统抽象成了 Unix I/O(有系统级函数、系统调用),这些操作非常底层,不过有优势也有劣势:
- Pros
- Unix I/O 是最通用、开销最小的 I/O 接口形式(其他所有 I/O 库都基于此);
- Unix I/O 提供了一系列访问文件 metadata 的函数(
stat
、fstat
); - ⚠ 重大优点:它们都是 异步信号安全 的,可以用在 signal handlers 中;
- Cons
- 应对 short counts 的处理很麻烦(尤其是应对
EINTR
和网络传输时),容易出错; - 想要按照文本行读取出一行也很麻烦,也易错;
- 应对 short counts 的处理很麻烦(尤其是应对
Standard I/O 非常优秀,同样有它的优缺点:
- Pros
- 使用特殊的 buffer 机制,减少了系统调用的访问次数;
- 自动解决 short counts 的异常问题;
- Cons
- 不提供访问文件元信息的接口;
- 其中的函数几乎都不是异步信号安全的函数;
- 不适宜用在读写网络套接字上,很容易出错;
最后,根据各个 I/O 的封装特性和抽象层级,我们可以总结出这些 I/O 库的选择注意事项 和 推荐:
⚠ 注意事项 ⚠
在条件允许的情况下,尽可能使用抽象层级高的 I/O 库;
这样可以避免一些诸如
EINTR
(之前提到,这个系统错误码是因为运气不好,重新调用一次就能修复)等底层奇奇怪怪的信息或错误;使用 I/O 库的接口前,一定弄清楚接口的具体作用和逻辑;
例如读二进制文件不能用 识别文本信息 的接口(例如用
rio_readlineb
去读图片、用strlen/strcpy
去操作 socket 数据);因为大多数识别文本信息的接口,尤其是按行输入的,会识别文本中的换行符(
0xa
或0xd 0xa
,即EOL
,end of line),并以此分割读入;不仅如此,它们还会把 byte value 0 解释为文本结束(end of string),这样会导致读入操作提前结束。而
0x0
只不过是二进制数据中一个数据而已,只有字符串是以 0 结尾的。
Standard I/O 和 RIO 不应该混合使用!因为二者内部维护的 buffer 不同,在运行中可能出现干扰和错误;
ℹ 使用建议 ℹ
- 当 I/O 操作的对象是 disk / terminal files 的时候,使用 Standard I/O 最佳;
- 当需要一些尽量底层的操作(例如写 signal handlers 时),或者极其需要程序性能的时候(少见),使用 raw I/O(Unix I/O);
- 当 I/O 操作的对象是网络 socket 文件时,最好使用 RIO 来处理一些特殊的情况,例如
EINTR
和针对网络的 short counts 的处理;
补充知识:操作目录文件
唯一推荐对 directory 的操作:打开、读取 entries;
看下面的这个例子:
1 |
|