信号
我们已经认识到了操作系统怎么通过异常使得进程上下文切换,以实现异常控制流的实现。现在我们将尝试另一种实现——Linux信号,来允许进程和内核中断其他的进程。
我们可以将信号理解成一条消息,它通知进程系统中发生了某一个事件。每种信号类型都会对应于某种系统事件。然后由不同的处理程序去处理这个事件。我们可以通过man 7 signal
来进一步的认识这些信号:

信号术语
传送一个信号到目的进程可以分作两个步骤:
- 发送信号:内核通过更新目的进程的上下文中的某个状态,从而实现发送一个信号给目的进程。发送信号一般有以下两种原因:1)内核检测到了一个系统事件,2)一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程
- 接收信号:当目的进程被内核强迫对信号的发送做出反应时,我们就说它接受了信号。目的进程可以忽略这个信号,终止,或者通过执行信号处理程序的用户层来捕获这个信号。

一个发出但没有被接收的信号我们称之为待处理信号。在任何时刻一个类型只会有一个待处理信号。如果一个进程中有一个类型为k的待处理信号。接下来任何发送到这个进程的类型为k的信号都不会再排队等候,而是被丢弃。同时一个进程可以选择阻塞接受某种信号。如果一个信号被阻塞,它可以发送,但是不会再被接收,直到取消对它的阻塞。
这个实现通过内核为每个进程维护这一个信号处理集合实现。在pending
向量中维护着一个待处理信号的集合,在blocked
向量中维护着一个阻塞信号的集合。当一个信号类型为k
的信号被发送时,目的进程会检查其pending
位是否已被设置,若是则丢弃;不是则设置。然后检查其block
位是否被阻塞,若是则丢弃;若不是则接受信号,并清除pending
位。
发送信号
发送信号的机制,是基于进程组实现的。我们接下来进一步的理解信号的发送:
进程组
每个进程都只属于一个进程组。进程组是由一个正整数进程组ID来标识的。我们有以下函数可以认识并改变进程组:
1 |
|
默认情况下,子进程和它的父进程是同属于一个进程组的。一个进程可以通过使用setpgid
函数来改变自己或者其他进程的进程组。setpgid
函数将pid(目标进程PID)
进程加入到进程组pgid(目标进程组ID)
。如果pid
是0,就表示当前进程。如果pgid
是0,就用pid
的值作为新的pgid
。
kill程序发送信号
/bin/kill
程序可以向其他程序发送任意的信号:
1 | kill -<signalNumber> <pid> |
也可以向指定的进程组中的所有进程发送信号:
1 | kill -<signalNumber> -<pgid> |
我们可以尝试一下:

此外我们也可以通过键盘发送信号
从键盘发送信号
Uinx shell
中通常使用job
(作业)来表示对一条命令行求值而创建的进程。在任何时刻,只有一个前台作业和0或多个后台作业。其中每个作业的都属于一个独立的进程组。
在键盘上Ctrl+C
会导致内核发送一个SIGINT
信号到前台进程组中的每个进程,导致作业终止。在键盘上Ctrl+Z
会导致内核发送一个SIGTSTP
信号到前台进程组中的每个进程,导致作业被停止(挂起)。
用kill函数发送信号
1 |
|
通过调用kill函数发送信号到其他进程。
- 如果
pid>0
,那么发送信号sig
给进程pid
。 pid=0
,那么发送信号sig
给调用进程所在进程组中的每个进程,包括自己。pid<0
,则发送信号sig
给进程组|pid|
中的每个进程
我们可以尝试编写一个程序来使用kill函数:
1 |
|
这样就实现了父进程击杀自己的子进程。
用alarm函数发送信号
进程可以通过调用alarm
函数向自己发送SIGALRM
信号
1 |
|
alarm
函数安排内核在secs
秒之后发送一个SIGALRM
信号给调用进程。如果secs==0
,那么不会安排调度新的闹钟。在任何情况下,alarm
的调用都会取消之前的待处理的闹钟,并返回前一个闹钟的剩余的秒数。
接收信号
当内核把进程p
从内核态切换到用户态时,它会检查进程p
的未被阻塞的待处理信号的集合pending & ~blocked
。如果这个集合为空,那么内核将控制传递到p
的逻辑控制流中的下一条指令。如果集合使非空的,那么内核选择集合中的某个信号k
(通常是最小的k
),并强制p
接收信号k
。收到这个信号会触发某种行为。一旦进程完成这个行为,就会将控制传递回p
的逻辑控制流中的下一条指令。
在上面展示信号类型的图中,也有每个信号类型相关联的默认行为。我们也可以通过signal
函数修改和信号相关联的默认行为。唯一例外的是SIGSTOP
和SIGKILL
。它们的默认行为是不能修改的。
1 |
|
signal
函数通过以下三种方式之一来改变和信号signum
相关联的行为:
- 如果
handler
是SIG_IGN,那么忽略这个类型的信号 - 如果
handler
是SIG_DFL,那么恢复这个类型的信号的默认行为 - 否则,
handler
就是用户定义的函数的地址,这个函数被称为信号处理程序。当接收到指定的信号类型时就会调用信号处理程序,我们称之为捕获信号。一个处理程序可以捕获不同的信号。
比如我们可以写一个程序用来改变SIGINT
信号的默认行为(终止进程):
1 |
|
当然信号处理程序也可以被其他信号处理程序中断。例如在下图中演示了这个过程:

阻塞和解除阻塞信号
Linux提供了两种阻塞信号的机制:
- 隐式阻塞机制 内核默认阻塞任何当前处理程序正在处理的信号类型的待处理信号。
- 显式阻塞机制
使用
sigprocmask
函数和它的辅助函数,明确阻塞/解除指定的信号。
1 |
|
sigprocmask
函数改变当前阻塞的信号集合(blocked位向量)。其具体的行为依赖于how
值:
- SIG_BLOCK 把set中的信号添加到blocked中
blocked = set|blocked
- SIG_UNBLOCK 从blocked中删除set中的信号
blocked = blocked & ~set
- SIG_SETMASK 令
blocked = set
如果oldset
非空,则将之前的blocked
保存在其中。对于其他的几个辅助函数:
sigemptyset
初始化set为空集合sigfillset
将每个信号都添加到set中sigaddset
将signum加入到set中sigdelset
从set中删除signum,如果signum是set的成员,返回1。不是则返回0
编写信号处理程序
这一部分太难了,我难以理解并接受。之后再来看看吧
显式地等待信号
和上面的关联度较高,涉及到竞争并发等内容,我暂时无法理解
非本地跳转
C语言提供了一种用户级的异常控制流形式,即非本地跳转(本地跳转是goto
),它将控制从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。其中非本地跳转是通过setjmp
和longjmp
实现的。
1 |
|
setjmp
函数会在env缓冲区中保存当前的调用环境(相当于设置一个锚点,保存当前状态),以供后面的longjmp
使用,并返回0。注意setjump
由于其特殊的返回机制,不能被存储在变量之中,但是可以被switch使用。
1 |
|
longjmp
函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp
调用的返回。然后setjmp
返回,并带有非零的返回值retval
注意到,setjmp只被执行一次,但是会返回多次:一次是第一次调用setjmp
时,调用环境保存在缓冲区env
中。一次时为每个相应的longjmp
调用。另一方面,longjmp
被调用一次,但是不返回。
通过非本地条状我们可以实现从一个深层嵌套的函数调用中立即返回,从而实现对错误的分析,而不用多次退出复杂的调用栈。我们以下面的程序为例,可以感受到非本地跳转的用途:
1 |
|
虽然C中并没有异常的捕获函数,但是我们可以通过这种方式去实现。当遇到一个错误是,从setjmp返回,并解析它的错误类型。
同时,也要注意。longjmp
允许跳过中间调用机制的过程可能回导致许多意外的后果。比如没有释放一些数据结构,导致内存泄露….
写在最后
关于异常控制流我感觉还是比较抽象的。涉及到的函数很多,尤其是信号部分,牵连到许多并发相关的内容。对于现在的我而言还是太过超前,日后再来巩固。