天天看點

Linux中的信号機制

信号就是一條消息,通知程序系統中發生了什麼事,每種信号都對應着某種系統事件。一般的底層硬體異常是由核心的異常處理程式處理的,它對使用者程序來說是透明的。而信号機制,提供了一種方法通知使用者程序發生了這些異常。

Linux中的信号機制

例如,一個程序試圖除0,會引發核心向他發送SIGFPE信号;執行非法指令會引發SIGILL信号;非法記憶體通路引發SIGSEGV;當你從鍵盤上鍵入Ctrl + C會引發SIGINT;當某個子程序結束會引發核心向其父程序發送SIGCHLD信号,等等。具體請看下圖:

Linux中的信号機制

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等)都不是安全函數,在編寫信号處理函數時要盡量避免使用。
Linux中的信号機制

為了在信号處理程式中能夠列印一些簡單的消息,我們可以使用一些異步信号安全的系統函數來建構自己的特有包裝函數。作為例子,下面的程式展示了利用異步信号安全的系統函數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​​​​​​

繼續閱讀