一、信號的概念
?信號是一種向目標進程發送通知消息的機制
信號的特性(可以結合紅綠燈、防空警報等生活樣例來理解)
1、在信號沒有出現之前,我們就已經知道如何去處理信號,即我們認識信號
2、信號是異步產生的,即我們不知道它具體何時產生
3、當信號產生時,我們可以對它暫時忽略不做處理(比如我們外賣到了,但是你正在和朋友開黑,就會將外賣暫時擱置)
4、由于我們有時不會立即去執行信號,所以我們需要能儲存信號
信號列表如下
一些補充的知識
二、信號的產生
?1、通過鍵盤進行信號的產生
解釋如下
該系統調用接口可以自定義捕捉信號的行為,將signum信號的默認行為改為handler
//process.cc #include <iostream> #include <unistd.h> #include <signal.h> #include <stdlib.h>void handler(int signo) {std::cout<<"發送了一個2號信號"<<std::endl;exit(1); }int main() {signal(2,handler);std::cout << "pid: " << getpid() << std::endl;while(1){std::cout << "running" << std::endl;sleep(1);}return 0; }
![]()
對信號的進一步理解
?man 7 singal? 查看信號的默認行為
既然我們能通過signal系統調用將信號的默認方法改變,那么我們能否將所有能殺死進程的信號改掉,使得進程無法被終止呢???
?很顯然是不行的,有些信號是無法被自定義捕捉,比如9號信號,保證了OS的安全
2、通過系統調用進行信號的產生
功能:向pid進程發送sig信號(kill命令就是調用的該系統調用)
舉個例子(寫一個自己的kill命令)
//test.c #include <stdio.h> #include <unistd.h> int main() {printf("%d\n",getpid());while(1){printf("running\n");sleep(1);}return 0; }//mykill.cc #include <iostream> #include <unistd.h> #include <signal.h> #include <stdlib.h> #include <sys/types.h>int main(int argc,char* argv[]) {if(argc!=3){std::cout<<"\nUsage:" << argv[0] <<" -signnumber processid" << std::endl;return 0;}int signnumber = std::stoi(argv[1]+1);int pid = std::stoi(argv[2]);kill(pid, signnumber);return 0; }
功能:向本進程發送sig信號
舉個例子
#include <iostream> #include <unistd.h> #include <signal.h> #include <stdlib.h> #include <sys/types.h>void handler(int signo) {std::cout<<"發送了一個" << signo <<"號信號"<<std::endl; }int main() {signal(2,handler);while(1){raise(2);sleep(1);}return 0; }
功能:向本進程發送6號信號,且進程一定會終止
舉個例子
#include <iostream> #include <unistd.h> #include <signal.h> #include <stdlib.h> #include <sys/types.h>void handler(int signo) {std::cout<<"發送了一個" << signo <<"號信號"<<std::endl; }int main() {std::cout << getpid() << std::endl;signal(6,handler);// abort();while(1){std::cout << "running" << std::endl;sleep(1);}return 0; }
3、通過硬件異常進行信號的產生
原理如下
那么除0異常是發送的哪個信號呢?是8號信號,驗證如下
根據上圖:發送的確實是8號信號,但現在還有一個問題,進程出異常,OS發送8號信號,可以理解,但是為什么它一直在打印呢,明明我的代碼沒有循環啊?
因為,原本的8號信號被自定義捕捉成了打印語句,導致進程無法退出,所以進程依舊會在等待隊列中等待CPU調度,所以各種寄存器中存放的該進程相關的數據(即進程上下文)都會被保留,包括狀態寄存器,而一旦該進程被調度,那么OS就又會檢測到硬件錯誤,向進程發送8信號,如此反復,故有上面的現象發生。
(一旦進程退出,它的相關數據就會被丟棄,因為我們不在需要調度該進程了)
OS殺死進程,就是處理問題的方式之一
程序運行出現異常,如何做取決于用戶,但一般都是要讓進程退出的(注意:異常的處理,很多時候只是打印錯誤)
順便說一下,*nullptr發送的信號是11號信號,本質是頁表中沒有該地址的物理地址的映射關系,引發的硬件錯誤
4、通過軟件條件進行信號的產生
這個其實在之前的博客中就講過一些示例,比如管道中只要讀端關閉,寫端還在寫,OS就會向寫端發送SIGPIPE,終止寫端,它本質是因為OS的內核數據中發現該管道只被一個進程打開,所以發信號終止寫端,不是硬件異常,而是軟件條件產生的信號。又比如調試程序用的gdb向進程發送的SIGSTOP和SIGCONT都是軟件條件產生的信號。
這里再介紹一個alarm函數
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
調用alarm函數可以設定一個鬧鐘,也就是告訴內核在seconds秒之后給當前進程發送SIGALRM信號(14號信號),該信號的默認處理動作是終止當前進程
?演示如下
上面兩段代碼都是設置了1秒的鬧鐘,確實時間到了,進程就終止了,但是兩段代碼cnt++的運行次數卻是天差地別,唯一的區別就是有沒有與外設進行IO交互,也證明了IO的效率很低
?
多次使用alarm函數,就是刷新鬧鐘,并返回之前鬧鐘剩余的時間,注意:alarm(0)不是設置0秒的鬧鐘,這個相當于清空之前的鬧鐘并返回之前鬧鐘剩余的時間
了解(擴展)---幫助理解alarm
這里講講alarm是如何實現的,首先每個進程都能設置鬧鐘,也就是說OS中可以存在多個鬧鐘,所以需要管理,即先描述在組織,所以我們要設計一個鬧鐘結構體,里面包含鬧鐘所屬進程id,時間(用時間戳記錄)等等(根據需求往里面加屬性)。然后就是如何組織,即選擇什么樣的數據結構進行管理,這里可以選擇用小堆,按照時間大小存放,如果時間堆頂鬧鐘的時間沒到,說明所有的都沒到,不做處理,如果時間到了,就拿出來處理。(當然這不一定是Linux中alarm的實現,這里只是提供思想,具體如何實現得根據需求)
core dump(核心轉儲)?
介紹:
分析core dump是Linux應用程序調試的一種有效方式,core dump又稱為“核心轉儲”,是該進程實際使用的物理內存的“快照”。分析core dump文件可以獲取應用程序崩潰時的現場信息,如程序運行時的CPU寄存器值、堆棧指針、棧數據、函數調用棧等信息。
Core dump是Linux基于信號實現的。Linux中信號是一種異步事件處理機制,每種信號都對應有默認的異常處理操作,默認操作包括忽略該信號(Ignore)、暫停進程(Stop)、終止進程(Terminate)、終止并產生core dump(Core)等
這里也簡單說一下,為什么有的信號終止進程需要核心轉儲,而有的不需要?
我們可以看一下那些不需要核心轉儲的終止信號,如SIGKILL,它其實并不是進程出現異常而將進程殺死,更類似于用戶強制終止進程,也就是說進程本身并沒有問題(或者出錯原因很明顯),所以我們不需要core dump再去分析異常,但是像SIGSEGV信號,即段錯誤信號,我們只能知道是內存出現問題,但是具體是數組越界還是其他什么問題引發的我們并不清楚,所以我們需要core dump幫助我們去分析。
但其實通過上面我們的示例代碼和結果截圖,我們會發現,Term/Core的功能好像都一樣,只是終止進程,并沒有產生core dump文件,這是什么原因呢?
因為核心轉儲的文件太大了,我們用的是服務器,默認將core dump大小設置為0,即不生成核心轉儲,防止服務器被寫滿(虛擬機應該是開啟的)當然可以通過指令打開,如下
注意:核心轉儲只能在對應的shell中生成,即哪個shell設置了core dump的大小,哪個shell跑程序收到異常才會生核心轉儲文件
上面的短短5行代碼,就需要生成55萬字節的文件,如果代碼在多一點,文件只會更大,所以為了保證服務的安全,系統默認將core dump文件大小設置為0
那么核心轉儲的文件有什么用呢?
三、信號存儲
信號的相關概念
- 實際執行信號的處理動作稱為信號遞達(Delivery)
- 信號從產生到遞達之間的狀態,稱為信號未決(Pending)---即在信號位圖中
- 進程可以選擇阻塞 (Block )某個信號---未決之后,暫時不遞達
被阻塞的信號產生時將保持在未決狀態,直到進程解除對此信號的阻塞,才執行遞達的動作
注意:阻塞和忽略是不同的,只要信號被阻塞就不會遞達,而忽略是在遞達之后可選的一種處理動作
如何在OS中體現上面說的三個概念---遞達、未決、阻塞??
- 每個信號都有兩個標志位分別表示阻塞(block)和未決(pending),還有一個函數指針表示處理動作。信號產生時,內核在進程控制塊中設置該信號的未決標志,直到信號遞達才清除該標志。在上圖的例子中,SIGHUP信號未阻塞也未產生過,當它遞達時執行默認處理動作。
- SIGINT信號產生過,但正在被阻塞,所以暫時不能遞達。雖然它的處理動作是忽略,但在沒有解除阻塞之前不能忽略這個信號,因為進程仍有機會改變處理動作之后再解除阻塞。
- SIGQUIT信號未產生過,一旦產生SIGQUIT信號將被阻塞,它的處理動作是用戶自定義函數sighandler。如果在進程解除對某信號的阻塞之前這種信號產生過多次,將如何處理?POSIX.1允許系統遞送該信號一次或多次。Linux是這樣實現的:常規信號在遞達之前產生多次只計一次,而實時信號在遞達之前產生多次可以依次放在一個隊列里。不討論實時信號
四、信號阻塞
sigset_t
未決和阻塞標志可以用相同的數據類型sigset_t來存儲,sigset_t稱為信號集,這個類型可以表示每個信號的“有效”或“無效”狀態,在阻塞信號集中“有效”和“無效”的含義是該信號是否被阻塞,而在未決信號集中“有效”和“無效”的含義是該信號是否處于未決狀態。阻塞信號集也叫做當前進程的信號屏蔽字(Signal Mask),這里的“屏蔽”應該理解為阻塞而不是忽略
信號集操作函數
int sigemptyset(sigset_t *set); 將所有信號的對應bit清零,表示該信號集不包含任何有效信號 int sigfillset(sigset_t *set); 將所有信號的對應bit置1,表示該信號集的有效信號包括系統支持的所有信號 int sigaddset (sigset_t *set, int signo); 在該信號集中添加某種有效信號 int sigdelset(sigset_t *set, int signo); 在該信號集中刪除某種有效信號 int sigismember(const sigset_t *set, int signo); 用于判斷一個信號集的有效信號中是否包含某種信號,若包含則返回1,不包含則返回0,出錯返回-1 注意:在使用sigset_ t類型的變量之前,一定要調用sigemptyset或sigfillset做初始化,使信號集處于確定的狀態
?sigprocmask
功能介紹:如果oset是非空指針,則讀取進程的當前信號屏蔽字通過oset參數傳出。如果set是非空指針,則更改進程的信號屏蔽字,參數how指示如何更改。假設當前的信號屏蔽字為mask,下表說明了how參數的可選值
SIG_BLOCK set包含我們希望添加到當前信號屏蔽字的信號,相當于mask = mask|set SIG_UNBLOCK set包含我們希望從當前信號屏蔽字中解除阻塞的信號,相當于mask = mask&~set SIG_SETMASK 設置當前信號屏蔽字為set所指向的值,相當于mask=set 如果調用sigprocmask解除了對當前若干個未決信號的阻塞,則在sigprocmask返回前,至少將其中一個信號遞達
演示如下
當然可能有人會說,既然能屏蔽信號,我們能不能將所有的信號全部屏蔽???
當然不行,跟有些信號無法被自定義捕捉是一個道理,如9號信號,這里就不驗證了
?sigpending
讀取當前進程的未決信號集,通過set參數傳出。調用成功則返回0,出錯則返回-1
演示從阻塞到遞達的過程,如下
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h>void handler(int signo) {std::cout << "接收到" << signo << "信號" << std::endl; }void Printf_Pending(const sigset_t &pending) {for (int i = 31; i >= 1; i--){if (sigismember(&pending, i))std::cout << 1;elsestd::cout << 0;}std::cout << "\n"; }int main() {std::cout << "pid: " << getpid() << std::endl;signal(2, handler);sigset_t set;sigemptyset(&set);sigaddset(&set, 2);sigprocmask(SIG_BLOCK, &set, nullptr); // 屏蔽2號信號std::cout << "屏蔽了2號信號" << std::endl;int cnt = 0;while (1){sigset_t pending;sigpending(&pending);Printf_Pending(pending);sleep(1);cnt++;if (cnt == 10){std::cout << "解除對2號信號的阻塞" << std::endl;sigprocmask(SIG_UNBLOCK, &set, nullptr);}}return 0; }
這里有一個問題:pending位圖的置0操作和信號的遞達,誰先發生???
我們可以在handler方法中打印pending位圖,如果已經為0,則置0操作先發生,反之,相反
顯然,我們是先將pending位圖的對應信號比特位置0,在執行信號的遞達操作
五、信號的捕捉
信號在什么時候被處理?
--- 進程從內核態返回用戶態的時候,進行信號的檢測和遞達
- 用戶態是一種受控的狀態,能夠訪問的資源是有限的
- 內核態是一種OS的共工作狀態,能訪問大部分系統資源
系統調用的背后,就包含了身份的變化!!!
補充一些進程地址空間的內容
內核空間對應的頁表和OS資源只需要一份,因為所有的進程都需要,就像動態庫資源我們只要加載一份就行,需要的就去映射。所以CPU在任何時間都能訪問OS
可能有人會覺得:如果我的程序中只是在死循環的打印語句,沒有進行系統調用,那么信號是不是就被處理了?
當然不是,進程是要被OS調度切換的,而當進程被放在CPU上執行時,本質就已經完成了從OS(內核)到用戶的轉換,所以信號有無數次機會被檢測處理
Sigaction
#include <signal.h>
int sigaction(int signo,const struct sigaction *act,struct sigaction *oact);
- 功能:可以讀取和修改與指定信號相關聯的處理動作。調用成功返回0,出錯返回-1
- 參數:signo是指定信號的編號。若act指針非空,則根據act修改該信號的處理動作。若oact指針非空,則通過oact傳出該信號原來的處理動作。act和oact指向sigaction結構體
(只關注被框出來的兩個成員)
將sa_handler賦值為常數SIG_IGN表示忽略信號,賦值為常數SIG_DFL表示執行系統默認動作,賦值為一個函數指針表示用自定義函數捕捉信號。和signal函數的第二個參數類似
當某個信號的處理函數被調用時,內核自動將當前信號加入進程的信號屏蔽字,當信號處理函數返回時自動恢復原來的信號屏蔽字,這樣就保證了在處理某個信號時,如果這種信號再次產生,那么它會被阻塞到當前處理結束為止。 如果在調用信號處理函數時,除了當前信號被自動屏蔽之外,還希望自動屏蔽另外一些信號,則用sa_mask字段說明這些需要額外屏蔽的信號,當信號處理函數返回時自動恢復原來的信號屏蔽字。sa_flags字段包含一些選項,這里我們不關心所以把sa_flags設為0,sa_sigaction是實時信號的處理函數
演示如下
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void Printf_Pending(const sigset_t &pending)
{for (int i = 31; i >= 1; i--){if (sigismember(&pending, i))std::cout << 1;elsestd::cout << 0;}std::cout << "\n";
}void handler(int signo)
{std::cout << "接收到" << signo << "信號" << std::endl;while (1){sigset_t pending;sigpending(&pending);Printf_Pending(pending);sleep(1);}
}int main()
{std::cout << "pid: " << getpid() << std::endl;struct sigaction act, oact;act.sa_handler = handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask, 3);sigaction(2, &act, &oact);while (1)sleep(1);return 0;
}
顯然,sigaction函數不僅將自定義捕捉的2號信號在運行時自行屏蔽,而且可以通過sa_mask同時將其他的信號也屏蔽,注意這些屏蔽會在2號信號處理結束返回后解除
并且返回后,它還是會去檢測是否有信號需要被遞達,至于信號被處理的順序和信號優先級有關(這里就不演示了)
?六、信號的補充問題(了解)
可重入函數
即可以重復進入的函數,什么意思?舉個簡單的例子,我們學過用fork創建子進程,當父進程和子進程同時執行printf語句時,就有可能在屏幕上出現交替打印的情況,導致輸出的數據不符合預期,這就說明printf語句是不可重入函數。反之,如果多個執行流可以同時進入一個函數,且不發生錯誤,就是可重入函數。
注意:可重入和不可重入是函數的一個特征,并不能作為評判函數好壞的標準。一般來說,使用公共資源的函數都是不可重入函數。
?volatile---保持內存的可見性
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> int flag = 0; void handler(int sigo) {flag = 1;std::cout << "flag changed to :" << flag << std::endl; }int main() {signal(2, handler);std::cout << "flag:" << flag << std::endl;while (!flag);std::cout << "flag:" << flag << std::endl;return 0; }
如果是正常編譯程序,不會有問題,一旦優化(相當于release版)就會出bug為什么?
因為flag變量被優化后直接放到了CPU的寄存器中,在信號處理時,我們改變的flag是內存中的flag,并不會改變寄存器中的flag,所以進程無法結束
我們可以給flag加volatile關鍵字,保證內存的可見性,即保證該變量一直從內存中讀取得到
SIGCHLD信號(17號信號)
子進程在終止時會給父進程發SIGCHLD信號,該信號的默認處理動作是忽略,父進程可以自 定義SIGCHLD信號的處理函數,這樣父進程只需專心處理自己的工作,不必關心子進程了,子進程終止時會通知父進程,父進程在信號處理函數中調用wait清理子進程即可
1、驗證子進程終止是否會向父進程發送SIGCHLD信號
#include <iostream> #include <unistd.h> #include <signal.h> #include <wait.h> #include <sys/types.h>void handler(int signo) {std::cout << "get " << signo << " sign" << std::endl; }int main() {signal(SIGCHLD, handler);pid_t id = fork();if(id==0){std::cout<<"child running"<<std::endl;sleep(5);exit(1);}wait(nullptr);return 0; }
2、在handler函數中回收子進程
#include <iostream> #include <unistd.h> #include <signal.h> #include <wait.h> #include <sys/types.h>void handler(int signo) {std::cout << "get " << signo << " sign" << std::endl;wait(nullptr); }int main() {signal(SIGCHLD, handler);std::cout << "father pid: " << getpid() << std::endl;pid_t id = fork();if (id == 0){std::cout << "child pid: " << getpid() << std::endl;std::cout << "child running" << std::endl;sleep(5);exit(1);}return 0; }
上面的是針對只有一個子進程的情況,但是如果有多個進程呢?
我們知道如果多個子進程同時退出,發送17號信號,父進程的pending表只會記錄一次17號信號,也就只會執行一次wait函數,所以有的子進程就會處于僵尸狀態,那我們該如何做?
有人可能覺得用循環就行,如下
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sys/types.h>void handler(int signo) {std::cout << "get " << signo << " sign" << std::endl;pid_t id;while ((id = wait(nullptr))){std::cout << "child pid: " << id << " exit" << std::endl;if (id < 0)break;} }int main() {signal(SIGCHLD, handler);for (int i = 0; i < 5; i++){pid_t id = fork();if (id == 0){std::cout << "child is running" << std::endl;sleep(5);exit(1);}}while(1) sleep(1);return 0; }
事實證明也確實行,但是這是所有子進程都結束的情況,如果有一部分子進程終止,另一部分子進程一直在運行,那么我們就無法跳出循環,因為wait是阻塞等待,它可以去查看是否有進程還沒終止,這樣就會一直在阻塞。
所以用非阻塞等待才是最恰當的,如下
void handler(int signo) {std::cout << "get " << signo << " sign" << std::endl;pid_t id;while ((id = waitpid(-1, nullptr, WNOHANG)) > 0){std::cout << "child pid: " << id << " exit" << std::endl;} }
當然,如果不關心子進程的返回信息,也可以直接忽略該信號,子進程會自動被清理。如下
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> int main() {signal(SIGCHLD, SIG_IGN);for (int i = 0; i < 5; i++){pid_t id = fork();if (id == 0){std::cout << "child is running" << std::endl;sleep(5);exit(1);}}while(1) sleep(1);return 0; }