引入信号后的几种陷阱讲解

程序在引入信号机制后会变的非常多元化,程序在某些情况下难以理解并且会出现一些非常奇特的问题,但这些问题经过总结无非是因为使用了不可重入函数、信号引起的时序竞态、信号处理函数与主程序的异步io过程中出现的问题。要避免这些问题,我们要先来复现和分析这些情况是如何出现的,才能针对性的去解决这些问题。


【可重入/不可重入函数】

在程序执行到某个多步逻辑处理操作时,忽然接收到信号,而信号也同样在处理同一处逻辑,这样可能会造成此处逻辑,考虑以下场景:

2015-07-04_193819

当主程序正在执行一段新节点插入链表的操作,最后一步头节点指向新节点的操作还尚未执行时,程序接收到了信号,而信号处理函数也同样执行了一个新的节点插入操作,插入完成后又返回主函数。这样的整个过程结束后,信号处理函数中插入的节点相当于白白浪费了,跳出信号函数后,头节点又指向了在进入信号函数之前插入的那个节点位置。这样的节点插入函数,我们就称为“不可重入”函数。在信号的捕捉处理函数中,一定要避免这种不可重入函数的使用。那么怎么区分这些不可重入函数呢?

一般不可重入函数都是操作了公共的数据结构或静态变量,像我们刚才举的例子中,链表就是一个公共的数据结构。再比如 strtok 函数,该函数内部维护了一个静态的变量用来记录每次处理字符串分割后的位置,如果主进程函数和信号捕获的处理函数中同时调用了 strtok 函数,那么就有可能引起错乱。要避免这些情况,linux/unix 系统中给我们提供了很多“可重入”函数,参见 man 7 signal:

2015-07-04_195246

上图中就是一些信号安全函数,我们可以放心调用,你在使用 man page 的时候也会发现,有一些函数除了正常声明以外,还有一些声明带有 _r 结尾的,如下图:

2015-07-04_195413

这些带有 _r 的函数同样就是信号安全函数,这个 strtok_r 需要我们自己传递一个指针来记录每次处理分割字符串的位置,这样就不会因为使用了公共的静态变量而导致处理错乱的情况了,所以切记,在信号捕获处理函数中,一定要使用可重入的函数。


【信号引起的时序竞态】

还记得我们以前写的 mysleep 延迟函数吗?我们来分析一下这个延迟函数的实现,看一看这种极端情况,如下图:

2015-07-05_103713

当我们注册了 alarm 函数时,传递了1,当 alarm 函数执行完毕后,此时该进程 CPU 时间片耗尽,CPU 被其他程序抢占,但这是 alarm 是由硬件在即时的,它不会因为CPU被其他程序抢占而暂停即时,而是继续即时,当其他程序在占用 CPU 时间片时,alarm 超时发送了信号,可当前程序还处于挂起状态,内核只记录了程序接下来该去执行信号捕获处理函数,而此时 pause 并未得到执行。当 CPU 时间片再次回到当前程序时,程序优先处理 alarm 信号捕获函数,然后再继续执行下面的 pause,可已经错过了 alarm 信号的 pause 将永远得不到执行,这也是 linux/unix 历史上一个重大的 bug。被我们成为时序竞态

那这种情况我们该如何处理呢?如果我们在执行 alarm 函数之前调用一个信号阻塞函数,把 SIGALRM 信号给阻塞掉,然后在 pause 之前将阻塞的信号解除,这样如果 CPU 被其他程序抢占,再回到程序时,pause 能成功接收到 SIGALRM 信号吗?其实与上面的场景也是一样的,因为解除信号屏蔽和 pause 之前一样是存在间隙的,如果在这个间隙中 CPU 被抢占,pause 一样也是无法得到 SIGALRM 信号的。linux/unix 系统为了解决这个问题,给出了以下函数原型:

int sigsuspend(const sigset_t *mask)

该函数有如下三个作用:

  1. 以通过指定mask来临时解除对某个信号的屏蔽,
  2. 然后挂起等待,
  3. 当被信号唤醒sigsuspend返回时,进程的信号屏蔽字恢复为原来的值

这三步被 sigsuspend 函数当作一步,那么最后一个正确的程序代码如下:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void dosig(int n)
{
}

int mysleep(int sec)
{
	int ret;
	struct sigaction act, oldact;

	// 捕获 SIGALRM 信号,交给 dosig 处理函数,oldact结构体保留原信号处理信息
	act.sa_handler = dosig;
	// 清空设定掩码
	sigemptyset(&act.sa_mask);
	// 设置标志位为0,代表使用 sa_handler 函数指针
	act.sa_flags = 0;
	// 捕获 SIGALRM 信号,第二个参数时上面设定好的结构体,第三个参数时备份
	sigaction(SIGALRM, &act, &oldact);

	// 阻塞 SIGALRM 信号
	sigset_t block, oldset, suspend;
	sigemptyset(&block);
	sigaddset(&block, SIGALRM);
	sigprocmask(SIG_BLOCK, &block, &oldset);

	// 根据传递进来的秒数发送一个 SIGALRM 信号
	alarm(sec);

	// 获取原来的信号阻塞集列表
	suspend = oldset;
	// 将 SIGALRM 信号解除
	sigdelset(&suspend, SIGALRM);
	/* 
	 * 该函数执行了三个操作
	 * 1、利用上面给出的信号屏蔽字解除了对 SIGALRM 的屏蔽
	 * 2、使程序暂停等待接收信号
	 * 3、收到信号后恢复程序运行并将信号集恢复为原来的
	 */
	sigsuspend(&suspend);

	// 将 alarm 置零并记录返回值
	ret = alarm(0);
	// 接触对 SIGALRM 信号的屏蔽
	sigprocmask(SIG_SETMASK, &oldset, NULL);
	// 恢复原有信号处理方式
	sigaction(SIGALRM, &oldact, NULL);
	return ret;
}

int main(int argc, char* argv[])
{
	printf("Hello World...\n");
	mysleep(10);
	printf("Hello World...\n");
	return 0;
}

【异步IO】

1、对数据IO非原子操作

在32位环境下,long类型是4个字节大小,long long是8个字节大小,long在转化为汇编代码后,是一条指令(我们称为最小原子操作),而 long long 在转化为汇编指令后由于32位寄存器只能存储4个字节的单位,所以被分成了两句汇编指令来完成了。而如果在这两句汇编指令执行的中间过程收到信号,信号也同时对这个 long long 类型进行了赋值会出什么问题?

与第一种情况类似,这个 long long 类型说不定是一个什么值,信号捕获处理函数和主函数同时对一个非原子类型进行了异步的IO操作,想解决这种问题可以使用引入信号机制后新引入的一种数据类型 sig_atomic_t,这个类型是平台相关的,32位下和64位下 sizeof 可能大小不一样。主要目的就是解决我们上面提到的问题,让操作这个变量时可以实现一步原子操作。

2、编译器过度优化忽略信号处理

有如下一个 while 循环:

#include <stdio.h>

int main(int argc, char* argv[])
{
	int a = 10;
	while (a = 10)
	{
		printf("%d\n", a);
		sleep(1);
	}
}

在使用编译器编译以上代码时,由于 a 的值是固定的,编译器很可能将 a 的值直接存放在寄存器中,不会每次都到内存中去取这个值而导致运行效率减缓的情况。

但是,如果这个变量被存放在寄存器上,不是每次都到内存中读取的话,我们信号处理函数想终止这个循环(改变a的值)的话,就实现不了了。比如我们在信号处理函数中修改了a在内存中的数值为0,而程序因为编译器的优化在运行过程中一直在寄存器中读取数据,而不是每次都从内存中取数据,这将导致这个循环永远都无法结束。

解决这种问题非常简单,那么就是给这个需要让信号操作的变量加上 volatile 关键字,该关键字是让编译器不对该变量进行优化,而是在每次使用的时候都从内存中重新读取,这样在信号函数修改了a在内存中的数据时,外部循环也就能根据修改的值随时变动了。


以上便是我们提到的三种引入信号后可能会出现的奇怪问题,因为纯文字总结,可能会有一些困扰,有任何疑问的可以随时通过关于本站的联系方式联系我。

 

发表评论