Linux 信号机制

信号的概念

信号的基本概念很简单,谍战剧里面的信号的概念就体现的非常形象,每次情报人员之间沟通的时候就用电台,就比如电台和密码本,每个对应的电台信号都有一个对应的意义,Key-Value 形式的,比如 A 信号表示进攻、B 信号表示撤退,非常容易理解的概念。再比如街上的红绿灯,红灯停、绿灯行….

我们既然知道了什么是信号,那么如何处理信号呢?

  • 收到信号执行默认动作,比如看到红灯就停下来
  • 忽略信号,比如看到红灯就当没看到,继续往前走
  • 收到信号执行自定义动作,比如看到红灯就躺在街上睡觉,然后被车碾压…

那么 Linux 下的进程能够处理信号的前提是认识信号,这就和我们要处理红绿灯的信号的前提是必须认识红绿灯信号,进程收到信号有可能并不会立即处理,而是在合适的时候!

查看 Linux 下所有的信号(编号 34 以上的是实时信号,实时信号必须立即处理):
mark
信号事件的产生对进程而言是异步的,这个不难理解,因为你也不知道别人什么时候给你发信号,所以信号的产生跟进程不是同步的,这是两个没有因果关系的东西!

进程即使收到信号可能也无法立即处理,信号如果无法立即处理就应该把信号保存起来,保存在 PCB 的一个位图里面,因为只需要用 31 个比特位来存储是否收到信号即可,一个 int32 字节,所以使用一个 int 就可以保存 31 个信号

所以: 发送信号的本质就是让操作系统去修改目标进程的信号位图

在此我猜想一下,Java 等高级语言捕获异常的原理只不过是程序出错后屏蔽了导致进程退出的信号而已!!!

信号的产生

键盘

通过键盘产生,比如 Ctrl C 产生终止进程的 SIGIN 信号
注意:键盘上的组合键形成的信号只能用于前台进程!
前台进程随时随地都可以收到一个信号,因为你在这个进程运行的任何时刻,你都可以出入 Ctrl-C 终止该进程。这也就说明了: 信号对于进程来说是异步的

程序运行时异常

程序运行时异常,比如除 0 产生 SIGFPE 信号

1
2
3
4
5
6
7
8
9
10
int main(){
int i = 0;
while(1){
if(i++ >= 5){
i /= 0;
}
cout << "hello,world pid = "<< getpid () << endl;
}
return 0;
}

接下来说一个调试程序 BUG 的技巧:事后调试

就是在程序已经出错的情况下通过产生的 Core Dump 来查看程序异常信息,当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做 Core Dump。进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。

一个进程允许产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存 在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。首先用 ulimit 命令改变 Shell 进程的 Resource Limit, 允许 core 文件最大为 1024K:ulimit -c 1024

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

[root@xpu code]# ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 7424
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 65535
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 10240
cpu time (seconds, -t) unlimited
max user processes (-u) 7424
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
[root@xpu code]# ulimit -c 1024
[root@xpu code]# ulimit -a
core file size (blocks, -c) 1024
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 7424
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 65535
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 10240
cpu time (seconds, -t) unlimited
max user processes (-u) 7424
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited

其中限定了一些用户使用资源的上限,比如 core 文件大小,最多打开的文件数目,最多多少消息队列等等,只要把 core 文件大小设定一下,就可以把系统产生的 core 文件保存下来,因为 core 文件通常较大和安全性的问题,所以默认是设置 core 文件大小为 0

当然,gcc 和 g++ 编译器默认生成的是 Release 版本的程序,无调试功能,在编译的时候需要加上 -g 选项才能进行事后调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[root@xpu code]# gdb test
GNU gdb (GDB) Red Hat Enterprise Linux (7.2-92.el6)
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/code/test...done.
(gdb) core-file core.30712
[New Thread 30712]
Reading symbols from /lib64/libm.so.6...(no debugging symbols found)...don
Loaded symbols for /lib64/libm.so.6

Loaded symbols for /lib64/ld-linux-x86-64.so.2
Core was generated by `./test'.
Program terminated with signal 8, Arithmetic exception.
#0 0x00000000004009c4 in main () at test.cpp:7
24 i /= 0;
Missing separate debuginfos, use: debuginfo-install

很明显的除 0 错误,连行号都可以显示出来!

很显然,进程收到信号的时候有很多种选择,执行默认动作,忽略,执行自定义动作,那么信号如何捕捉?

1
2
3
4
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

只需要使用 signal 函数即可捕捉信号,参数里面的函数指针锁指向的函数将决定收到信号后究竟会做什么,下面这个例子展示了如何捕捉信号:
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
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

void handler(int signo){
sleep (1);
cout << "catch a sig, sigo:" << signo << " pid:" << getpid () << endl;
}

int main(){
int i = 0;
// 捕获一个除 0 之后异常的信号
signal (SIGFPE, handler);
while(1){
if(i++ >= 5){
i /= 0;
}
cout << "hello,world pid = "<< getpid () << endl;
sleep (1);
}
return 0;
}

为什么虽然捕捉到了信号,但是一直不停的捕捉信号呢?原因是因为捕捉到异常信号后没有终止进程,导致 PCB 上下文中保存着 CPU 的寄存器中的信息,该进程被切换出去之后当再次获得 CPU 执行权的时候,等到寄存器中的错误信息一恢复又会出现硬件错误,操作系统又会给进程发送 SIGFPE 信号,所以 在捕捉到异常信号的时候别忘记终止进程 ,本例中也就是在 handler 函数中添加一句 exit (1)

Kill 命令

kill 命令产生信号,这个不难理解,比如我们杀死进程用的 kill -9

系统调用

1
2
3
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);

这两个函数都是成功返回 0,错误返回 - 1。
kill 一般用于向别的进程发送信号,而 raise 用于进程自己向自己发送信号

1
2
#include <stdlib.h>
void abort(void);

abort 函数使当前进程接收到信号而异常终止,完全可以理解为调用 abort 函数的进程打算使用 6 号信号自杀,就像 exit 函数一样,abort 函数总是会成功的,所以没有返回值。即使 SIGABORT 被进程设置为阻塞信号,调用 abort () 后,SIGABORT 仍然能被进程接收!

Kill 命令其实就是调用了 kill 系统接口实现的命令,简单的实现一个 kill 命令:

1
2
3
4
5
6
7
8
9
//mykill 1234 9
int main(int argc, char* argv []){
if(argc != 3){
cout << " 参数异常 & quot; << endl;
return -1;
}
kill (atoi (argv [1]), atoi (argv [2]));
return 0;
}

软件条件产生信号

软件条件产生信号比较特殊,因为像野指针访问内存错误、除零这种错误其实都属于硬件错误,由于硬件发送错误引起的异常 (例如:你使用了野指针,那么直接导致报错的硬件就是 MMU),但是接下来要说的这种情况是软件产生的异常:
在学习管道的时候我们就发现,如果读端都把文件描述符关闭了,那么写端也不会再写了,操作系统向写端发送 9 号信号终止写端进程,避免资源浪费,所以由此可见:不但硬件错误会产生信号,软件条件或者错误同样会产生信号!

1
2
#include <unistd.h>
unsigned int alarm(unsigned int seconds);

调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
接下来的演示就是捕获一下 alarm 函数产生的信号,应该是捕获 14 SIGALRM 信号:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void handler(int singo){
cout << "singo = " << singo << endl;
}

int main(){
signal (SIGALRM, handler);
alarm (1);
sleep (5);
return 0;
}

阻塞信号

信号相关常见概念

  • 实际执行信号的处理动作称为信号递达 (Delivery)
  • 信号从产生到递达之间的状态,称为信号未决 (Pending)
  • 进程可以选择阻塞 (Block ) 某个信号
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
  • 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号在内核中的示意图

mark
每个信号都有两个标志位分别表示阻塞 (block) 和未决 (pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没 有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1 允许系统递送该信号一次或多次。Linux 是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,暂时不讨论实时信号。

sigset_t

sigset_t 是一种结构体,sigset_t 类型对于每种信号用个 bit 表示 “有效” 或 “无效” 状态,至于怎么实现,我们作为使用者无须在意,其定义在 /usr/include/bits/sigeset.h
从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的 “有效” 或 “无效” 状态,在阻塞信号集中 “有效” 和 “无效” 的含义是该信号是否被阻塞。而在未决信号集中 “有效” 和 “无效” 的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字 (Signal Mask),注意:这里的 “屏蔽” 应该理解为阻塞而不是忽略。

信号集操作函数

为什么提供了一组信号集操作函数呢?很明显 Linux 系统的设计者认为让其他人自行操作信号机是非常危险的一件事情,或者说设计者们根本不信任我们对比特位的操作能力,于是乎为我们提供了一种 API 来操作信号集,当然你可以理解为这是为了让我们这些使用者更方便!

sigset_t 类型对于每种信号用一个 bit 表示 “有效” 或 “无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的

1
2
3
4
5
6
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);

  • sigemptyset 此函数用于清空信号集,使得目标信号集中不包含任何有效信号
  • sigfillse 此函数用于初始化目标信号集,把所有的信号加入到此信号集里即将所有的信号标志位置为 1,可以理解为把所有信号都加入集合,如果你不想阻塞哪些信号再 sigdel 单独删去它们即可
  • sigaddsetsigdelset 在初始信号集之后就可以调用 sigaddsetsigdelset 在该信号集中添加或删除某种有效信号,两个函数都是成功返回 0,出错返回 - 1
  • sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1,不包含则返回 0,出错返回 - 1

    sigprocmask

    此函数用于读取或更改进程的信号屏蔽字 (阻塞信号集)
    1
    2
    3
    #include <signal.h>
    /* Prototype for the glibc wrapper function */
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。如果 oldset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 set 和 how 参数更改信号屏蔽字。

how : 如何更改进程的信号屏蔽字
假设当前的信号屏蔽字为 mask, 下表说明了 how 参数的可选值:
| 选项 | 描述 |
| :————-: | :—————————————————————————————: |
| SIG_BLOCK | set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask=mask|set |
| SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除的信号,相当于 mask=mask&~set |
| SIG_SETMASK | 设置当前信号屏蔽字为 set 所指向的值,相当于 mask=set |
sigset_t *set:要更改的信号屏蔽字的结构体指针
sigset_t *oldset:将原来的信号屏蔽字备份到 oldset 中,不需要备份传入 NULL 即可
return:若成功则为 0,若出错则为 - 1

注意:如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回之前,至少将其中一个信号递达!

sigpending

读取当前进程的未决信号集,通过 set 参数传出,调用成功返回 0,失败返回 - 1

1
2
#include <signal.h>
int sigpending(sigset_t *set);

下面的一个示例程序演示了上述函数的作用
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
#include <unistd.h>
#include <stdio.h>
#include <signal.h>

void printsigset( sigset_t *set )
{
int i = 0;
for (; i < 32; i++ )
{
if ( sigismember ( set, i ) ) /* 判断指定信号是否在信号集中 */
{
putchar( '1' );
}else{
putchar( '0' );
}
}
puts( "" );
}


int main()
{
/* 定义信号集对象,并清空初始化 */
sigset_t s, p;
sigemptyset ( &s );
sigaddset ( &s, SIGINT );
/* 设置阻塞信号集,阻塞 SIGINT 信号 */
sigprocmask ( SIG_BLOCK, &s, NULL );
while ( 1 )
{
/* 获取未决信号集 */
sigpending ( &p );
printsigset ( &p );
sleep ( 1 );
}
return(0);
}

mark
由于我们阻塞了 SIGINT 信号,所以 Ctrl C 也终止不了程序,SIGINT 信号处于未决状态,但是可以按 Ctrl \ 来终止程序

捕捉信号

mark

内核如何捕捉信号

如果信号的处理动作是用户的自定义函数,在信号递达的时候就会调用这个函数,这就是信号捕捉!

由于信号处理函数的代码是在用户空间,处理过程比较复杂,举例如下:用户程序注册了 SIGQUIT 信号的处理函数 sighandler。 当前正在执行 main 函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。

sigaction

sigaction 函数的功能是检查或修改与指定信号相关联的处理动作(或同时执行这两种操作)

1
2
3
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

signum:指定信号的编号或者类型
act:指定新的信号处理方式
sigaction:原来的信号处理方式
sigaction 结构体:
1
2
3
4
5
6
7
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}

若 act 指针非空,则根据 act 修改该信号的处理动作。若 oldact 指针非空,则通过 oact 传出该信号原来的处理动作。act 和 oldact 指向 sigaction 结构体:将 sahandler 赋值为常数 SIGIGN 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统所调用

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止 。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项,本章的代码都把 sa_flags 设为 0,sa_sigaction 是实时信号的处理函数,暂时不用关心

pause

pause 函数使调用进程挂起直到有信号递达

1
2
3
#include <unistd.h>

int pause(void);

如果信号的处理动作是终止进程,则进程终止,pause 函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause 不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后 pause 返回 - 1,errno 设置为 EINTR,所以 pause 只有出错的返回值,这和程序替换那几个函数是一样的,出错才返回,错误码 EINTR 表示 “被信号中断”。

接下来演示一个用闹钟 + 信号的方式实现的 sleep 函数 mysleep ()

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
#include <unistd.h>
#include <stdio.h>
#include <signal.h>

void sig_alrm( int signo )
{
/* DO NOTHING */
}


unsigned int mysleep( unsigned int nescs )
{
struct sigaction new, old;
unsigned int unslept = 0;
new.sa_handler = sig_alrm;
sigemptyset ( &new.sa_mask );
new.sa_flags = 0;
// 注册信号处理函数
sigaction ( SIGALRM, &new, &old );
// 设置闹钟
alarm ( nescs );
pause ();
// 清空闹钟
unslept = alarm ( 0 );
// 恢复默认信号处理动作
sigaction ( SIGALRM, &old, NULL );
return(unslept);
}


int main()
{
while ( 1 )
{
mysleep ( 5 );
printf( "5 seconds passed\n" );
}
return(0);
}

执行流程分析:
1.main 函数调用 mysleep 函数,后者调用 sigaction 注册了 SIGALRM 信号的处理函数 sig_alrm

  1. 调用 alarm (nsecs) 设定闹钟
  2. 调用 pause 等待,内核切换到别的进程运行
  3. nsecs 秒之后,闹钟超时,内核发 SIGALRM 给这个进程
  4. 从内核态返回这个进程的用户态之前处理未决信号,发现有 SIGALRM 信号,其处理函数是 sig_alrm
  5. 切换到用户态执行 sig_ alrm 函数,进入 sig_ alrm 函数时 SIGALRM 信号被自动屏蔽,从 sig_alrm 函数返回时 SIGALRM 信号自动解除屏蔽。然后自动执行系统调用 sigreturn 再次进入内核,再返回用户态继续执行进程的主控制流程 (main 函数调用的 mysleep 函数)
  6. pause 函数返回 - 1,然后调用 alarm (0) 取消闹钟,调用 sigaction 恢复 SIGALRM 信号以前的处理动作

接下来说明关于 mysleep 函数的几个问题:
:信号处理函数 sig_alrm 什么都没干,为什么还要注册它作为 SIGALRM 的处理函数?不注册信号处理函数可以吗?
:很显然,注册 sig_alrm 函数是很有必要的,因为绑定了自定义的处理函数则会从内核态切换到用户态运行 sig_alrm 函数,这样才不至于回到主控制流程,pause 函数使调用进程挂起直到有信号递达,如果不注册 SIGALRM 处理函数,当有信号 SIGALRM 信号产生时会执行默认动作,终止进程
:为什么在 mysleep 函数返回前要恢复 SIGALRM 信号原来的 sigaction? 不恢复会怎样?
:必须要恢复信号处理方式,因为 sleep 函数是不会修改 SIGALRM 信号的,将 SIGALRM 不恢复会使 alarm () 失效
:mysleep 函数的返回值表示什么含义?什么情况下返回非 0 值?
:mysleep 的返回值是在信号 SIGALRM 信号传来时闹钟还剩余的秒数;当闹钟结束前有其他信号发送给该进程,并该进程对其进行了相关的处理时,alarm (0) 取消闹钟会使返回值非零

可重入函数

这个概念不难理解,现在假设一个进程正陷入内核态,现在正好要返回用户态执行到一个函数 function 的时候,现在呢进程收到了一个信号,进程当然要处理这个信号,于是执行自定义动作,在用户自定义处理该信号的函数中,恰好又调用了 function 函数,那么这就叫做该函数被重入了!

下面这个例子很详细,非常能说明可重入函数的概念:
mark

main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,最后只有一个节点真正插入链表中了。

像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入 (Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

不可重入的函数的条件

  • 其中有 static、全局变量等的函数也是不可重入函数
  • 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的
  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构

volatile

volatile 关键字的作用是保证内存的可见性,确保本条指令不会因编译器的优化而省略,且要求每次直接读值,volatile 的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <signal.h>

//int flag = 0;
volatile int flag = 0;

void handler(int singo)({
flag = 1;
printf("chage flag to 1\n");
}

int main (){
signal (2, handler);
while(!flag);
printf("proc done ...\n");
return 0;
}

在本例中:假设我们不加 volatile 修饰变量 flag,那么在 gcc 编译器优化级别为 2 的时候,main 执行流是不会从内存中拿数据的,即使已经进程收到 SIGINT 信号之后改了 flag 的值,main 执行流也是直接从寄存器上面拿数据,所以 Ctrl C 也不会结束进程!
mark
使用 volatile 修饰之后无论编译器的优化级别是怎么样的,CPU 都可以从内存中拿 flag 的值,所以只需要将 flag 用 volatile 修饰便可以的到我们预期的结果!

竞态条件与 sigsuspend 函数

最难处理的问题很多都是时序问题!!!

设想上述 mysleep 这样的时序:

  1. 注册 SIGALRM 信号的处理函数
  2. 调用 alarm (nsecs) 设定闹钟
  3. 内核调度优先级更高的进程取代当前进程执行,并且优先级更高的进程有很多个,每个都要执行很长时间
    4.nsecs 秒钟之后闹钟超时了,内核发送 SIGALRM 信号给这个进程,处于未决状态
  4. 优先级更高的进程执行完了,内核要调度回这个进程执行。SIGALRM 信号递达,执行处理函数 sig_alrm 之后再次进入内核
  5. 返回这个进程的主控制流程,alarm (nsecs) 返回,调用 pause () 挂起等待
  6. 可是 SIGALRM 信号已经处理完了,还等待什么呢?

出现这个问题的根本原因是系统运行的时序 (Timing) 并不像我们写程序时所设想的那样。 虽然 alarm (nsecs) 紧接着的下一行就是 pause (),但是无法保证 pause () 一定会在调用 alarm (nsecs) 之 后的 nsecs 秒之内被调用。由于异步事件在任何时候都有可能发生 (这里的异步事件指出现更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题 而导致错误,这叫做竞态条件 (Race Condition)

很显然,我们需要解决的问题就是:
从解除信号屏蔽到调用 pause 之间存在间隙,SIGALRM 仍有可能在这个间隙递达。 要消除这个间隙, 我们把解除屏蔽移到 pause 后面可以吗?很显然不行,还没有解除屏蔽信号就调用 pause 将会导致根本等不到 SIGALRM 信号,我们需要的是将 “解除信号屏蔽” 和 “挂起等待信号” 这两步能合并成一个原子操作,这就是 sigsuspend 函数的功能。sigsuspend 包含了 pause 的挂起等待功能,同时解决了竞态条件的问题, 在对时序要求严格的场合下都应该调用 sigsuspend 而不是 pause

1
2
#include <signal.h>
int sigsuspend(const sigset_t *mask);

和 pause 一样,sigsuspend 没有成功返回值,只有执行了一个信号处理函数之后 sigsuspend 才返回,返回值为 - 1,errno 设置为 EINTR
调用 sigsuspend 时,进程的信号屏蔽字由 sigmask 参数指定,可以通过指定 sigmask 来临时 解除对某个信号的屏蔽,然后挂起等待,当 sigsuspend 返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从 sigsuspend 返回后仍然是屏蔽的

下面是使用示例:

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
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
void sig_alrm(int signo)
{
printf("% d, sig_alrm\n", signo);
}

unsigned int mysleep(unsigned int nsecs)
{
struct sigaction newact, oldact;
sigset_t newmask, oldmask, suspmask;
unsigned int unslept;

newact.sa_handler = sig_alrm;
sigisemptyset (&newact.sa_mask);
newact.sa_flags = 0;
sigaction (SIGALRM, &newact, &oldact);

sigemptyset (&newmask);
sigaddset (&newmask, SIGALRM);
sigprocmask (SIG_BLOCK, &newmask, &oldmask);

alarm (nsecs);
suspmask = oldmask;
sigdelset (&suspmask, SIGALRM);
sigsuspend (&suspmask);

unslept = alarm (0);
sigaction (SIGALRM, &oldact, NULL);

sigprocmask (SIG_SETMASK, &oldmask, NULL);
return unslept;
}

int main(){
mysleep (5);
printf("5s seconds pass...\n");
return 0;

SIGCHLD 信号

在之前学过的进程中,父进程通过 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理 (也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义函数捕获 SIGCHLD 信号:父进程在信号处理函数中调用 wait 清理子进程即可

由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用:

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
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <wait.h>

void handler(int sig)
{
pid_t id;
printf("sig = % d\n", sig);
while((id = waitpid (-1, NULL, WNOHANG)) > 0)
{
printf("wait child success: % d\n", id);
}
printf("child is quit! % d\n", getpid ());
}
int main()
{
signal (SIGCHLD, handler);
pid_t cid;
if((cid = fork ()) == 0)
{
printf("child: % d\n", getpid ());
sleep (3);
exit(1);
}

while(1)
{
printf("father proc is doing some thing!\n");
sleep (1);
}
return 0;
}

mark

信号部分总结完毕,接下来看看常用的普通信号(慢慢遇到了再补充):
| 编号 | 信号 | 含义 | 缺省动作 |
| —— | ———- | ———————————————— | ———————————————————— |
| 1 | SIGHUP | 终端挂起或者控制进程终止 | 终止进程 |
| 2 | SIGINT | 键盘中断(如 Ctrl C) | 终止进程 |
| 3 | SIGQUIT | 键盘的退出键被按下 | 终止进程并核心转储(dump core) |
| 6 | SIGABRT | 由 abort () 发出的退出指令 | 终止进程并核心转储(dump core) |
| 8 | SIGFPE | 浮点异常,比如错零错误 | 终止进程并核心转储(dump core) |
| 9 | SIGKILL | Kill 信号 | 终止进程、信号不能被捕获 、信号不能被忽略 |
| 11 | SIGSEGV | 无效的内存引用,比如野指针 | 终止进程并核心转储(dump core) |
| 13 | SIGPIPE | 管道破裂:写一个没有读端口的管道 | 终止进程 |
| 14 | SIGALRM | 由 alarm 函数发出的信号 | 终止进程 |
| 17 | SIGCHLD | 子进程结束信号 | 忽略此信号 |
| … | … | … | … |