前面两章节基本将Linux系统下信号知识都介绍了一遍,这里对信号机制进行补充。虽然信号给我们编程带来了极大的方便,但是其原则还是异步操作,内核在调度进程上不可能像我们预期想象的那样运行,因此会额外带来其他问题,这里就进行统一的阐述。
信号引起的竞态
普通版sleep
先来看一下信号机制中普通版本的sleep函数是如何实现的:
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void sig_alrm(int i){
}
unsigned int mysleep(unsigned int nsecs){
struct sigaction newact, oldact;
unsigned int unslept;
// 注册捕捉函数
newact.sa_handler = sig_alrm;
newact.sa_flags = 0;
sigemptyset(&newact.sa_mask);
// 注册阻塞捕捉信号 SIGALRM
sigaction(SIGALRM, &newact, &oldact);
// alarm定时开启
alarm(nsecs);
// 挂起等待,知道被唤醒
pause();
unslept = alarm(0);
// 恢复原来信号屏蔽字,否则 SIGALRM 信号一直被程序阻塞
sigaction(SIGALRM, &oldact, NULL);
return unslept;
}
int main(void){
while(1){
mysleep(2);
printf("Two seconds passed\n");
}
return 0;
}
通过之前的捕捉函数处理流程可以得知,程序最大的缺点是:系统运行的时序并不像我们写程序时所设想的那样,虽然alarm紧接着下一步就是pause,但是无法保证pause一定会在调用alarm之后的指定秒之内被调用,因为捕捉函数的调用是由内核返回用户态监测时后调用的。因此这种情况下就导致了时序竞态的产生。
竞态条件: 由于异步事件在任何时候都有可能会发生(异步事件在这里指的是更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这就是竞态条件。
避免时序竞态sleep
设想如何将解除信号屏蔽与挂起等待信号合并成一个原子操作就可以避免因时序问题导致的错误,因此引入sigsuspend函数。它不仅用于pause函数的挂起等待功能,而且解决了竞态条件产生的时序问题。
sugsuspeng 原理: 用于在接收到某个信号之前,临时用mask替换进程的信号掩码,并暂停进程执行,直到收到信号为止。调用sigsuspend后,进程就挂在那里,等待着开放的信号的唤醒。系统在接收到信号后,马上就把现在的信号集还原为原来的,然后调用处理函数。
int sigsuspend(const sigset_t *mask);
mask:指定进程的信号屏蔽字,可以临时解除对某一个信号的屏蔽,然后挂起等待。当suspend返回时,进程的信号屏蔽字恢复原先的值,如果原先对信号是屏蔽的,返回后仍然屏蔽。
返回值:返回值与pause一致,永远返回-1,errno设置为EINTR。
sigsuspend的整个原子操作过程为:
- 设置新的mask阻塞当前进程;
- 挂起等待,收到信号,恢复原先mask;
- 调用该进程设置的信号处理函数;
- 待信号处理函数返回后,sigsuspend返回。
使用sigsuspend解决时序竞态sleep函数代码
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void sig_alrm(int i){
}
unsigned int mysleep(unsigned int nsecs){
struct sigaction newact, oldact;
sigset_t newmask, oldmask, suspmask;
unsigned int unslept;
newact.sa_handler = sig_alrm;
newact.sa_flags = 0;
sigemptyset(&newact.sa_mask);
// 注册信号捕追
sigaction(SIGALRM, &newact, &oldact);
// 信号捕追时,启动信号屏蔽字
sigemptyset(&newmask);
// 添加指定信号屏蔽字 SIGALRM
sigaddset(&newmask,SIGALRM);
// 更改信号屏蔽字,已阻的方式开启定时
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
alarm(nsecs);
// 开始定时后,删除原有 mask 中 SIGALRM,只有 SIGALRM 才能通过
suspmask = oldmask;
sigdelset(&suspmask,SIGALRM);
// 使用sigsuspend 替换 pause,使用新的 mask
// 此时信号屏蔽字更改临时的suspmask,非阻塞信号SIGALRM,挂起等待
// 一旦有信号SIGALRM立即唤醒,恢复原来信号屏蔽字oldmask(阻塞SIGALRM),便立即进入捕捉函数处理
sigsuspend(&suspmask);
// 检查定时剩余时间
unslept = alarm(0);
sigaction(SIGALRM, &oldact, NULL);
// 信号捕追完毕,恢复信号原有 mask
sigprocmask(SIG_SETMASK, &oldmask, NULL);
return unslept;
}
int main(void){
while(1){
mysleep(2);
printf("Two seconds passed\n");
}
return 0;
}
以上通过函数 sigsuspend 避免了时序竞态引起的问题,其主要流程是:能够在信号 SIGALRM 到达时立即响应处理,通过实现不阻塞 SIGALRM 唤醒进程,然后恢复原来设置的信号屏蔽字后(阻塞捕捉 SIGALRM),直接进入信号捕捉函数,然后返回。
可重入函数与不可重入函数
函数是一段载入到内存的代码。函数的代码可长可短,执行时间长度也不确定。在多线程中,线程之间是可以进行切换的。函数是一段写好的代码,属于程序公有的代码段。一个进程中有多个线程,每一个线程都可以调用这段函数代码执行。而在多线程环境中,线程的切换是无法预料的,你不知道下一秒是哪个线程在执行,每时每刻的运行环境都不一样,因为线程切换也是变化莫测的。这是操作系统调度进程线程的范围,不是我们能够掌控的。
信号作为一种软中断,能够被进程给捕获,因而也就中断进程的正常执行,转而去执行信号处理程序,最后再返回到原进程继续正常执行。因此就会涉及到可重入函数问题了。
可重入函数
一个函数在执行的过程中被打断,然后会再被再重头执行一次,执行完后,再回来把刚才没执行完的部分执行完。这就相当于嵌套的执行了。函数是公共代码,这样的执行是允许的。函数的执行可以被打断,打断之后还可以再重头执行,执行完后接着执行刚才没有执行的代码,然后第一次执行的代码(被打断的函数)执行结果还是正确的。也就是说,这个函数执行,无论中间把这个函数再嵌入执行多少遍,怎么嵌入,最终执行完,执行的结果都是正确的,这样的函数就是可重入函数。
常用的可重入函数的方法有:
- 不要使用全局变量,防止别的代码覆盖这些变量的值。
- 调用这类函数之前先关掉中断,调用完之后马上打开中断。防止函数执行期间被中断进入别的任务执行。
- 使用信号量(互斥条件)。
总而言之:要保证中断是安全的。
使用 man 7 signal 查看
不可重入函数
在函数执行期间被中断,从头执行这个函数,执行完毕后再返回刚才的中断点继续执行,此时由于刚才的中断导致了现在从新在中断点执行时发生了不可预料的错误。那么这类函数就是不可重入函数。
常见的不可重入函数:
- 使用了静态数据结构
- 调用了malloc和free等
- 调用了标准I/O函数
- 进行了浮点运算
如何避免写出不可重入函数
- 不使用或者互斥使用全局变量
- 不使用静态局部变量,只是用局部变量,
- 在函数中动态分配的内存只在本函数中使用,不会传递函数外使用,
只要保证局部特性,函数中使用的所有东西都只有局部性,对外不公开,用完即释放,就可以保证可重入。这样,不管怎么重叠,反正本层的函数的东西只有本层能够使用,其他层的函数无法使用。
信号中的可重入函数
信号捕捉函数内部,禁止调用不可重入函数。
strtok就是一个不可重入函数,因为strtok内部维护了一个内部静态指针,保存上一次切割到的位置,如果信号的捕捉函数中也去调用strtok函数,则会造成切割字符串混乱, 应用strtok_r版本,r表示可重入。
总结
通过三篇信号博文,信号的部分就告一段落了。从信号的基础认识,到信号相关函数的介绍,以及后续对信号机制带来的其他问题等方面,系统的阐述了Linux下信号的内容,相信读完三篇内容的你,应该会对信号有更深层次的了解。
我们知道信号机制可以在多进程中进行通信,当然在多线程中使用也是可以的。但是这对开发者来说并非好事,使用时必须要严格控制管理,否则会造成不可预估的后果。
邢文鹏Linux教学资料