Linux中的信号机制

信号就是一条消息,通知进程系统中发生了什么事,每种信号都对应着某种系统事件。一般的底层硬件异常是由内核的异常处理程序处理的,它对用户进程来说是透明的。而信号机制,提供了一种方法通知用户进程发生了这些异常。

例如,一个进程试图除0,会引发内核向他发送SIGFPE信号;执行非法指令会引发SIGILL信号;非法内存访问引发SIGSEGV;当你从键盘上键入Ctrl + C会引发SIGINT;当某个子进程结束会引发内核向其父进程发送SIGCHLD信号,等等。具体请看下图:

在这里插入图片描述

1. 信号术语与原则

1.1信号发送

当内核检测到某种系统事件(除零错误或子进程终止等等)或一个进程调用了kill函数显式的要求内核发送一个信号给目的进程时,内核会通过更新目的进程上下文中的某个状态而达到向它发送一个信号的目的。发送信号的方式为:

  • **命令行:**用kill -signum PID命令,向进程号为PID的进程发送signum信号;

  • **键盘:**通过键盘发送特定信号,Ctrl + C 向前台进程组中的每个进程发送SIGINT终止信号;Ctrl + Z 向前台进程组中的每个进程发送SIGTSTP暂停信号;

  • 函数alarm: 使内核在一段时间(secs秒)后,向自己发送SIGALRM信号;

  #include <unistd.h>
  
  unsigned int alarm(unsigned int secs);
  //返回:待处理的闹钟在被发送前还剩余的秒数,若之前没有待处理的闹钟,则返回0
  //若secs = 0,不会调度安排新的闹钟。
  • **函数kill:**进程通过调用kill函数发送信号给其它进程(包括自己)。
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
//成功返回0,失败返回-1。
  • pid > 0 :发送信号sig给进程pid;
  • pid = 0 :发送信号给自己所在进程组中的每个进程,包括自己。
  • pid < 0 :发送信号sig给进程-pid。

1.2 信号处理

当进程从系统调用返回或是完成了一次上下文切换而重新取得控制权之前,内核会检查该进程的待处理信号集(pengding&(~blocked)),如果为空则完成控制权的交接,如果不为空则会让进程响应该信号集合中信号值最小的那个信号。

目的进程收到信号后有“忽略信号”、“终止进程”和“捕获信号“这3种方式来响应。其中

  • SIGKILL(终止)和SIGSTOP(暂停)这2个信号不可被忽略,也不能像其它信号一样可以通过signal函数改变他们的默认处理函数;

    #include <signal.h>
    typedef void (*sighandler_t)(int);
    
    sighandler_t signal(int signum, sighandler_t handler);

    如果handler = SIG_IGN,那么就忽略类型为signum的信号;

    如果handler = SIG_DFL,那么就恢复类型为signum的信号的默认行为;

    否则,handler就是用户自定义的信号处理函数地址。

  • 进程可以有选择的忽略某些信号(通过将blocked位向量中相应的位置1),即该信号虽然被内核或进程发送了过来,但我可以选择视而不见。

    #include <signal.h>
    
    int sigprocmask(int HOW, const sigset_t *set, sigset_t *oldset);
    
    int sigemptyset(sigset_t *set);		//初始化set集为空(set = 0);
    int sigfillset(sigset_t *set);		//将所有信号都添加进set集(set = 1);
    int sigaddset(sigset_t *set, int signum);
    int sigdelset(sigset_t *set, int signum);																			//以上5个函数成功返回0,错误返回-1
    int sigismember(const sigset_t *set, int signum);
    										//是成员返回1,不是返回0,错误返回-1

    关于sigprocmask函数中的"HOW"有以下几种可能的取值:

    • SIG_BLOCK:把set集中的信号加到进程的blocked中(blocked |= set);
    • SIG_UNBLOCK:从进程的blocked中删除set集中的信号(blocked &= ~set);
    • SIG_SETMASK:忽略set集中的信号(blocked = set);

    oldset : 如果他是非空的,则将进程原先blocked的值保存在其中。

    一下示例展示了临时忽略SIGINT信号的程序片段:

    sigset_t mask, oldmask;
    
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    
    sigprocmask(SIG_BLOCK, &mask, &oldmask);
    .
    .	//此处的所有语句将不会响应SIGINT信号
    .
    sigprocmask(SIG_SETMASK, &oldmask, NULL);
    	//之后的语句将会正常响应SIGINT信号
  • 任何信号只能被记录阻塞一次;即如果进程正在执行某类型信号的处理函数,那么在此进程返回主程序前,不管又收到了多少个该类型的信号,它只会被记录一次(即等到该进程从上次处理函数返回后,它只会再响应一次该类型的信号)。因为内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量中维护着被阻塞的信号集。每次,收到一个信号,就在blocked相应的位置1,响应一个信号,就在pengding中相应的清零。

2. 安全的信号处理函数

由于信号处理函数和主程序是并发运行的,他们享有相同的全局变量,他们的运行顺序是不可预测的,这就导致何时接收到信号的规则往往有违人们的直觉,或者说主程序和子程序间不一定会按照你预想的顺序去执行。所以为了防止竞争冒险,在编写信号处理函数时有几个保守的原则需要遵守:

  • 处理程序尽可能简单
  • 在处理程序中仅使用异步安全的函数,也就是说该函数是可重入的(只访问局部变量)且不能中断;下图列出了所有Linux保证安全的系统函数,可以发现许多常见的库函数(printf、sprintf、malloc、exit等)都不是安全函数,在编写信号处理函数时要尽量避免使用。

在这里插入图片描述

为了在信号处理程序中能够打印一些简单的消息,我们可以使用一些异步信号安全的系统函数来构建自己的特有包装函数。作为例子,下面的程序展示了利用异步信号安全的系统函数write编写自己的SIO(safe I/O)函数。

ssize_t sio_puts(char s[])
{
	int count = 0;
	char *str = s;
	if(!str)
		_exit(1);
	while(*str++)
		count++;		
	return write(STDOUT, s, count);
}
  • 保存和恢复error;为了避免处理程序中某些语句的出错导致error被设置,进而影响主程序中的判断,在信号处理程序的第一条语句保存原error,在它返回前恢复error。

  • 不管是主程序还是子程序,在访问全局变量时,都要阻塞所有的信号,以防相互干扰

  • 用volatile声明全局变量。 volatile要求编译器每次都是从内存中读取全局变量的值,而非从缓存中。

  • 使用sig_atomic_t声明标志。 此处的标志代表在主程序和子程序间传递信号的全局变量,因为sig_atomic_t要求编译器对它的操作是原子的,所以即使没有阻塞所有信号,它也不会被任何信号打断。

  • 使用sigaction函数重新包装signal函数,使得系统自动重启被中断的系统调用。 由于一些系统函数(例如read、write、accept等)需要执行较长时间,所以可能会被信号中断。而在许多较早以前版本的Unix系统中,被中断的系统调用并不会在信号处理返回后重启,而是直接返回错误并将error设置为EINTR。而sigaction函数可以设置信号处理时的语义。

    以下代码用sigaction函数编写了signal函数的包装函数[Signal][1],并且具有如下语义:

    • 只有当前处理的该类型信号被阻塞;
    • 其它信号也不会排队等待;
    • 只要可能,被中断的系统调用会自动重启;
    • 一旦为某信号设置了信号处理程序,它会一直保持到Signal重新为该信号设置SIG_IGN或SIG_DFL的信号处理程序。
    handler_t *Signal(int signum, handler_t *handler)
    {
    	struct sigaction action,oldaction;
    	
    	action.sa_handler = handler;
    	sigemptyset(&action.sa_mask);
    	action.sa_flags = SA_RESTART;
    	
    	if(sigaction(signum, &action, &oldaction) < 0)
    		unix_error("Signal error");
    	return(oldaction.sa_handler)
    }
    

3.信号的同步

当需要编写读写相同内存位置的并发进程,我们不得不考虑进程间的(既包括进程与进程之间,也包括主进程与子进程之间)竞争关系。这是一个很大的命题,在此限于文章主题,只讨论信号之间的竞争关系如何处理。主要分两个方面,一是隐式竞争,二是显式竞争。

3.1 避免隐式竞争

考虑一个类似shell的函数功能,父进程在一个全局作业列表中记录着它的当前子进程,每个作业一个条目。addjob和deletejob函数分别向这个作业列表中添加和删除作业。父进程每创建一个子进程就把它添加在作业列表中,每当在SIGCHLD信号处理程序中回收一个僵死的子进程时,就在job列表中删除这个子进程。

void handler(int sig)
{
	int olderrno = errno;		//保存进程的原error值
	sigset_t mask_all,prev_all;
	pid_t pid;
	
	sigfillset(&mask_all);		//将所有信号添加到信号集mask_all中
	while((pid = waitpid(-1, NULL, 0)) > 0){	//回收僵死子进程
		sigprocmask(SIG_BLOCK, &mask_all, prev_all);	//阻塞(屏蔽)所有信号
		deletejob(pid);			//从job列表中删除僵死的子进程条目
		sigprocmask(SIG_SETMASK, &prev_all, NULL);
	}
	if(errno != ECHILD)			//如果父进程的所有子进程都已经回收,则内核发送ECHILD错误
		Unix_error("waitpid error");
	errno = olderrno;			//恢复进程的原error值
}


int main(int argc, char **argv)
{
	int pid;
	sigset_t mask_all,mask_one,prev_one;
	
	sigfillset(mask_all);
	sigemptyset(mask_one);
	sigaddset(&mask_one, SIGCHLD);	
	Signal(SIGCHLD, handler);		//使用安全的Signal函数设置处理函数
	initjobs();						//初始化工作列表
	
	while(1){
        /*在产生子进程前屏蔽SIGCHLD,以防止主进程还没执行到addjob就已经收到了因子进程终止
        而发来的SIGCHLD信号,进而进入handler导致在jobs中找不到要删除的子进程条目*/
		sigprocmask(SIG_BLOCK, &mask_one, &prev_one);	//频闭SIGCHLD信号
		if((pid = fork()) == 0){
			sigprocmask(SIG_SETMASK, &prev_one, NULL)//子进程解除频闭SIGCHLD
			execve("/bin/date", argv, NULL);
		}
		sigprocmask(SIG_BLOCK, &mask_all, NULL)//父进程屏蔽所有信号
		addjob(pid);	
		sigprocmask(SIG_SETMASK, &prev_one, NULL)//父进程解除屏蔽
	}
	exit(0);
}

3.2 避免显式竞争

有时候主程序需要显式地等待某个信号处理运行。例如shell程序,它必须等待当前的前台进程结束,被SIGCHLD处理程序回收之后,才能继续创建另一个进程。主进程在等待的这段时间应该干些什么才最好呢?我们可以用一个无限循环语句,让主进程就在那执行。但这样也太浪费CPU的资源了;我们也可以用一个sleep或者nanosleep函数让主进程休眠,但到底休眠多长时间不好把握,间隔太小同样会造成多次循环,间隔太大,程序又会太慢。

合适的解决办法是,引入sigsuspend函数:

#include <signal.h>

int sigsuspend(const sigset_t *mask);			//返回-1

它暂时挂起调用它的进程,利用参数mask替换当前的信号阻塞集,直到收到一个信号并进入处理程序(如果是终止信号,就直接返回),处理完之后返回主进程,并恢复原来的阻塞集。

下面例子展示了主进程在创建完子进程后,如何利用该函数显式的等待SIGCHLD的到来,以达到同步的效果。

#include <signal.h>

volatile sig_atomic_t pid;

void sigchld_handler(int signum)
{
    int olderror = errno;
    pid = waitpid(-1, NULL, 0);
    int errno = olderrno;    
}

void sigint_handler(int signum)
{
     
}

int main(int argc, char **argv)
{
    sigset_t mask,prev;
    
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    
    while(1){
        sigprocmask(SIG_BLOCK, &mask, &prev);	//屏蔽SIGCHLD信号
        if(fork() == 0)	//子进程
            exit(0);
        
        pid = 0;
        while(!pid){	
            sigsuspend(&prev);	//挂起并等待SIGCHLD信号的到来,其处理函数会使得pid大于0
        }
        
        sigprocmask(SIG_SETMASK, &prev, NULL);
        
        printf("...");
    }
    exit(0);
}

[1]: 引用:Unix Network Programming: The Sockets Networking API,第三版,第一卷


获取更多知识,请点击关注:
嵌入式Linux&ARM
CSDN博客
简书博客
知乎专栏


已标记关键词 清除标记
表情包
插入表情
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符 “速评一下”
©️2020 CSDN 皮肤主题: 黑客帝国 设计师:上身试试 返回首页