操作系统基础 | 5.3 信号;fork
信号的概念
信号(Signal)是通知进程已发生某种事件的一种机制。信号有时被描述为软件中断(software interrupts)。信号与硬件中断类似,因为它们会中断程序的正常执行流程;在大多数情况下,无法精确预测信号何时到达。一个进程(如果它具有适当的权限)可以向另一个进程发送信号。这种用途下,信号可以作为一种同步技术,甚至作为一种原始的进程间通信(IPC) 形式。进程也可以向自己发送信号。
然而,传递给进程的许多信号的通常来源是内核(kernel)。导致内核为进程生成信号的事件类型包括:
- 发生硬件异常:这意味着硬件检测到故障条件并通知内核,内核随后向相关进程发送相应的信号。硬件异常的例子包括:执行格式错误的机器语言指令、除以0、或引用了无法访问的内存区域。
- 用户键入了能生成信号的终端特殊字符。这些字符包括中断字符(通常是
Control-C
)和挂起字符(通常是Control-Z
)。 - 发生软件事件。例如:文件描述符上有输入可用、终端窗口大小改变、定时器超时、进程的CPU时间限制已超过、或该进程的一个子进程终止。
每个信号都被定义为一个唯一的(小)整数,从1开始顺序编号。这些整数在
<signal.h>
头文件中用 SIGxxxx
形式的符号名定义。由于每个信号使用的实际数字因实现而异,因此在程序中总是使用这些符号名称。例如,当用户键入中断字符时,SIGINT
(信号编号2)被传递给进程。
信号分为两大类。第一组构成了传统或标准信号(standard signals),内核使用它们来通知进程事件。在Linux上,标准信号编号从1到31。本章我们描述标准信号。另一组信号由实时信号(realtime signals)组成,其与标准信号的区别将在第22.8节描述。
信号被认为是由某个事件产生(generated)。一旦产生,信号随后会被递送(delivered)给一个进程,该进程随后会采取某些动作(action)来响应信号。在信号产生和递送之间的时间段,信号被称为处于等待状态(pending)。通常,一个等待中的信号会在进程下一次被调度运行时立即递送,如果进程已经在运行则立即递送(例如,进程向自己发送信号)。
然而,有时我们需要确保一段代码不会因信号的递送而中断。为此,我们可以将一个信号添加到进程的信号掩码(signal mask)中——这是一组当前被阻塞(blocked)递送的信号。如果一个信号在阻塞状态下产生,它将保持等待状态,直到后来被解除阻塞(unblocked)(从信号掩码中移除)。各种系统调用允许进程向其信号掩码中添加和移除信号。
根据信号的不同,信号被递送时,进程会执行以下默认动作(default actions)之一:
- 忽略信号(Ignored):即信号被内核丢弃,对进程没有影响。(进程甚至不知道它发生了。)
- 进程被终止(Terminated)(杀死)。这有时被称为异常进程终止,与进程使用
exit()
终止的正常进程终止相对。 - 生成核心转储文件(Core dump file)且进程被终止。核心转储文件包含进程虚拟内存的一个映像,可以将其加载到调试器中,以检查进程终止时的状态。
- 进程被停止(Stopped)——进程的执行被暂停。
- 进程被恢复(Resumed)执行——在之前被停止后恢复执行。
程序可以改变信号递送时发生的动作,而不是接受特定信号的默认动作。这被称为设置信号的处置方式(disposition)。程序可以为信号设置以下处置方式之一:
- 发生默认动作。这对于撤销之前将信号处置方式更改为非默认值的操作很有用。
- 忽略信号。这对于那些默认动作是终止进程的信号很有用。
- 执行一个信号处理程序(signal
handler)。信号处理程序是由程序员编写的函数,它执行适当的任务以响应信号的递送。例如,shell
有一个用于
SIGINT
信号(由中断字符Control-C
产生)的处理程序,该处理程序使其停止当前正在做的事情并将控制权返回给主输入循环,从而再次向用户显示 shell 提示符(用户按下Control-C
-shell中断当前处理-用户可以再次在shell中输入指令了)。通知内核应调用某个处理函数通常被称为安装(installing)或建立(establishing)一个信号处理程序。当信号处理程序因信号递送而被调用时,我们说信号已被处理(handled)或,同义词,被捕获(caught)。 注意:不可能将信号的处置方式设置为终止或转储核心(除非其中一个是该信号的默认处置方式)。最接近这一点的是为该信号安装一个处理程序,然后该处理程序调用exit()
或abort()
。abort()
函数(第21.2.2节)为进程生成一个SIGABRT
信号,这会导致其转储核心并终止。
Linux特有的 /proc/PID/status
文件包含各种位掩码字段,可以检查这些字段以确定进程对信号的处理情况。位掩码以十六进制数显示,最低有效位代表信号1,左边下一位代表信号2,依此类推。这些字段是:
* SigPnd
(线程内等待信号,per-thread pending signals) *
ShdPnd
(进程范围内等待信号,process-wide pending
signals;自Linux 2.6起) * SigBlk
(阻塞信号,blocked
signals) * SigIgn
(忽略信号,ignored signals) *
SigCgt
(捕获信号,caught signals)。
(当我们第33.2节描述多线程进程中的信号处理时,SigPnd
和
ShdPnd
字段之间的区别将变得清晰。)同样的信息也可以使用
ps(1)
命令的各种选项来获取。
fork()
,
exit()
, wait()
和 execve()
概述
fork()
fork()
系统调用允许一个进程(称为父进程)创建一个新的进程(称为子进程)。这是通过使新的子进程成为父进程的(近乎)完全副本来实现的:子进程获取父进程栈、数据、堆和文本段(第6.3节)的副本。“Fork”一词源于我们可以将父进程视为分裂 (forking) 以产生自身的两个副本这一构想。exit(status)
exit()
库函数终止一个进程,使该进程使用的所有资源(内存、打开的文件描述符等)可供内核后续重新分配。status
参数是一个整数,用于确定进程的终止状态。通过wait()
系统调用,父进程可以检索此状态。exit()
库函数是基于_exit()
系统调用构建的。在第25章,我们将解释这两个接口之间的区别。在此我们只需注意,在fork()
之后,通常只有父进程和子进程中的一个通过调用exit()
终止;另一个进程应使用_exit()
终止。wait(&status)
wait(&status)
系统调用有两个目的。首先,如果该进程的某个子进程尚未调用exit()
终止,那么wait()
会暂停该进程的执行,直到它的一个子进程终止为止。其次,子进程的终止状态通过wait()
的status
参数返回。execve(pathname, argv, envp)
execve(pathname, argv, envp)
系统调用将一个新的程序(pathname
,带有参数列表argv
和环境列表envp
)加载到一个进程的内存中。现有的程序文本被丢弃,并为新程序全新创建栈、数据和堆段。此操作通常被称为 execing 一个新程序。后面我们会看到,有几个库函数是基于execve()
构建的,每个函数都在编程接口上提供了有用的变体。当我们不关心这些接口变体时,我们遵循通用惯例,将这些调用统称为exec()
,但请注意,并没有叫这个名字的系统调用或库函数。
与其他系统的对比: 一些其他操作系统将
fork()
和 exec()
的功能组合到单个操作中——即所谓的
spawn——该操作创建一个新进程然后执行指定的程序。相比之下,UNIX
的方法通常更简单、更优雅。将这两个步骤分开使得 API
更简单(fork()
系统调用不需要参数),并且允许程序在两个步骤之间执行的操作具有极大的灵活性。此外,只进行
fork()
而不接着执行 exec()
通常也很有用。
SUSv3 规定了可选的 posix_spawn()
函数,它结合了
fork()
和 exec()
的效果。此函数以及 SUSv3
规定的几个相关 API 已在 glibc 中为 Linux 实现。SUSv3 规定
posix_spawn()
是为了允许为那些不提供交换设施或内存管理单元(这在许多嵌入式系统中很典型)的硬件架构编写可移植应用程序。在此类架构上,传统的
fork()
难以或无法实现。
协同工作概述: fork()
,
exit()
, wait()
, 和 execve()
通常是如何一起使用的。(shell
持续执行一个循环,该循环读取命令、对其进行各种处理,然后 fork
一个子进程来 exec 该命令。)
创建新进程:fork()
fork()
系统调用创建一个新的进程,即子进程,它是调用进程,即父进程的一个几乎完全相同的副本。
理解 fork()
的关键在于认识到,在它完成工作后,存在两个进程,并且在每个进程中,执行都从
fork()
返回的地方继续。两个进程执行相同的程序代码,但它们拥有独立的栈、数据和堆段副本。子进程的栈、数据和堆段最初是父进程内存相应部分的精确副本。在
fork()
之后,每个进程都可以修改其栈、数据和堆段中的变量,而不会影响另一个进程。
在程序代码中,我们可以通过 fork()
的返回值来区分这两个进程:
- 对于父进程,
fork()
返回新创建子进程的进程ID (PID)。这很有用,因为父进程可能会创建多个子进程,并因此需要(通过wait()
或其相关函数)跟踪它们。 - 对于子进程,
fork()
返回 0。 - 如果无法创建新进程,
fork()
返回 -1。失败的可能原因包括:已达到允许该(真实)用户ID创建的进程数的资源限制(RLIMIT_NPROC
,在第36.3节描述),或者已达到系统范围内可创建进程数的上限。
必要时,子进程可以使用 getpid()
获取自身的进程ID,使用
getppid()
获取其父进程的进程ID。
调用 fork()
时有时会使用以下惯用法:
1 | pid_t childPid; /* 在父进程中用于记录成功 fork() 后的子进程 PID */ |
重要的是要认识到,在 fork()
之后,无法确定接下来是哪个进程被调度使用CPU。在编写不佳的程序中,这种不确定性可能导致称为竞争条件
(race conditions) 的错误,我们将在第24.4节进一步描述。
代码清单24-1演示了 fork()
的用法。该程序创建一个子进程,修改它在 fork()
期间继承的全局变量和自动变量的副本。在程序中(由父进程执行的代码中)使用
sleep()
,是为了让子进程能在父进程之前被调度到CPU上,从而使子进程可以在父进程继续执行之前完成其工作并终止。使用
sleep()
这种方式并不是保证此结果的万无一失的方法;我们将在第24.5节探讨一种更好的方法。
代码清单 24-1: 使用 fork() 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
static int idata = 111; /* 分配在数据段 (data segment) */
int
main(int argc, char *argv[])
{
int istack = 222; /* 分配在栈段 (stack segment) */
pid_t childPid;
switch (childPid = fork()) {
case -1:
errExit("fork");
case 0: /* 子进程分支 */
idata *= 3; /* 修改继承的变量副本 */
istack *= 3; /* 修改继承的变量副本 */
break;
default: /* 父进程分支 */
sleep(3); /* 给子进程一个执行的机会 */
break;
}
/* 父进程和子进程都会执行到这里 */
printf("PID=%ld %s idata=%d istack=%d\n", (long) getpid(),
(childPid == 0) ? "(child) " : "(parent)", idata, istack);
exit(EXIT_SUCCESS);
}1
2
3$ ./t_fork
PID=28557 (child) idata=333 istack=666
PID=28556 (parent) idata=111 istack=222fork()
时获得了栈段和数据段的自有副本,并且它能够修改这些段中的变量而不影响父进程。