Linux: 信號【阻塞和捕捉信號】
- (一)阻塞信號
- 1.信號其他相關的概念
- 2.在內核中表示
- 3.sigset_t
- 4.信號集操作函數
- 5.sigprocmask(設置阻塞)
- 6.sigpending(得到未決狀態)
- (二)捕捉信號
- 1、內核空間與用戶空間
- 2、內核態與用戶態
- 2.1 內核如何實現信號的捕捉
- 3.捕捉信號
- signal函數
- sigaction函數
- 3.可重入函數
- 4.volatile關鍵字
- 5.SIGCHLD信號
(一)阻塞信號
1.信號其他相關的概念
- 實際執行信號的處理動作,稱為信號遞達(Delivery)。
- 信號從產生到遞達之間的狀態,稱為信號未決(pending)。
- 進程可以選擇阻塞(Block) 某個信號。
- 被阻塞的信號產生時將保持在未決狀態,直到進程解除對此信號的阻塞,才執行遞達的動作。
- 需要注意的是,阻塞和忽略是不同的,只要信號被阻塞就不會遞達,而忽略是在遞達之后的一種處理動作
信號 傳遞過程:信號產生 -> 信號未決 -> 信號遞達
2.在內核中表示
- 每個信號都有兩個標志位分別表示阻塞(block)和未決(pending),還有一個函數指針表示處理動作。信號產生時,內核在進程控制塊中設置該信號的未決標志,直到信號遞達才清除該標志。在上圖中,SIGHUP信號未阻塞也未產生過,當它遞達時執行默認處理動作。
- SIGINT信號產生過,但正在被阻塞,所以暫時不能遞達。雖然它的處理動作是忽略,但在沒有解除阻塞之前不能忽略這個信號,因為進程仍有機會在改變處理動作之后再接觸阻塞。
- SIGQUIT信號未產生過,但一旦產生SIGQUIT信號,該信號將被阻塞,它的處理動作是用戶自定義函數sighandler。如果在進程解除對某信號的阻塞之前,這種信號產生過多次,POSIX.1允許系統遞達該信號一次或多次。Linux是這樣實現的:普通信號在遞達之前產生多次只計一次,而實時信號在遞達之前產生多次可以依次放在一個隊列里,這里只討論普通信號。
block和pending都是用位圖表示,而handler是一個函數指針數組
- 在block位圖中,比特位的位置代表某一個信號,比特位的內容代表該信號是否被阻塞。
- 在pending位圖中,比特位的位置代表某一個信號,比特位的內容代表是否收到該信號。
- handler表本質上是一個函數指針數組,數組的下標代表某一個信號,數組的內容代表該信號遞達時的處理動作,處理動作包括默認、忽略以及自定義
- block、pending和handler這三張表的每一個位置是一一對應的。
3.sigset_t
從上圖來看,每個信號只有一個bit的未決標志,非0即1,不記錄該信號產生了多少次,阻塞標志也是這樣表示的。
因此,未決和阻塞標志可以用相同的數據類型sigset_t來存儲,sigset_t稱為信號集,這個類型可以表示每個信號的“有效”或“無效”狀態,在阻塞信號集中“有效”和“無效”的含義是該信號是否被阻塞,而在未決信號集中“有效”和“無效”的含義是該信號是否處于未決狀態。
阻塞信號集也叫做當前進程的信號屏蔽字(Signal Mask),這里的“屏蔽”應該理解為阻塞而不是忽略。
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;typedef __sigset_t sigset_t;
_SIGSET_NWORDS 大小為 32,所以這是一個可以包含 32 個 無符號長整型 的數組,而每個 無符號長整型 大小為 4 字節,即 32 比特,至多可以使用 1024 個比特位
4.信號集操作函數
sigset_t類型對于每種信號用一個bit表示“有效”或“無效”,至于這個類型內部如何存儲這些bit則依賴于系統的實現,從使用者的角度是不必關心的,使用者只能調用以下函數來操作sigset_t變量,而不應該對它的內部數據做任何解釋。
#include <signal.h>int sigemptyset(sigset_t *set);int sigfillset(sigset_t *set);int sigaddset(sigset_t *set, int signum);int sigdelset(sigset_t *set, int signum);int sigismember(const sigset_t *set, int signum);
- sigemptyset函數:初始化set所指向的信號集,使其中所有信號的對應bit清零,表示該信號集不包含任何有效信號。
- sigfillset函數:初始化set所指向的信號集,使其中所有信號的對應bit置位,表示該信號集的有效信號包括系統支持的所有信號。
- sigaddset函數:在set所指向的信號集中添加某種有效信號。
- sigdelset函數:在set所指向的信號集中刪除某種有效信號。
- sigemptyset、sigfillset、sigaddset和sigdelset函數都是成功返回0,出錯返回-1。
- sigismember是一個布爾函數,用于判斷一個信號集的有效信號中是否包含
某種 信號,若包含則返回1,不包含則返回0,出錯返回-1。
5.sigprocmask(設置阻塞)
sigprocmask函數可以用于讀取或更改進程的信號屏蔽字(阻塞信號集),該函數的函數原型如下:
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
函數說明:
- 參數how:
- 參數set:
就是一個信號集,主要從此信號集中獲取屏蔽信號信息 - 參數oldset:
一個信號集,保存進程中原來的 block 表
返回值:
- sigprocmask函數調用成功返回0,出錯返回-1。
- 如果調用sigprocmask解除了對當前若干個未決信號的阻塞,則在sigprocmask函數返回前,至少將其中一個信號遞達。
6.sigpending(得到未決狀態)
sigpending函數可以用于讀取進程的未決信號集,該函數的函數原型如下:
sigpending函數讀取當前進程的未決信號集,并通過set參數傳出。該函數調用成功返回0,出錯返回-1。
(二)捕捉信號
1、內核空間與用戶空間
每一個進程都有自己的進程地址空間,該進程地址空間由內核空間和用戶空間組成:
- 用戶所寫的代碼和數據位于用戶空間,通過用戶級頁表與物理內存之間建立映射關系。
- 內核空間存儲的實際上是操作系統代碼和數據,通過內核級頁表與物理內存之間建立映射關系。
內核級頁表是一個全局的頁表,它用來維護操作系統的代碼與進程之間的關系。因此,在每個進程的進程地址空間中,用戶空間是屬于當前進程的,每個進程看到的代碼和數據是完全不同的,但內核空間所存放的都是操作系統的代碼和數據,所有進程看到的都是一樣的內容。
內核級頁表只有一個并且是全部進程共享。而用戶級頁表有多個,每個進程都有一個獨立的用戶級頁表。
2、內核態與用戶態
- 內核態通常用來執行操作系統的代碼,是一種權限非常高的狀態。
- 用戶態是一種用來執行普通用戶代碼的狀態,是一種受監管的普通狀態。
為什么要區分 用戶態 與 內核態 ?
- 內核空間中存儲的可是操作系統的代碼和數據,權限非常高,絕不允許隨便一個進程對其造成影響
- 區域的合理劃分也是為了更好的進行管理
2.1 內核如何實現信號的捕捉
-
用戶態切換到內核態:
- 需要進行系統調用時。
- 當前進程的時間片到了,導致進程切換。
- 產生異常、中斷、陷阱等。
-
內核態切換到用戶態:
- 系統調用返回時。
- 進程切換完畢。
- 異常、中斷、陷阱等處理完畢。
由用戶態切換為內核態我們稱之為陷入內核。每當我們需要陷入內核的時,本質上是因為我們需要執行操作系統的代碼,比如系統調用函數是由操作系統實現的,我們要進行系統調用就必須先由用戶態切換為內核態。
如果信號的處理動作是用戶自定義函數,在信號遞達時就調用這個函數,這稱為捕捉信號。由于信號處理函數的代碼是在用戶空間的,處理過程比較復雜。
舉例如下:
- 用戶程序注冊了SIGQUIT信號的處理函數sighandler。
- 當前正在執行main函數,這時發生中斷或異常切換到內核態。
- 在中斷處理完畢后要返回用戶態的main函數之前檢查到有信號SIGQUIT遞達。
- 內核決定返回用戶態后不是恢復main函數的上下文繼續執行,而是執行sighandler函數,sighandler和main函數使用不同的堆棧空間,它們之間不存在調用和被調用的關系,是 兩個獨立的控制流程。
- sighandler函數返回后自動執行特殊的系統調用sigreturn再次進入內核態。 如果沒有新的信號要遞達,這次再返回用戶態就是恢復main函數的上下文繼續執行了。
當識別到信號的處理動作是自定義時,能直接在內核態執行用戶空間的代碼嗎?
理論上是可以實現的,但是是絕對不允許的。
很好理解,倘若可以在內核態執行用戶空間的代碼,那么會不會有非法操作?假如在內核態有一個非法操作是將其數據庫都刪掉了,那么這可恢復不了了,因為內核態的權限是足夠大的。雖然在用戶態時沒有足夠的權限做到清空數據庫,但是如果是在內核態時執行了這種非法代碼,那么數據庫就真的被清空了,因為內核態是有足夠權限清空數據庫的。
也就是說,不能讓操作系統直接去執行用戶的代碼,因為操作系統無法保證用戶的代碼是合法代碼,即操作系統不信任任何用戶。
3.捕捉信號
signal函數
signal 函數可以用來 修改信號的執行動作,也叫注冊自定義執行動作。
函數理解:
- 參數 1:
待操作信號的編號,為 int,單純地傳遞 信號名也是可以的,因為信號名其實就是信號編號的宏定義 - 參數 2:待注冊的新方法。
參數2 是一個函數指針,意味著需要傳遞一個 參數為 int,返回值為空的函數對象
我們需要手動創造一個函數對象,就像下面這樣:
void handler(int signo)
{cout << "當前 " << signo << " 號信號正在嘗試執行相應的動作" << endl;
}
sigaction函數
捕捉信號除了用上面的signal函數之外,我們還可以使用sigaction函數對信號進行捕捉,sigaction函數的函數原型如下:
#include <signal.h>int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);struct sigaction
{void (*sa_handler)(int); //自定義動作void (*sa_sigaction)(int, siginfo_t *, void *); //實時信號相關,不用管sigset_t sa_mask; //待屏蔽的信號集int sa_flags; //一些選項,一般設為 0void (*sa_restorer)(void); //實時信號相關,不用管
};
函數說明:
- 參數1:待操作的信號
- 參數2:sigaction 結構體,具體成員如上所示
- 參數3:保存修改前進程的 sigaction 結構體信息
- 返回值:成功返回 0,失敗返回 -1 并將錯誤碼設置
再了解sigaction
結構體中的 sa_mask
字段。
sa_mask
:當信號在執行 用戶自定義動作 時,可以將部分信號進行屏蔽,直到 用戶自定義動作 執行完成。
也就是說,我們可以提前設置一批 待阻塞 的 屏蔽信號集,當執行 signum 中的 用戶自定義動作 時,這些 屏蔽信號集 中的 信號 將會被 屏蔽(避免干擾 用戶自定義動作 的執行),直到 用戶自定義動作 執行完成。
使用示范:
#include<iostream>
#include <string.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
struct sigaction act, oldact;
void handler(int signo)
{cout << "get a signo#" << signo << endl;sigaction(2, &oldact, NULL);
}
int main()
{memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaction(2, &act, &oldact);while(1){cout << "I am a process..." << endl;sleep(1);}return 0;
}
3.可重入函數
可以被重復進入的函數稱為 可重入函數
main函數調用insert函數向一個鏈表head中插入節點node1,插入操作分為兩步,剛做完第一步的 時候,因為硬件中斷使進程切換到內核,再次回用戶態之前檢查到有信號待處理,于是切換 到sighandler函
數,sighandler也調用insert函數向同一個鏈表head中插入節點node2,插入操作的 兩步都做完之后從sighandler返回內核態,再次回到用戶態就從main函數調用的insert函數中繼續 往下執行,先前做第一步之后被打斷,現在繼續做完第二步。結果是,main函數和sighandler先后 向鏈表中插入兩個節點,而最后只有一個節點真正插入鏈表中了。
像上例這樣,insert函數被不同的控制流程調用,有可能在第一次調用還沒返回時就再次進入該函數,這稱為重入,insert函數訪問一個全局鏈表,有可能因為重入而造成錯亂,像這樣的函數稱為 不可重入函數,反之,如果一個函數只訪問自己的局部變量或參數,則稱為可重入(Reentrant) 函數。
如果一個函數符合以下條件之一則是不可重入的
- 調用了malloc或free,因為malloc也是用全局鏈表來管理堆的。
- 調用了標準I/O庫函數。標準I/O庫的很多實現都以不可重入的方式使用全局數據結構。
4.volatile關鍵字
volatile 作用:保持內存的可見性,告知編譯器,被該關鍵字修飾的變量,不允許被優化,對該變量的任何操作,都必須在真實的內存中進行操作
我們看到下面代碼:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
int flag = 0;
void handler(int signo)
{cout << "get a signal is#" << signo << endl;flag = 1;
}
int main()
{signal(2, handler);cout << "process beginning" << endl;while(!flag){// 什么也不做}cout << "process exit..." << endl;return 0;
}
我們將2號信號進行捕捉,當進程收到2號信號后會將全局變量flag由0置1。也就是說,在進程收到2號信號之前,該進程會一直處于死循環狀態,直到收到2號信號時將flag置1才能夠正常退出。
上面的觀點不一定是正確的!!
當我們修改編譯器的優先級時,將優化級別設為更高是一樣的結果,如果設為 O0 則會符合預期般的運行,說明我們當前的編譯器默認的優化級別是 O0。編譯器就會對上述代碼做出優化!
代碼中的main函數和handler函數是兩個獨立的執行流,而while循環是在main函數當中的,在編譯器編譯時只能檢測到在main函數中對flag變量的使用。此時編譯器檢測到在main函數中并沒有對flag變量做修改操作,在編譯器優化級別較高的時候,就有可能將flag的值設置進寄存器里面。此時main函數在檢測flag時只檢測寄存器里面的值,而handler執行流只是將內存中flag的值置為1了,那么此時就算進程收到2號信號也不會跳出死循環,因為編譯器直接從寄存器上拿到值了。
5.SIGCHLD信號
為了避免出現僵尸進程,父進程需要使用wait或waitpid函數等待子進程結束,父進程可以阻塞等待子進程結束,也可以非阻塞地查詢的是否有子進程結束等待清理,即輪詢的方式。采用第一種方式,父進程阻塞就不能處理自己的工作了;采用第二種方式,父進程在處理自己的工作的同時還要記得時不時地輪詢一下,程序實現復雜。
其實,子進程在終止時會給父進程發SIGCHLD信號,該信號的默認處理動作是忽略,父進程可以自 定義SIGCHLD(17)信號的處理函數,這樣父進程只需專心處理自己的工作,不必關心子進程了,子進程 終止時會通知父進程,父進程在信號處理函數中調用wait清理子進程即可
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{cout << "get a signal#" << signo << endl;int ret = 0;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){cout << "wait child" << ret << "sucess" << endl;}
}
int main()
{signal(SIGCHLD, handler);pid_t id = fork();if (id == 0){//childcout << "child is running, begin dead#" << getpid() << endl;sleep(3);exit(1);}//fatherwhile (1);return 0;
}
SIGCHLD屬于普通信號,記錄該信號的pending位只有一個,如果在同一時刻有多個子進程同時退出,那么在handler函數當中實際上只清理了一個子進程,因此在使用waitpid函數清理子進程時需要使用while不斷進行清理。
-
信號處理期間的附加規則
在信號處理函數執行期間,內核會自動將該信號加入信號屏蔽字(臨時阻塞)。此時:- 若該信號再次觸發,會被標記為未決狀態( s i g p e n d i n g sigpending sigpending位置1),但需等待當前處理函數結束后才會遞送。
- 對于非實時信號(如SIGINT),多次觸發僅保留一次未決記錄;**實時信號(如SIGRTMIN+1)**則支持排隊,每次觸發均獨立記錄。
-
關鍵流程圖釋
信號觸發 → 檢查是否被阻塞 → 是 → 加入sigpending表(置1)↓ 否 立即遞送處理
事實上,由于UNIX的歷史原因,要想不產生僵尸進程還有另外一種辦法:父進程調用signal或sigaction函數將SIGCHLD信號的處理動作設置為SIG_IGN,這樣fork出來的子進程在終止時會自動清理掉,不會產生僵尸進程,也不會通知父進程。系統默認的忽略動作和用戶用signal或sigaction函數自定義的忽略通常是沒有區別的,但這是一個特列。此方法對于Linux可用,但不保證在其他UNIX系統上都可用。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
int main()
{signal(SIGCHLD, SIG_IGN);pid_t id = fork();if (id == 0){//childcout << "child is running, child dead:" << getpid() << endl;sleep(3);exit(1);}//fatherwhile (1);return 0;
}
原理:在設置 SIGCHLD 信號的處理動作為忽略后,父進程的 PCB 中有關僵尸進程處理的標記位會被修改,子進程繼承父進程的特性,子進程在退出時,操作系統檢測到此標記位發生了改變,會直接把該子進程進行釋放