pause 函数
调用该函数可以造成进程主动挂起,等待信号唤醒。调用该系统调用的进程将处于阻塞状态(主动放弃 CPU)直到有信号递达将其唤醒。
int pause(void); //返回值:-1,并设置 errno 为 EINTR
[返回值]
- 如果信号的默认处理动作是终止进程,则进程终止,pause 函数没有机会返回
- 如果信号的默认处理动作是忽略,进程继续处于挂起状态,pause 函数不返回
- 如果信号的处理动作是捕捉,则调用完信号处理函数之后,pause 函数返回-1,errno 设置为 EINTR,表示 “被信号中断”。想想我们哪个函数只有出错返回值(exec)
- pause 收到的信号不能被屏蔽,如果被屏蔽,那么 pause 就不能被唤醒。
【练习】:使用 pause 函数 和 alarm 函数来实现 sleep 函数。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <assert.h>
#include <errno.h>
void myalarm(int signum)
{
return;
}
unsigned int mysleep(unsigned int seconds)
{
//signal(SIGALRM, myalarm);
int ret;
struct sigaction act, oldact;
act.sa_handler = myalarm;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
ret = sigaction(SIGALRM, &act, &oldact);
assert(ret != -1);
alarm(seconds);
ret = pause();
assert(ret == -1 && errno == EINTR); //注意,当返回值为-1时,pause() 函数执行成功
ret = alarm(0); //清除闹钟
sigaction(SIGALRM, &oldact, NULL); //恢复 SIGALRM 信号原有的处理方式
return ret; //返回上一次闹钟剩余时间
}
int main()
{
while(1)
{
printf("hello world\n");
mysleep(1);
}
return 0;
}
【运行结果】
时序问题分析
设想如下场景:
想睡觉,定闹钟10分钟,希望10分钟后闹铃将自己唤醒
- 正常情况:定时,睡觉,10分钟后被闹钟唤醒
- 异常情况:闹钟定好后,被唤走,外出劳动,20分钟后劳动结束。回来继续睡觉,但劳动期间闹钟已经响过,不会再将我唤醒
回顾,借助 pause 和 alarm 函数实现的 mysleep 函数,设想如下时序:
- 注册 SIGALRM 信号处理函数(sigaction)
- 调用 alarm(1) 函数 设置闹钟 1 秒
- 函数调用刚结束,开始倒计时 i 秒。当前进程失去 CPU,内核调度优先级高的进程(有多个)取代当前进程。当前进程无法获得 CPU,进入就绪状态等待 CPU
- 1秒后,闹钟超时,内核向当前进程发送 SIGALRM 信号(自然定时法,与进程状态无关),高优先级进程尚未执行完,当前进程仍处于就绪态,信号无法处理(未决)
- 优先级高的进程执行完,当前进程获得 cpu 资源,内核调度回当前进程执行。SIGALRM 信号递达,信号设置捕捉,执行处理函数 myalarm
- 信号处理函数执行结束,返回当前进程主控流程,pause() 被调用挂起等待。(就等待 alarm 函数发送的 SIGALRM 信号将自己唤醒)
- SIGALRM 信号已经处理完毕,pause() 永远不会等到,将导致进程永远阻塞。
假设在执行完以上代码的 alarm(seconds) 之后,该进程失去 CPU。再过了大于 seconds 秒后,该进程又获得了 CPU,这时候 alarm 函数发出的 SIGALRM 信号已经递达,进程首先出来信号的捕捉函数,在捕捉函数执行完成返回后,继续向下执行 pause() 函数。这时,pause() 函数一直等待 SIGALRM 信号的到达,但是 SIGALRM 信号已经被处理了,所以这个进程会永远阻塞在 pause() 函数的调用上,这个是致命的。
解决时序问题
可通过设置屏蔽 SIGALRM 的方法来控制程序执行逻辑,但无论如何设置,程序都有可能在 “解除信号屏蔽” 与 “挂起等待信号” 这两个操作间隙失去 CPU 资源。除非将这两步合并成一个 “原子操作”。sigsuspend 函数具备这个功能。在对时序要求严格的场合下都应该使用 sigsuspend 替换 pause。
int sigsuspend(const sigset_t *mask); //挂起等待信号
sigsuspend 函数调用期间,进程信号屏蔽字由参数 mask 指定
可将某个信号(SIGALRM)从临时信号屏蔽字 mask 中删除,这样在调用 sigsuspend 时将解除对该信号的屏蔽,然后挂起等待,当 sigsuspend 返回时,进程的信号屏蔽字恢复为原来的值。如果原来对该信号是屏蔽态,sigsuspend 函数返回后仍然屏蔽该信号
使用 sigsuspend 实现 sleep
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <assert.h>
#include <errno.h>
void myalarm()
{
return;
}
//仿照 sleep 函数
unsigned int mysleep(unsigned int seconds)
{
int ret;
struct sigaction act, oldact;
sigset_t mask, oldmask, suspmask;
//1. 为 SIGALRM 设置捕捉函数
act.sa_handler = myalarm;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
ret = sigaction(SIGALRM, &act, &oldact); //注册 SIGALRM 的捕捉函数
assert(ret != -1);
//2. 设置阻塞信号集,阻塞SIGALRM信号
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
sigprocmask(SIG_BLOCK, &mask, &oldmask);
//3. 定时 seconds 秒
alarm(seconds);
//4. 构造一个调用sigsuspend 临时有效的阻塞信号集
//在临时阻塞信号集里解除 SIGALRM 的阻塞
suspmask = oldmask;
sigdelset(&suspmask, SIGALRM);
//5. 在sigsuspend 调用期间,采用临时阻塞信号集 suspmask 替换原有信号集
//当这个信号集中不包含 SIGALRM 信号,同时挂起等待
//当这个sigsuspend被信号唤醒返回时,恢复原有的阻塞信号集
sigsuspend(&suspmask); //原子操作
ret = alarm(0);
//6. 恢复 SIGALRM原有处理动作
sigaction(SIGALRM, &oldact, NULL);
//7. 恢复原有的mask
sigprocmask(SIG_SETMASK, &oldmask, NULL);
return ret;
}
int main()
{
while(1)
{
printf("hello world\n");
mysleep(1);
}
return 0;
}
【运行结果】
总结
- 竞争条件,跟系统负载有很紧密的关系,体现出信号的不可靠性。系统负载越严重,信号不可靠性越强。
- 不可靠由其实现原理所致。信号是通过软件方式实现(跟内核调度高度依赖,延时性强),每次系统调用结束后,或中断处理结束后,需要通过扫描 PCB 中的未决信号集,来判断是否应处理某个信号。当系统负载过重时,会出现时序混乱。
- 这种意外情况只能在编写程序过程中,提前预见,主动规避,而无法通过 gdb 程序调试等其他手段弥补。且由于该错不具规律性,后期捕捉十分困难。
全局变量异步 I/O
分析如下父子进程交替数数程序,当捕捉函数里的 sleep 取消,程序即会出现问题。请分析原因。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <assert.h>
#include <errno.h>
int flag = 0; //当flag = 1时,数数完成的标志;flag = 0时,信号发送完毕的标志
int num = 0; //计数器
void do_parent()
{
printf("I am parent, num = %d\n", num);
num += 2;
flag = 1; //数数完成
sleep(1);
}
void do_child()
{
printf("I am child, num = %d\n", num);
num += 2;
flag = 1;
sleep(1);
}
int main()
{
pid_t pid;
struct sigaction act;
pid = fork();
if(pid > 0)
{
num = 1;
sleep(1); //确保子进程注册信号处理函数能够完成
//注册信号
act.sa_handler = do_parent;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(SIGUSR2, &act, NULL);
do_parent();
while(1)
{
if(flag == 1) //数数完成,发信号给子进程
{
kill(pid, SIGUSR1);
flag = 0;
}
}
}
else if(pid == 0)
{
num = 2;
//注册信号
act.sa_handler = do_child;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(SIGUSR1, &act, NULL);
while(1)
{
if(flag == 1)
{
kill(getppid(), SIGUSR2);
flag = 0; //已经给父进程发送完信号了
}
}
}
return 0;
}
【运行结果】
抛出一个问题:如果去掉 do_parent 和 do_child 函数中的 sleep 函数,会出现什么现象?
原因分析
问题出现的位置,在父子进程 kill 函数之后需要紧接着调用 flag,将其置0,标记信号已经发送。但,在这期间很有可能被 kernel 调度,失去执行权力,而对方获得了执行时间,通过发送信号回调捕捉函数,从而修改了全局的 flag。
解决办法
可以使用 “锁” 机制。当操作全局变量的时候,通过加锁、解锁来解决该问题。
可重入/不可重入函数
一个函数在被调用执行期间(尚未调用结束),由于某种时序又被重复调用,称之为 “重入”(比如:递归)。根据函数实现的方法可分为 “可重入函数” 和 “不可重入函数” 两种。
看如下时序
本来,在该程序中调用了两次 insert 函数,那么应该插入两个节点,但是结果显示,只插入了一个节点。显然,insert 函数是不可重用函数,重入调用,会导致意外结果呈现。究其原因,是该函数内部使用了全局变量(head)。
【注意事项】
- 定义可重入函数,函数内不能含有全局变量及 static 变量,不能使用 malloc、free
- 信号捕捉函数应设计为可重入函数
- 信号处理程序可以调用的可重入函数可参阅 man 7 signal
- 没有包含在上述列表中的函数大多是不可重入的,其原因为:使用了静态数据结构、调用了 malloc/free、是标准 I/O 函数