(八) 一起学 Unix 环境高级编程 (APUE) 之 信号

.

.

.

.

.

目录

(一) 一起学 Unix 环境高级编程 (APUE) 之 标准IO

(二) 一起学 Unix 环境高级编程 (APUE) 之 文件 IO

(三) 一起学 Unix 环境高级编程 (APUE) 之 文件和目录

(四) 一起学 Unix 环境高级编程 (APUE) 之 系统数据文件和信息

(五) 一起学 Unix 环境高级编程 (APUE) 之 进程环境

(六) 一起学 Unix 环境高级编程 (APUE) 之 进程控制

(七) 一起学 Unix 环境高级编程 (APUE) 之 进程关系 和 守护进程

(八) 一起学 Unix 环境高级编程 (APUE) 之 信号

(九) 一起学 Unix 环境高级编程 (APUE) 之 线程

(十) 一起学 Unix 环境高级编程 (APUE) 之 线程控制

(十一) 一起学 Unix 环境高级编程 (APUE) 之 高级 IO

(十二) 一起学 Unix 环境高级编程 (APUE) 之 进程间通信(IPC)

(十三) [终篇] 一起学 Unix 环境高级编程 (APUE) 之 网络 IPC:套接字

 

 

从信号这章开始情况就开始变得复杂了,所以从这篇开始后面的博文大家一天可能就无法学习完毕了,大家可以把每篇博文分为两天到三天的时间来学习,要给自己充足的时间练习才能掌握这些内容。

所以一定不要手懒,想要学会唯一的办法就是多写多练习。

如果前面的东西你还没有掌握,那么从这部分开始到后面的内容就先不要看了,一定要回去把前面的部分掌握了再开始看后面的内容。

因为 APUE 的东西是前后呼应的,如果前面没有学扎实,后面有些东西是不太容易理解的。

说实话前面的内容几乎都没有难度,只有从信号开始才算是有一点难度了,APUE 的东西多写多练很容易掌握的,所以不要害怕。

 

到目前为止,之前写的程序还没有一个是异步运行的,全部都是同步运行的。

在第一篇博文中我们就介绍过了,Linux 环境中的并发可以分为 多进程+信号 和 多线程两种,信号属于初级异步,多线程属于强烈异步。

在实际项目中信号和多线程基本不会一块儿使用,要么使用 多进程+信号 的形式,要么采用多线程的形式。

同步程序的特点是程序的执行流程、分支都是明确的。

异步事件的特点:事件到来的时间不确定,到来之后产生的结果是不确定的。比如在俄罗斯方块游戏中需要异步接收用户的方向控制输入,你永远无法知道用户什么时候按下方向键,以及按下哪个方向键。

 

异步事件的获取方式通常只有两种:查询法,通知法

 

假如我们使用一个烟雾传感器监测库房中是否发生了火灾,火灾的到来的时间就是一种异步事件。

我们可以通过两种方式获取是否发生了火灾:

1)查询法:传感器将状态写到一个位图当中,我们不停的查询位图的状态来得到传感器的最新监测结果。

2)通知法:当检测到火灾时传感器推送一个消息给我们,这样我们就不用不停的查询位图了。

 

那么什么情况使用查询法更好,什么情况使用通知法更好呢?

异步事件到来的频率比较的情况考虑使用查询法,因为撞到异步事件到来的概率比较高。

异步事件到来的频率比较稀疏的情况考虑通知法,因为比较经济实惠。

所有的通知法都需要配合一个监听机制才行。否则比如你在垂钓,放下一个鱼竿之后你就走了,就算鱼上钩了你也不可能知道。

 

即使计算机中没有连接任何外部硬件设备,内核每秒钟也会发生成百上千个中断来打断正在运行的程序。

时间片调度其实就是通过中断打断程序的执行,把时间片耗尽的进程移动到队列中等待。所以任何一个进程在执行的过程中都是磕磕绊绊的不断被打断的,程序在任何地方都可能被打断,唯独一条机器指令是无法被打断的(机器指令是原子的)。

比如你在执行一句 printf("Hello World!\n"); 的时候,看似是很流畅的打印出来了,但是执行过程中已经被打断很多次了。

所以在单核 CPU 上其实是不存在真正意义上的异步的,你感受到的异步无非就是时间片切换给你带来的错觉。你以为你边听音乐边写程序,这两件事是同时进行的吗?其实内核在快速的不断的打断其中一个程序,然后再让另一个程序运行一会儿,如此往复,给你一种两件事情在同时发生的错觉。

 

1.信号概述

信号不是中断,中断只能由硬件产生,信号是模拟硬件中断的原理在软件层面上进行的实现。

 

可以使用 kill(1) 命令向其它进程查看或发送信号。

>$ kill -l
 1) SIGHUP          2) SIGINT          3) SIGQUIT         4) SIGILL          5) SIGTRAP
 6) SIGABRT         7) SIGBUS          8) SIGFPE          9) SIGKILL        10) SIGUSR1
11) SIGSEGV        12) SIGUSR2        13) SIGPIPE        14) SIGALRM        15) SIGTERM
16) SIGSTKFLT      17) SIGCHLD        18) SIGCONT        19) SIGSTOP        20) SIGTSTP
21) SIGTTIN        22) SIGTTOU        23) SIGURG         24) SIGXCPU        25) SIGXFSZ
26) SIGVTALRM      27) SIGPROF        28) SIGWINCH       29) SIGIO          30) SIGPWR
31) SIGSYS         34) SIGRTMIN       35) SIGRTMIN+1     36) SIGRTMIN+2     37) SIGRTMIN+3
38) SIGRTMIN+4     39) SIGRTMIN+5     40) SIGRTMIN+6     41) SIGRTMIN+7     42) SIGRTMIN+8
43) SIGRTMIN+9     44) SIGRTMIN+10    45) SIGRTMIN+11    46) SIGRTMIN+12    47) SIGRTMIN+13
48) SIGRTMIN+14    49) SIGRTMIN+15    50) SIGRTMAX-14    51) SIGRTMAX-13    52) SIGRTMAX-12
53) SIGRTMAX-11    54) SIGRTMAX-10    55) SIGRTMAX-9     56) SIGRTMAX-8     57) SIGRTMAX-7
58) SIGRTMAX-6     59) SIGRTMAX-5     60) SIGRTMAX-4     61) SIGRTMAX-3     62) SIGRTMAX-2
63) SIGRTMAX-1     64) SIGRTMAX
>$

 

其中 1 - 31 是标准信号,34 - 64 是实时信号。我们下面讨论的内容如果没有特殊说明则都是针对标准信号。

信号有五种不同的默认行为:终止、终止+core、忽略、停止进程、继续。

 core 文件就是程序在崩溃时由操作系统为它生成的内存现场映像和调试信息,主要是用来调试程序的,可以使用 ulimit(1) 命令设置允许生成的 core 文件的最大大小。

1)终止:使程序异常结束。还记得我们在前面的博文中提到的程序的 3 种异常终止情况吗?其中被信号杀死就是异常终止的一种。

2)终止+core:杀死进程,并为其产生一个 core dump 文件,可以使用这个 core dump 文件获得程序被杀死的原因。

3)忽略:程序会忽略该信号,不作出任何响应。

4)停止进程:将运行中的程序中断。被停止的进程就像被下了一个断点一样,停止运行并不会再被调度,直到收到继续运行的信号。当按下 Ctrl+Z 时就会将一个正在运行的前台进程停止,其实就是向这个进程发送了一个 SIGTSTP 信号。

5)继续:使被停止的进程继续运行。只有 SIGCONT 信号具有这项功能。

这里介绍下常用的标准信号,但是有时间所有的信号都要仔细的看(见《APUE》第三版 P252 - P256)。

信号 默认动作 说明
SIGABRT 终止+core 调用 abort(3) 函数会向自己发送该信号使程序异常终止,通常在程序自杀时使用。
SIGALRM 终止

调用 alarm(2) 或 setitimer(2) 定时器超时时向自身发送的信号。
setitimer(2) 设置 which 参数的值为 ITIMER_REAL 时,超时后会发送此信号。

SIGCHLD(某些平台是 SIGCLD) 忽略

当子进程状态改变系统会将该信号发送给其父进程。
状态改变是指由运行状态改变为暂停状态、由暂停状态改变为运行状态、由运行状态改变为终止状态等等。

SIGHUP 终止 如果终端接口检测到链接断开则将此信号发送给该终端的控制进程,通常会话首进程就是该终端的控制进程。
SIGINT 终止 

当用户按下中断键(Ctrl+C)时,终端驱动程序产生此信号并发送给前台进程组中的每一个进程。
大家经常使用 Ctrl + C 来杀死进程,这回知道是什么原理了吧?

SIGPROF 终止   setitimer(2) 设置 which 参数的值为 ITIMER_PROF 时,超时后会发送此信号。
SIGQUIT 终止+core 

当用户在终端上按下退出键(Ctrl+\)时,终端驱动程序产生此信号并发送给前台进程组中的所有进程。

该信号与 SIGINT 的区别是,在终止进程的同时为它生成 core dump 文件。

SIGTERM 终止  使用 kill(1) 命令发送信号时,如果不指定具体的信号,则默认发送该信号。
SIGUSR1 终止  用户自定义的信号。
有童鞋说不明白什么是用户自定义的信号,
其实所谓自定义的信号就是系统不赋予它什么特殊的意义,你想用它来做什么都行,
根据你的程序逻辑为它定义好相应的信号处理函数就行了。
SIGUSR2 终止  另一个用户自定义的信号,作用同上。
SIGVTALRM 终止    setitimer(2) 设置 which 参数的值为 ITIMER_VIRTUAL 时,超时后会发送此信号。

表1 常见的标准信号 

2. signal(2)

 1 signal - ANSI C signal handling
 2 
 3 #include <signal.h>
 4 
 5 /* man 手册中定义的写法 */
 6 
 7 typedef void (*sighandler_t)(int);
 8 
 9 sighandler_t signal(int signum, sighandler_t handler);
10 
11 /* APUE 课本上的写法 */
12 void (*signal (int signo, void (*func) (int))) (int);

signal(2) 函数的作用是为某个信号注册一个信号处理函数。

课本上的写法比 man 手册中的写法更好,因为 sighandler_t 这个名字纯属手册捏造出来的,如果某一天标准库发布了一个函数的名字恰巧也叫 sighandler_t,那么手册就出问题了,这是C 语言名空间管理不善导致的。

参数列表:

  singno:1 - 31 是标准信号,34 - 64 是实时信号,当然也可以使用 kill(1) -l 所列出的宏名;

  func:收到信号时的处理行为,也就是信号处理函数;也可以使用 SIG_DEF 和  SIG_IGN 两个宏来替代。SIG_DEF 表示使用信号的默认处理行为。SIG_IGN 表示忽略该信号。

返回值:原来的信号处理函数。有时候我们在定义自己的信号处理函数之前会把原来的信号处理函数保存下来,这样当我们的库使用完之后需要还原原来注册的信号处理函数,避免因为调用了我们的库而导致别人的库失效的问题。

我们先来看下面的代码:

 1 #include <stdio.h>
 2 #include <signal.h>
 3 #include <unistd.h>
 4 
 5 static void handler (int s)
 6 {
 7         write(1, "!", 1);
 8 }
 9 
10 int main(void)
11 {
12         int i = 0;
13 
14         signal(SIGINT, handler);
15 
16         for (i = 0; i < 10; i++)
17         {
18                 write(1, "*", 1);
19                 sleep(1);
20         }
21 
22         return 0;
23 }

 

这个程序运行起来之后,每秒钟会打印一个星号(*),当按下 Ctrl+C 时会打印一个感叹号(!),直到 10 秒钟后程序退出,下面是不停的按 Ctrl+C 的运行结果。

>$ gcc -Wall signal.c
>$ time ./a.out 
*^C!*^C!*^C!*^C!*^C!*^C!*^C!*^C!*^C!*^C!
real    0m1.656s
user    0m0.000s
sys    0m0.002s
>$

 

通过 time(1) 命令可以测试出来,程序并没有持续 10 秒钟才结束,这是因为信号会打断阻塞的系统调用,也就是说 SIGINT 这个信号打断了 sleep(3)。

比如使用 read(2) 函数读取一个设备的时候,当设备中没有充足的数据供读取时,read(2) 函数会进入阻塞等待数据的状态,这时候如果收到了一个信号就会打断阻塞中的 read(2) 函数,它会设置 EINTR 的 errno。所以收到函数报错的时候往往需要判断一下是否被信号打断了,如果是被信号打断的,还要重新再执行一次。

 

3.竞争

当学习了信号之后,我们的程序中就出现异步的情况了,只要是异步的程序就可能会出现竞争,先来了解下什么是竞争。

竞争:一个十字路口没有红绿灯,两辆不同方向驶来车可能会发生碰撞,而且碰撞可能很严重也可能很轻微。当安装上红绿灯之后就相当于增加了一个协议,如果没有这个协议的限制,大家就可以随意的使用公共资源了,你在十字路口中间跳广场舞也可以。所以为了避免竞争带来的后果,我们会使用一些协议来避免竞争的发生。

当然,避免竞争的办法我们后面会讨论。

 

4.不可靠的信号

很多人看到了不可靠的信号这一章节,就认为因为额信号会丢失所以是不可靠的,其实这么理解是不对的,不可靠的信号是指信号的行为不可靠。

信号的处理就好比现在 LZ 正在写这篇博文,忽然来了一个电话,于是打断了手头的工作,先接电话去了。

信号处理函数的执行现场不是程序员布置的,而是内核布置的,因为程序中不会有调用信号处理函数的地方。

同一个信号处理函数的执行现场会被布置在同一个地方,所以当一次信号处理函数未执行完成时再次触发了相同的信号,信号处理函数发生了第二次调用,则第一次调用的执行现场会被覆盖。

 

5.可重入函数

函数重入乍一看上去像是递归,但又是有区别的,递归调用的现场是程序员布置的,而重入是在一个函数执行未结束时再次发生了调用并且进入了同一个函数现场。

重入时函数会发生错误的函数称为“不可重入函数”,重入不会出现错误的函数叫做“可重入函数”。

所有的系统调用都是可重入函数,所以信号处理函数中可以放心的使用系统调用。但并不是说所有的非系统调用都是不可重入的。

man 手册所有的函数中如果有一个同名的带 _r 后缀的函数,那么不带 _r 后缀的函数是不可重入的函数,而带 _r 后缀的函数是可重入的函数。比如下面这两个常见的函数:

 1 strerror,  strerror_r - return string describing error number
 2 
 3 #include <string.h>
 4 
 5 char *strerror(int errnum);
 6 
 7 int strerror_r(int errnum, char *buf, size_t buflen);
 8             /* XSI-compliant */
 9 
10 char *strerror_r(int errnum, char *buf, size_t buflen);
11             /* GNU-specific */

 

6.可靠信号术语和语义

这是信号这章比较重要的内容,通过这个我们来了解信号在 Linux 系统中是如何实现的。

 

图1 标准信号的处理过程

 

呼,累死了,这么简单的一张图竟然画了一个小时。。下面且听 LZ 解释上图的内容。

mask 和 pending 位图是一一对应的,它们用于反映当前进程信号的状态。每一位代表了一个标准信号。

mask 位图用于记录哪些信号可以响应。1 表示该信号可以响应,0 表示该信号不可响应(会被忽略)。

pending 位图用于记录收到了哪些信号。1 表示收到了该信号,0 表示没有收到该信号。

前面说过了,程序在执行的过程中会被内核打断无数次,也就是说程序被打断后要停止手头的工作,进入一个队列排队等待再次被调度才能继续工作。

当进程获得调度机会后,从内核态返回到用户态之前要做很多事情,其中一件事就是将 mask 位图和 pending 位图进行 & 运算,当计算的结果不为 0 时就需要调用相应的信号处理函数或执行信号的默认动作。

这就是 Linux 的信号处理机制,从这个机制中,我们可以总结出几个信号的特点:

1)如果想要屏蔽某个信号,只需将对应的 mask 位 置为 0 即可。这样当程序从内核态返回用户态进行 mask & pending 时,该信号位的计算结果一定为 0。

2)信号从收到到响应是存在延迟的,一般最长延迟 10 毫秒。因为只有程序被打断并且重新被调度的时候才有机会发现收到了信号,所以当我们向一个程序按下 Ctrl+C 时程序并没有立即挂掉,只不过这个时间非常短暂我们一般情况下感觉不到而已,我们自己以为程序是立即挂掉了。其实想要实验也很容易,写一个死循环不断打印一个字符,然后在它跑起来的时候按下 Ctrl+C,你会发现并不是打印了 ^C 之后程序会立即停止,而是继续打印了一些字符之后才停止。

3)当一个信号没有被处理时,无论再次接受到多少个相同的信号都只能保留一个,因为 pending 是位图,位图的特点就是只能保留最后一次的状态。这一点说的就是标准信号会丢失的特点,如果想要不丢失信号就只能使用实时信号了。

4)信号处理函数轻易不允许使用 longjmp(3) 进行跨函数跳转。因为处理信号之前系统会把 mask 对应的位设置为 0 来避免信号处理函数重入,当信号处理完成之后系统会把对应的 mask 位设置为 1 恢复进程对该信号的响应能力。如果进行了长跳转系统就不会恢复 mask 位图了,也就再也无法收到该信号了。其实这个图只是一个草图,信号实际上是线程级别的(这个我们在后面讲到线程的时候会详细讨论),所以即使 mask 位图在处理前被置为 0,依然有可能出现重入的现象,因为无法保证兄弟线程也同步屏蔽了相应的位。

5)信号处理函数的执行时间越短越好,因为信号处理函数是在用户态执行的,在它的执行过程中也会不停的被内核打断,所以如果信号处理函数执行的时间过长会使情况变得复杂。

6)信号的响应是嵌套执行的。就是说假设进程先收到了 SIGINT 信号,当它的信号处理函数还没有执行完毕时又收到了另一个信号 SIGQUIT,那么当进程从内核态返回到用户态时会优先执行 SIGQUIT 的信号处理函数,等 SIGQUIT 的信号处理函数执行完毕后再回到 SIGINT 信号处理函数上次被打断时的地方继续执行,函数调用栈看上去就像在 SIGINT 的信号处理函数中调用了 SIGQUIT 的信号处理函数一样。这也是上面所说的为什么信号处理函数的执行时间要越短越好,要尽量避免这种复杂的情况发生。

7)如果同时到来多个优先级差不多的信号,无法保证优先响应哪个信号,它们的响应没有严格意义上的顺序。除非是收到了优先级较高的信号,系统会保证高优先级的先被处理。

 

7. kill(2)

1 kill - send a signal to a process or a group of processes
2 
3 #include <signal.h>
4 
5 int kill(pid_t pid, int sig);

kill(2) 函数的作用是将指定的信号(sig)发送给指定的进程(pid)。

大家一看到 kill 就觉得有杀死进程的意味,其实未必如此,kill(3) 也负责给进程发送各种信号。

参数列表:

  pid:接收信号的进程 ID。可填的内容详见下表:

说明
>0 接收信号的进程 ID。
==0 发送信号给当前进程所在进程组的所有进程。
==-1

发送信号给当前进程有权向它们发送信号的所有进程,1 号 init 进程除外。

相当于一个全局广播信号,发送这种信号一般只有 1 号 init 会做,比如在关机的时候 init 进程会发送全局广播信号通知大家该结束了。

<-1 pid 的绝对值作为组 ID,给这个组中所有的进程发送信号。

表2 kill(2) 函数 pid 参数的取值

  sig:要发送的信号,可以使用 kill(1) -l 所列出的信号。如果 sig 是 0 会执行所有的错误检查,但并不真正发送信号。所以通常使用 0 值检查一个进程是否仍然存在,如果该进程不存在则返回 -1 并将 errno 设置为 ESRCH。需要注意的是这种检查并不原子,当 kill(2) 返回测试结果的时候也许被测试的进程已经终止了。当然也可以测试当前进程是否对目标进程有权限发送信号,如果 errno 为 EPERM 表示被测试的进程存在但当前进程无权限访问。

返回值:成功为 0,失败为 -1,并设置 errno。

 

8. pause(3P)

1 pause - suspend the thread until a signal is received
2 
3 #include <unistd.h>
4 
5 int pause(void);

专门用于阻塞当前进程,等待一个信号来打断它。

 

9. alarm(3P)

1 alarm - schedule an alarm signal
2 
3 #include <unistd.h>
4 
5 unsigned alarm(unsigned seconds);

 指定  seconds 秒,发送一个 SIGALRM 信号给自己。

seconds 为 0 的时候,表示取消这个定时器,并且新设置的值会覆盖上次设置的值。所以当程序中出现了多个对 alarm(3P) 的调用时,计时是不准确的。

注意,SIGALRM 信号默认动作是杀死进程。

 

我们来看看代码 count_alarm.c、count_time.c,哪个效率更高。

 1 /* count_alarm.c */
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 #include <unistd.h>
 5 #include <signal.h>
 6 
 7 long long count = 0;
 8 static volatile int flag = 1;
 9 
10 void alarm_handler (int s)
11 {
12     flag = 0;
13 }
14 
15 int main (void)
16 {
17     signal(SIGALRM, alarm_handler);
18     alarm(5);
19 
20     flag = 1;
21 
22     while (flag)
23     {
24         count++;
25     }
26 
27     printf("%lld\n", count);
28 
29     return 0;
30 }

 

 1 /* count_time.c */
 2 #include <stdio.h>
 3 #include <time.h>
 4 
 5 int main (void)
 6 {
 7     long long count = 0;
 8     time_t t;
 9 
10     t = time(NULL) + 5;
11 
12     while (time(NULL) < t) {
13         count++;
14     }
15 
16     printf("%lld\n", count);
17 
18     return 0;
19 }

 

编译运行:

>$ make count_alarm count_time
cc     count_alarm.c   -o count_alarm
cc     count_time.c   -o count_time
>$ time ./count_alarm
2374311494

real    0m5.004s
user    0m4.780s
sys    0m0.194s
>$ time ./count_time
2139947

real    0m4.152s
user    0m4.116s
sys    0m0.021s
>$

 

通过执行结果可以看出来,alarm(3P) 的方式和 time(2) 的方式执行效率竟然差了 1000 多倍,当然这个简单的测试精度是不高的。

上面的代码通过 gcc count_alarm.c -O1 优化之后就无法正确执行了。

我们先把 count_alarm.c 编译成汇编代码再讨论它为什么被优化之后无法正确执行了。

优化前:

 1 >$ gcc -S count_alarm.c -o count_alarm.S
 2 >$ vim count_alarm.S
 3     ; ...... 省略不相关代码
 4 
 5 alarm_handler:
 6 .LFB0:
 7     .cfi_startproc
 8     pushq    %rbp
 9     .cfi_def_cfa_offset 16
10     .cfi_offset 6, -16
11     movq    %rsp, %rbp
12     .cfi_def_cfa_register 6
13     movl    %edi, -4(%rbp)
14     movl    $0, flag(%rip)   ; 修改 flag 的值
15     leave
16     .cfi_def_cfa 7, 8
17     ret
18     .cfi_endproc
19 
20     ; ...... 省略不相关代码
21 
22 main:
23 .LFB1:
24     .cfi_startproc
25     pushq    %rbp
26     .cfi_def_cfa_offset 16
27     .cfi_offset 6, -16
28     movq    %rsp, %rbp
29     .cfi_def_cfa_register 6
30     movl    $alarm_handler, %esi
31     movl    $14, %edi
32     call    signal
33     movl    $5, %edi
34     call    alarm
35     movl    $1, flag(%rip)
36     jmp    .L4
37 .L5:
38     movq    count(%rip), %rax
39     addq    $1, %rax
40     movq    %rax, count(%rip)
41 .L4:
42     movl    flag(%rip), %eax
43     testl    %eax, %eax        ; 每次循环会检测 flag 的值是否改变
44     jne    .L5
45     movq    count(%rip), %rdx
46     movl    $.LC0, %eax
47     movq    %rdx, %rsi
48     movq    %rax, %rdi
49     movl    $0, %eax
50     call    printf
51     movl    $0, %eax
52     leave
53     .cfi_def_cfa 7, 8
54     ret
55     .cfi_endproc
56 
57     ; ...... 省略不相关代码

 

优化后:

 1 >$ gcc -S count_alarm.c -O1 -o count_alarm1.S
 2 >$ vim count_alarm1.S
 3     ; ...... 省略不相关代码
 4 
 5 alarm_handler:
 6 .LFB21:
 7     .cfi_startproc
 8     movl    $0, flag(%rip)    ; 修改 flag 的值
 9     ret
10     .cfi_endproc
11 
12     ; ...... 省略不相关代码
13 
14 main:
15 .LFB22:
16     .cfi_startproc
17     subq    $8, %rsp
18     .cfi_def_cfa_offset 16
19     movl    $alarm_handler, %esi
20     movl    $14, %edi
21     call    signal
22     movl    $5, %edi
23     call    alarm
24     movl    $1, flag(%rip)
25 .L4:
26     jmp    .L4                ; 变成了死循环
27     .cfi_endproc
28 
29     ; ...... 省略不相关代码

 

从上面的代码不难看出,优化时编译器认为 flag 的值一直没有改变,所以直接把 flag 的值拿过来作为循环条件了,每次循环的时候不再从 flag 变量所在的内存位置取值了。

为了避免编译器犯这种错误,我们需要把 flag 定义成 volatile 变量,volatile 关键字表示一定要到变量定义的位置取变量的值,而不要轻信曾经拿到的值。

 

10.流量控制

播放音乐和电影的时候都要按照播放的速率读取文件,而不能像 cat(1) 命令一样,直接将交给它的文件用最快的速度读取出来,否则你听到的音乐就转瞬即逝了。

我们先通过一个栗子了解下什么是流量控制:

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <fcntl.h>
 4 #include <errno.h>
 5 #include <signal.h>
 6 
 7 #include <sys/types.h>
 8 #include <sys/stat.h>
 9 
10 #define BUFSIZE    10
11 
12 static volatile int loop = 0;
13 
14 static void alarm_handler (int s)
15 {
16     alarm(1);
17     loop = 0;
18 }
19 
20 int main (int argc, char **argv)
21 {
22     int fd = -1;
23     char buf[BUFSIZE] = "";
24     ssize_t readsize = -1;
25     ssize_t writesize = -1;
26     size_t off = 0;
27 
28     if (argc < 2)
29     {
30         fprintf(stderr, "Usage %s <filepath>\n", argv[0]);
31         return 1;
32     }
33 
34     do {
35         fd = open(argv[1], O_RDONLY);
36         if (fd < 0) {
37             if (EINTR != errno) {
38                 perror("open()");
39                 goto e_open;
40             }
41         }
42     } while (fd < 0);
43 
44     loop = 1;
45     signal(SIGALRM, alarm_handler);
46     alarm(1);
47 
48     while(1) {
49         // while (loop); // 忙等
50         // 非忙等
51         while (loop) {
52             pause();
53         }
54         loop = 1;
55 
56         while ((readsize = read(fd, buf, BUFSIZE)) < 0) {
57             if (readsize < 0) {
58                 if (EINTR == errno) {
59                     continue;
60                 }
61                 perror("read()");
62                 goto e_read;
63             }
64         }
65         if (!readsize) {
66             break;
67         }
68 
69         off = 0;
70         do {
71             writesize = write(1, buf + off, readsize);
72             off += writesize;
73             readsize -= writesize;
74         } while (readsize > 0);
75     }
76 
77     close(fd);
78 
79     return 0;
80 
81 e_read:
82     close(fd);
83 e_open:
84     return 1;
85 }

 

程序的运行结果我就不贴出来了,各位童鞋一定要在自己的电脑上运行一下(为了加强练习,最好不要直接复制代码)。

等你运行完了上面的代码就可以继续往下看了,否则你会不知道我在说什么。

前面文件 IO 的部分我们做过一个栗子 mycp,用来模仿 cp(1) 命令。这次我们把它修改为 mycat,用来模仿 cat(1) 命令,并且是慢慢的 cat,每秒钟输出 10 个字节的数据。

这个流控方案就是漏桶:当没有数据可读的时候就是闲着,并没有积攒权限,所以当数据再次可读的时候它的速率不会变。

我们前面提到过,stream 这种东西并非像小河流水一样是非常均匀的潺潺细流,而是要么没有数据,要么一下子来一大坨。如果用漏桶处理这种情况速度会非常慢,那么有没有什么更好的流控方案呢?当然有,用令牌桶来处理就可以很好的解决这种流量激增的情况。

令牌桶闲着的时候在积攒权限,所以实际使用时令牌桶比漏桶用得更普遍。

具体要用哪种桶需要根据实际需求来决定,比如在线听音乐的时候网速不好,不能等数据来了的时候用最快的速度把之前积攒了权限的数据一下子都播放出来,应当还保持原来的速率播放,所以这时候选择漏桶就更加合适了。

下面我们重构一下上面的漏桶流控代码,把它改成令牌桶的实现:

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <fcntl.h>
 4 #include <errno.h>
 5 #include <signal.h>
 6 
 7 #include <sys/types.h>
 8 #include <sys/stat.h>
 9 
10 #define BUFSIZE     10   // 流量速率
11 #define MAXTOKEN    1024 // 令牌上限
12 
13 static volatile int token = 0; // 积攒的令牌数量
14 
15 static void alarm_handler (int s)
16 {
17     alarm(1);
18     if (token < MAXTOKEN) {
19         token++; // 每秒钟增加令牌
20     }
21 }
22 
23 int main (int argc, char **argv)
24 {
25     int fd = -1;
26     char buf[BUFSIZE] = "";
27     ssize_t readsize = -1;
28     ssize_t writesize = -1;
29     size_t off = 0;
30 
31     if (argc < 2)
32     {
33         fprintf(stderr, "Usage %s <filepath>\n", argv[0]);
34         return 1;
35     }
36 
37     do {
38         fd = open(argv[1], O_RDONLY);
39         if (fd < 0) {
40             if (EINTR != errno) {
41                 perror("open()");
42                 goto e_open;
43             }
44         }
45     } while (fd < 0);
46 
47     signal(SIGALRM, alarm_handler);
48     alarm(1);
49 
50     while(1) {
51         while (token <= 0) { // 如果令牌数量不足则等待添加令牌
52             pause(); // 因为添加令牌是通过信号实现的,所以可以使用 pause(2) 实现非忙等(通知法)
53         }
54         token--; // 每次读取 BUFSIZE 个字节的数据时要扣减令牌
55 
56         while ((readsize = read(fd, buf, BUFSIZE)) < 0) {
57             if (readsize < 0) {
58                 if (EINTR == errno) {
59                     continue;
60                 }
61                 perror("read()");
62                 goto e_read;
63             }
64         }
65         if (!readsize) {
66             break;
67         }
68 
69         off = 0;
70         do {
71             writesize = write(1, buf + off, readsize);
72             off += writesize;
73             readsize -= writesize;
74         } while (readsize > 0);
75     }
76 
77     close(fd);
78 
79     return 0;
80 
81 e_read:
82     close(fd);
83 e_open:
84     return 1;
85 }

 当然这只是一个简单的令牌桶的雏形,不过已经足以让我们了解令牌桶的工作原理了。

 令牌桶的三要素:令牌、令牌上限、流量速率(CPS)。

从上面的代码可以看出来:SIGALRM 的回调函数负责向令牌桶中添加令牌,而每次读取数据之前要先检查令牌的剩余数量。如果令牌充足则扣减令牌后开始读取数据,如果令牌数量不足则阻塞等待 SIGALRM 回调函数向令牌桶中补充令牌。

设计令牌上限是为了防止令牌桶溢出,通常没必要让令牌无限制的上涨。

 

11. getitimer(3P) 和 setitimer(3P) 函数

1 getitimer, setitimer - get or set value of an interval timer
2 
3 #include <sys/time.h>
4 
5 int getitimer(int which, struct itimerval *curr_value);
6 int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

setitimer(2) 函数可以用来替代 alarm(2) 函数。

setitimer(2) 函数主要有两点比 alarm(2) 函数更好:

  1)setitimer(2) 函数可以使用精度更高的微秒为计时单位;

  2)从 it_interval 赋值给 it_value 是采用原子操作的。

setitimer(2) 直接可以构成一个类似 alarm(2) 链的执行结构。也就是说当 it_value 的值被递减为 0 时会发送一个信号给当前进程,并且自动将 it_interval 的值赋给 it_value 使计时重新开始。

参数列表:

which:使用不同的时间,并发送不同的信号;详见下表(其实在 表1 中我们也提到它们了)

which 可选宏值 对应的信号
ITIMER_PROF SIGPROF
ITIMER_REAL SIGALRM
ITIMER_VIRTUAL SIGVTALRM

表3 which 与对应的信号

  new_value:新的定时器周期;这个结构体的定义可以见下面的说明。

  old_value:由该函数回填以前设定的定时器周期,不需要保存可以设置为 NULL;

1 struct itimerval {
2     struct timeval it_interval; /* next value */
3     struct timeval it_value;    /* current value */
4 };
5 
6 struct timeval {
7     time_t      tv_sec;         /* seconds */
8     suseconds_t tv_usec;        /* microseconds */
9 };

递减的是 it_value 的值,当 it_value 被递减为 0 的时候将 it_interval 的值 原子化 的赋给 it_value

tv_sec 表示以秒为单位;tv_usec 表示以微秒为单位。使用一种计时方式时,另一种必须设置为 0。

 

 

12.信号集

信号集就是一种能表示一组信号的数据类型,一般都是用在批量设置信号掩码时使用。

信号集使用 sigset_t 类型表示,有一组函数可以操作它。

 1 sigemptyset, sigfillset, sigaddset, sigdelset, sigismember - POSIX signal set operations
 2 
 3 #include <signal.h>
 4 
 5 int sigemptyset(sigset_t *set);
 6 
 7 int sigfillset(sigset_t *set);
 8 
 9 int sigaddset(sigset_t *set, int signum);
10 
11 int sigdelset(sigset_t *set, int signum);
12 
13 int sigismember(const sigset_t *set, int signum);

 

这一组函数的作用无非就是对信号集中的信号进行曾删改差,这里 LZ 就不再赘述了,具体的用法各位可以自行查阅 man 手册。

 

13.sigprocmask(2)

1 sigprocmask - examine and change blocked signals
2 
3 #include <signal.h>
4 
5 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

前面我们提到过我们可以人为的干扰信号 mask 位图,唯一的途径就是通过这个函数实现。但是 pending 位图是无法人为干扰的。

我们不能保证信号什么时候来,使用这个函数的目的就是为了让我们来决定什么时候响应信号。

参数列表:

how:指定如何来干扰 mask 位图,可以使用下表中三个宏中的任何一个来指定;

含义
SIG_BLOCK 将当前进程的信号屏蔽字和 set 信号集中的信号全部屏蔽,也就是将它们的 mask 位设置为 0
SIG_UNBLOCK set 信号集中与当前信号屏蔽字重叠的信号解除屏蔽,也就是将它们的 mask 位设置为 1
SIG_SETMASK 将 set 信号集中的信号 mask 位设置为 0,其它的信号全部恢复为 1

表4 干扰 mask 位图的方式

  set:需要被干扰 mask 位图的信号集;

  oldset:由该函数回填之前被干扰的信号集。

使用这个函数,我们来重构上面那个打印星号和感叹号的程序,新需求是这样的:

每行打印 5 个星号,然后停止。期间如果收到了 SIGINT 信号不会立即响应,而是等待本行打印结束后再响应,并且在收到信号之后再打印下一行。

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <signal.h>
 4 #include <unistd.h>
 5 
 6 static void int_handler(int s)
 7 {
 8     write(1,"!",1);
 9 }
10 
11 int main()
12 {
13     sigset_t set,oset,saveset;
14     int i,j;
15 
16     signal(SIGINT,int_handler);
17 
18     sigemptyset(&set);
19     sigaddset(&set,SIGINT);    
20 
21     sigprocmask(SIG_UNBLOCK,&set,&saveset);
22 
23     sigprocmask(SIG_BLOCK,&set,&oset);
24     for(j = 0 ; j < 10000; j++)
25     {
26         for(i = 0 ; i < 5; i++)
27         {
28             write(1,"*",1);
29             sleep(1);
30         }
31         write(1,"\n",1);
32 
33         // 相当于下面三行的原子操作
34         sigsuspend(&oset);
35     /*
36         sigset_t tmpset;
37         sigprocmask(SIG_SETMASK,&oset,&tmpset);
38         pause();
39         sigprocmask(SIG_SETMASK,&tmpset,NULL);
40     */
41     }
42 
43     sigprocmask(SIG_SETMASK,&saveset,NULL);
44 
45     exit(0);
46 }
47     

 

大致的实现思路是:开始打印每行星号之前先屏蔽信号,当打印完成之后再恢复信号,然后等待被信号打断,再重新屏蔽信号,打印星号。

但是在测试的时候会发现,这样只能实现当一行信号打印完毕时可以停住,然后按下 Ctrl+C 发送信号,可以继续打印下一行。但是当一行没有打印完成时就按 Ctrl+C 发送信号,下一行会在行首打印感叹号,但是却并不继续开始打印星号。

这是什么原因导致的呢?其实仔细分析一下信号的处理过程就明白了,在开始打印星号之前我们屏蔽了信号的 mask 位,当接收到信号时对应的 pending 位被置1,由于 mask 位是 0 所以程序不会响应信号。当星号打印完成时 mask 位被置为 1,程序会再次看到信号,所以会打印感叹号并进入 pause 状态等待被信号打断,所以程序只打印了一个感叹号却没有继续打印星号。

归根结底还是因为 解除信号屏蔽 --- 等待被信号打断 --- 屏蔽信号 的这三个步骤不原子导致的。

sigsuspend(2) 函数我们在这篇博文的最后面还会讲解。

当使用 sigsuspend(2) 函数使这三个步骤原子化时我们再来分析一下程序的执行过程:

开始打印星号之前将 mask 位设置为 0,开始打印星号,此时如果接收到了信号 pending 被设置为 1,但是由于 mask 为 0 所以程序不会响应信号。当程序打印完星号时将 mask 位设置为 1,此时响应信号打印出感叹号,并原子化的解除信号屏蔽 + 被信号打断 + 重新屏蔽信号,然后继续开始打印下一行星号。

我们再来看另一种情况:开始打印星号之前将 mask 位设置为 0 并开始打印星号,当一行星号打印完成时没有收到信号,那么原子化的解除信号屏蔽并等待被信号打断。当信号到来时重新屏蔽信号并继续开始打印下一行星号。

根据上面的分析,只要 解除信号屏蔽 --- 等待被信号打断 --- 屏蔽信号 的这三个步骤原子化后就没问题了。当某件事情需要信号驱动时,在该事件未处理完成时又不希望再次被信号打断的时候,就可以采用类似的这种方式。

当然,这个这个程序是用标准信号实现的,所以标准信号的特点也被它继承了下来:当连续接收到多个信号时只能驱动打印一行星号,而不能收到多少个信号就打印多少行星号,因为标准信号会丢失。

如果想要让程序收到多少个信号就打印多少行星号,其实代码别的地方都不用修改,直接把信号集中的标准信号替换成实时信号就可以了,因为实时信号的特点是不丢失。代码很简单 LZ 就不贴出来了,感兴趣的小伙伴可以自己实验一下。

 

13. sigpending(3P)

1 sigpending - examine pending signals
2 
3 #include <signal.h>
4 
5 int sigpending(sigset_t *set);

用于获取当前收到但是没有响应的信号。

它是一个系统调用,所以当它从内核中返回的时候需要对信号位图做 & 操作,相应的信号已经被处理了,所以当它返回用户态的时候,它带回来的结果可能已经不准确了。

除非调用它之前先把所有的信号都 block 住,然后再调用它,返回的结果才是准确的。

LZ 目前还未发现这个函数在实际开发当中有什么作用,主要有两个理由:

  1)该函数没有后续操作;

  2)没有上面说的手段,取出来的信号集是不准确的。

如果小伙伴们发现了它的用途,请在评论中告知 LZ 哈。

 

14. sigaction(2) 

1 sigaction - examine and change a signal action
2 
3 #include <signal.h>
4 
5 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

这个函数也是信号这章比较重要的一个函数。sigaction(2) 是用来替换 signal(2) 函数的。因为 signal(2) 有一些设计上的缺陷,所以小伙伴们学过了这个函数之后以后就尽量不要再使用 signal(2) 函数了。

参数列表:

  signum:要设定信号处理函数的信号;

  act:对信号处理函数的设定;

  oldact:由函数回填之前的信号处理函数设定,备份用,如果不需要可以填 NULL。

下面看看 struct sigaction 这个结构体的成员表示什么意思:

1 struct sigaction {
2     // 前两个是信号处理函数,二选一,在某些平台上是一个共用体。
3     void     (*sa_handler)(int); // 为了兼容 signal(2) 函数
4     void     (*sa_sigaction)(int, siginfo_t *, void *); // 第二个参数可以获得信号的来源和属性。第三个参数最原始时是 ucontext_t* 而不是 void*,与 setcontext(3) 有关,目前该参数已经禁止使用。
5     sigset_t   sa_mask; // 信号集位图,指定要处理的信号集,并且信号集中的任何一个信号被触发时,信号集中的其它成员同时会被 block,避免像 signal(2) 的信号处理函数一样当多个信号同时到来时发生重入。
6     int        sa_flags; // 特殊要求。如果使用三参的信号处理函数,需要指定为 SA_SIGINFO
7     void     (*sa_restorer)(void); // 基本被废弃了,不用管
8 };

实际上一个参数的信号处理函数和三个参数的信号处理函数使用哪个都行,一般一个参数的就够用了。假设你的程序需要区分信号的来源或属性信息,那么就需要使用三参的信号处理函数了。 

 

我们再来说说 signal(2) 函数哪里不靠谱。

还记得使用 signal(2) 函数注册的信号处理函数的原型吗?它的参数 s 的作用被设计出来的目的是为了让信号处理函数区别出来是哪个信号触发了它,也就是允许多个不同的信号共用同一个信号处理函数,并且动作可以不一样,可以根据 s 的不同做不同的事。

下面举一个简单的小栗子给大家演示一下如何使用 sigaction(2) 代替 signal(2),以及为什么说 signal(2) 函数是不靠谱的。

  1 #include <stdio.h>
  2 #include <stdlib.h>
  3 #include <unistd.h>
  4 #include <fcntl.h>
  5 #include <syslog.h>
  6 #include <errno.h>
  7 #include <signal.h>
  8 #include <string.h>
  9 
 10 #include <sys/types.h>
 11 #include <sys/stat.h>
 12 
 13 #define FNAME        "/tmp/out"
 14 
 15 static FILE *fp;
 16 
 17 static int daemonize(void)
 18 {
 19     pid_t pid;    
 20     int fd;
 21 
 22     pid = fork();
 23     if(pid < 0)
 24 //      syslog(LOG_ERR,"fork():%s",strerror(errno));
 25         return -1;    
 26     
 27     if(pid > 0)
 28         exit(0);
 29 
 30     fd = open("/dev/null",O_RDWR);
 31     if(fd < 0)
 32         return -2;
 33             
 34     dup2(fd,0);    
 35     dup2(fd,1);    
 36     dup2(fd,2);    
 37 
 38     if(fd > 2)
 39         close(fd);
 40 
 41     setsid();
 42     
 43     chdir("/");
 44     umask(0);
 45 
 46     return 0;
 47 
 48 }
 49 
 50 static void daemon_exit(int s)
 51 {
 52     fclose(fp);
 53     closelog();
 54     syslog(LOG_INFO,"daemonize exited.");
 55     exit(0);
 56 }
 57 
 58 int main()
 59 {
 60     int i;
 61     struct sigaction sa;
 62 
 63 //    如果使用 signal(2) 函数则是这样注册信号处理函数
 64 //    signal(SIGINT,daemon_exit);
 65 //    signal(SIGTERM,daemon_exit);
 66 //    signal(SIGQUIT,daemon_exit);    
 67 
 68 
 69 //  现在改用 sigaction(2) 来替代 signal(2) 函数
 70     sa.sa_handler = daemon_exit;
 71     sigemptyset(&sa.sa_mask);
 72     sigaddset(&sa.sa_mask,SIGQUIT);
 73     sigaddset(&sa.sa_mask,SIGTERM);
 74     sigaddset(&sa.sa_mask,SIGINT);
 75     sa.sa_flags = 0;
 76     sigaction(SIGINT,&sa,NULL);
 77     /*if error*/
 78     sigaction(SIGTERM,&sa,NULL);
 79     /*if error*/
 80     sigaction(SIGQUIT,&sa,NULL);
 81     /*if error*/
 82 
 83 
 84 
 85     openlog("mydaemon",LOG_PID,LOG_DAEMON);
 86 
 87 //  启动守护进程
 88     if(daemonize())
 89     {
 90         syslog(LOG_ERR,"daemonize() failed.");
 91         exit(1);
 92     }    
 93     else
 94     {
 95         syslog(LOG_INFO,"daemonize() successed.");
 96     }
 97 
 98     fp = fopen(FNAME,"w");
 99     if(fp == NULL)
100     {
101         syslog(LOG_ERR,"fopen():%s",strerror(errno));
102         exit(1);
103     }
104     
105     for(i = 0 ; ; i++)
106     {
107         fprintf(fp,"%d\n",i);
108         fflush(fp);
109         syslog(LOG_DEBUG,"%d was printed.",i);
110         sleep(1);
111     }
112 
113 
114     exit(0);
115 }

这段代码很简单,就是启动一个守护进程每秒钟向 /tmp/out 文件输出一个序列。

上面的代码动机是好的,注册了三个信号处理函数,企图将异常结束行为改变为正常结束行为。但是信号处理函数中并不需要区分不同的信号,只要任何一个信号到来想要杀死进程的时候把资源释放掉再结束即可。

所以有一个重要的缺陷:当多个信号同时到来的时候,一定会发生内存泄漏。因为 signal(2) 函数在一个信号到来的时候不会把其它注册了同一个信号处理函数的信号屏蔽掉。

上面已经说过了,sigaction(2) 在收到信号集中的任何一个信号的时候,都会将信号集中的其它信号屏蔽掉,所以就会避免信号处理函数发生重入。上面的代码改成使用 sigaction(2) 的方式实现就变得安全了。

 

 15. setjmp(3) 和 sigsetjmp(3) 函数

1 setjmp, sigsetjmp - save stack context for nonlocal goto
2 
3 #include <setjmp.h>
4 
5 int setjmp(jmp_buf env);
6 
7 int sigsetjmp(sigjmp_buf env, int savesigs);

我们前面说过,在信号处理函数中是不能使用跨函数的长跳转的还记得吗?是因为进入处理函数之前系统会帮我们屏蔽对应的信号掩码,而当信号处理完成的时候系统会帮我们还原信号掩码。如果我们在信号处理函数中跳走了,那么信号掩码就不会被还原了,可能会造成当前进程再也无法接收到该信号了。

setjmp(3) 在 FreeBSD 平台上和其他平台上的实现不一致。FreeBSD 在跳转的时候还会保存信号掩码,并且在跳转的时候恢复信号掩码,所以在 FreeBSD 上使用 setjmp(3) 从信号处理函数中跳转是安全的。

由于其它平台的实现在跳转时不支持恢复信号掩码,大家一定猜到了为什么又出现了一个 sigsetjmp(3) 函数了。

果然标准再一次跳出来和稀泥了,制定了 sigsetjmp(3) 函数。

sigsetjmp(3) 函数的参数:如果 savesigs 为真,表示与 FreeBSD 平台的 setjmp(3) 实现相同,否则跳转时不保存信号掩码。就这么一点差别,仅此而已。

 1 #include <stdio.h>
 2 #include <setjmp.h>
 3 #include <signal.h>
 4 #include <unistd.h>
 5 
 6 static sigjmp_buf env;
 7 
 8 static void fun (void)
 9 {
10     long long i = 0;
11 
12     sigsetjmp(env, 1);
13 
14     printf("before %s\n", __FUNCTION__);
15     for (i = 0; i < 1000000000; i++);
16     printf("end %s\n", __FUNCTION__);
17 }
18 
19 static void handler (int s)
20 {
21     printf("before %s\n", __FUNCTION__);
22     siglongjmp(env, 1);
23     printf("end %s\n", __FUNCTION__);
24 }
25 
26 int main (void)
27 {
28     long long i = 0;
29 
30     signal(SIGINT, handler);
31 
32     fun();
33 
34     for (i = 0; ; i++)
35     {
36         printf("%lld\n", i);
37         pause();
38     }
39 
40     return 0;
41 }

 编译运行:

>$ gcc -Wall siglongjmp.c -o siglongjmp
>$ ./siglongjmp 
before fun
^Cbefore handler
before fun
^Cbefore handler
before fun
^Cbefore handler
before fun
end fun
0
^Cbefore handler
before fun
end fun
Segmentation fault (core dumped)
>$

从上面的执行结果可以看出来,第一次执行 fun() 函数的时候设置了跳转点,在 fun() 函数执行完成之前发送 SIGINT 信号使程序切换到 handler() 函数运行,并且在 handler() 函数中再次跳转到 fun() 函数。在 fun() 函数运行结束之前再次发送信号依然可以被程序看到,说明 siglongjmp(3) 在跳转的时候确实恢复信号掩码了。

但是继续往下看,当 fun() 函数执行完毕时再次发送 SIGINT 信号给程序,handler() 函数会再次被调用,但是当从 handler() 跳转到 fun() 函数的时候出现段错误了!

为什么呢?经过 LZ 实验发现:siglongjmp(3) 函数只能从信号处理函数中跳转到当前被打断的函数,而不能随意跳转到其它函数中!(信号处理的过程可以见上面的图1)

也就是说当 fun() 函数在运行时被打断,从内核态回到用户态时发现收到了信号,这时候跳转到信号处理函数中运行,这个信号处理函数如果使用 siglongjmp(3) 函数进行跳转,则只能跳转到 fun() 函数中,否则会报段错误。

同理,上面的代码当 fun() 函数运行结束时回到 main() 函数继续运行,在 main() 被打断后进入内核排队等待被调度,当它获得调度机会从内核态回到用户态时发现收到了信号并且需要处理,这个时候信号处理函数 handler() 开始运行,如果信号处理函数需要使用 siglongjmp(3) 进行跳转,那么它只能选择跳转到 main() 函数中,而不能跳转到其它函数中。因为前面 LZ 说了,当前被打断的是 main() 函数,谁被打断就只能跳转到谁那去。这时候信号处理函数依然选择跳转到 fun() 函数中,所以引发了段错误。

为什么会有这么奇怪的现象 LZ 也不明白,估计跟执行现场有关系,各位如果知道是什么原因的话请在留言中告诉 LZ 哈。

 

16 abort(3)

1 abort - cause abnormal process termination
2 
3 #include <stdlib.h>
4 
5 void abort(void);

给调用者发送一个 SIGABRT 信号,收到这个信号的默认动作是终止 + 产生 coredump 文件。

我们在上面的 表1 中提到过它,一般都是程序发现自己出现了明显的异常,为了避免缺陷扩散,自杀的时候使用。

 

17. system(3)

1 system - execute a shell command
2 
3 #include <stdlib.h>
4 
5 int system(const char *command);

在前面介绍进程相关的博文中我们介绍过 system(3) 函数,所以对于它的功能我们这里就不再赘述了,今天聊点关于它与信号的花边新闻。

对于它的使用有一些需要注意的内容,想要正确的使用 system(3) 函数,必须阻塞 SIGCHLD 信号并忽略 SIGINT、SIGQUIT 信号。

为什么使用 system(3) 函数之前要做这些动作呢?这与 shell 的内部命令处理有关系,如果想要了解更详细的内容,请自行参阅 《APUE》 第三版第九章。

 

18. select(2)

 1 select,  pselect - synchronous I/O multiplexing
 2 
 3 /* According to POSIX.1-2001 */
 4 #include <sys/select.h>
 5 
 6 /* According to earlier standards */
 7 #include <sys/time.h>
 8 #include <sys/types.h>
 9 #include <unistd.h>
10 
11 int select(int nfds, fd_set *readfds, fd_set *writefds,
12            fd_set *exceptfds, struct timeval *timeout);

其实 sleep(3) 函数是不好用的,因为某些平台上是使用 alarm(2) + pause(2) 封装它的,大家知道 alarm(2) 的计时是不太准确的。

在当前平台(Linux)sleep(3) 函数是使用 nanosleep 封装的,所以如果不考虑移植的话在当前平台上可以安全的使用 sleep(3) 函数。

其实 usleep(3)、nanosleep(2)、select(2) 这些函数都比 sleep(3) 好用。

select 我们在第14章还会讲,这里说一下利用它的副作用来为我们实现一个安全的定时器。

这样设定它的参数列表就可以了:-1, NULL, NULL, NULL, 定时结构体。

本来不打算贴出代码的,但是后来 LZ 发现用 select(2) 作为计数器使用的时候有几个坑,有必要在这里强调一下。

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <stdlib.h>
 4 
 5 #include <sys/time.h>
 6 #include <sys/types.h>
 7 
 8 int main (void)
 9 {
10     int i = 0;
11     struct timeval timeout;
12 
13     for (i = 0; i < 5; i++) {
14         /*
15          * struct timeval 结构体表示剩余的时间
16          * select(2) 函数内部会修改这个结构体的值
17          * 如果把这两行写在循环上面...
18          * 效果大家可以自己测试一下
19          */
20         timeout.tv_sec = 1;
21         timeout.tv_usec = 0;
22 
23         // 作为定时器使用时只给时间就行了,其它参数都填 0。
24         if (select(0,0,0,0, &timeout) < 0) {
25             perror("select()");
26             exit(1);
27         }
28         /*
29          * 如果不写 \n,那么程序会在结束的时候把所有的haha显示出来,
30          * 而不是在每次循环的时候都显示,
31          * 原因很简单,在前面我们讨论 IO 的时候就讨论过,
32          * 默认情况下标准输出是行缓冲模式。
33          */
34         printf("hehe\n");
35     }
36 
37     return 0;
38 }

 

 

19.sigsuspend(2)

1 sigsuspend - wait for a signal
2 
3 #include <signal.h>
4 
5 int sigsuspend(const sigset_t *mask);

这个函数我们在上面已经见过了, 它就是为了解决解除信号阻塞和 pause(2) 之间不原子的问题。

如果本来程序期望的是解除该信号的阻塞之后用 pause(2) 来等待被该信号打断,结果这个信号在解除阻塞和 pause(2) 之间到来了,这就导致它无法打断 pause(2) 了,因为它是在进行 pause(2) 之前到来的。如果后面不会再见到该信号,那么 pause(2) 将永远阻塞下去。

我们用下面的栗子来说明这个问题。

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <signal.h>
 4 #include <unistd.h>
 5 
 6 static void int_handler(int s)
 7 {
 8     write(1,"!",1);
 9 }
10 
11 int main()
12 {
13     sigset_t set,oset,saveset;
14     int i,j;
15 
16     signal(SIGINT,int_handler);
17 
18     sigemptyset(&set);
19     sigaddset(&set,SIGINT);    
20 
21     sigprocmask(SIG_UNBLOCK,&set,&saveset);
22 
23     for(j = 0 ; j < 10000; j++)
24     {
25         sigprocmask(SIG_BLOCK,&set,NULL);
26 //        sigprocmask(SIG_BLOCK,&set,&oset);
27         for(i = 0 ; i < 5; i++)
28         {
29             write(1,"*",1);
30             sleep(1);
31         }
32         write(1,"\n",1);
33         sigprocmask(SIG_UNBLOCK,&set,NULL);
34 //        sigprocmask(SIG_SETMASK,&oset,NULL);
35 //        pause();
36     }
37 
38     sigprocmask(SIG_SETMASK,&saveset,NULL);
39 
40     exit(0);
41 }

 

这个程序跟上面的栗子类似,每秒钟打印一个星号,每 5 个星号组成一行,只有当一行星号打印完毕时才响应 SIGINT 信号。

如果解除阻塞和等待信号打断不采用原子操作,那么在 pause(2) 之前收到了信号就无法驱动下一行星号的打印了。

 

20.有关信号的其它内容

除了 kill -l 可以查看所有的信号,还可以通过 /usr/include/bits/signum.h 文件查看。

 

实时信号会按照先到先响应的顺序处理,并且信号会排队,不会丢失。

信号是否排队、是否丢失,不取决于使用哪个函数,而是取决于使用哪种信号。

实时信号具有这些特点是因为它不是采用位图实现的,而是采用链式结构实现的。

其它方面与标准信号没有区别。

 

信号处理函数中只能安全的使用可重入函数(所有系统调用都是可重入函数)和所有的科学计算(科学计算都是可重入的),编写信号处理函数要时刻防止重入发生。

尽量不要大范围的混用信号和多线程,如果在小范围内信号 + 多线程可以方便的解决某个问题时才可以在小范围内混用它们。

 

posted on 2015-05-13 12:14  0xCAFEBABE  阅读(6341)  评论(3编辑  收藏  举报

导航