Linux信号(三)—— 时序竞态

pause 函数

调用该函数可以造成进程主动挂起,等待信号唤醒。调用该系统调用的进程将处于阻塞状态(主动放弃 CPU)直到有信号递达将其唤醒。

int pause(void);  //返回值:-1,并设置 errno 为 EINTR

[返回值]

  1. 如果信号的默认处理动作是终止进程,则进程终止,pause 函数没有机会返回
  2. 如果信号的默认处理动作是忽略,进程继续处于挂起状态,pause 函数不返回
  3. 如果信号的处理动作是捕捉,则调用完信号处理函数之后,pause 函数返回-1,errno 设置为 EINTR,表示 “被信号中断”。想想我们哪个函数只有出错返回值(exec)
  4. 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 函数,设想如下时序:

  1. 注册 SIGALRM 信号处理函数(sigaction)
  2. 调用 alarm(1) 函数 设置闹钟 1 秒
  3. 函数调用刚结束,开始倒计时 i 秒。当前进程失去 CPU,内核调度优先级高的进程(有多个)取代当前进程。当前进程无法获得 CPU,进入就绪状态等待 CPU
  4. 1秒后,闹钟超时,内核向当前进程发送 SIGALRM 信号(自然定时法,与进程状态无关),高优先级进程尚未执行完,当前进程仍处于就绪态,信号无法处理(未决)
  5. 优先级高的进程执行完,当前进程获得 cpu 资源,内核调度回当前进程执行。SIGALRM 信号递达,信号设置捕捉,执行处理函数 myalarm
  6. 信号处理函数执行结束,返回当前进程主控流程,pause() 被调用挂起等待。(就等待 alarm 函数发送的 SIGALRM 信号将自己唤醒)
  7. 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)。

【注意事项】

  1. 定义可重入函数,函数内不能含有全局变量及 static 变量,不能使用 malloc、free
  2. 信号捕捉函数应设计为可重入函数
  3. 信号处理程序可以调用的可重入函数可参阅 man 7 signal
  4. 没有包含在上述列表中的函数大多是不可重入的,其原因为:使用了静态数据结构、调用了 malloc/free、是标准 I/O 函数
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值