文章目錄
- 前言
- 一、信號的處理時機
- 處理情況
- “合適”的時機
- 二、用戶態與內核態
- 概念
- 重談進程地址空間
- 信號的處理過程
- 三、信號的捕捉
- 內核如何實現信號的捕捉?
- sigaction
- 四、信號部分小結
- 五、可重入函數
- 六、volatile
- 七、SIGCHLD 信號
- 總結
前言
??這篇就是我們關于信號的最后一篇啦!
??馬上就要到線程嘍,進一步說,我們的Linux系統編程就要結束嘍!
從信號產生到信號保存,中間經歷了很多,當操作系統準備對信號進行處理時,還需要判斷時機是否 “合適”,在絕大多數情況下,只有在 “合適” 的時機才能處理信號
一、信號的處理時機
處理情況
普通情況
??所謂的普通情況就是指 信號沒有被阻塞,直接產生,記錄未決信息后,再進行處理
??在這種情況下,信號是不會被立即遞達的,也就無法立即處理,需要等待合適的時機
特殊情況
??當信號被 阻塞 后,信號 產生 時,記錄未決信息,此時信號被阻塞了,也不會進行處理
??當阻塞解除后,信號會被立即遞達,此時信號會被立即處理
??特殊情況 很好理解,就好比往氣球里吹氣,當氣球炸了,空氣會被立即釋放,因為空氣是被氣球 阻塞 的,當氣球炸了之后(阻塞解除),空氣立馬往外跑,這不就是 立即遞達、立即處理 嗎?
普通情況 就有點難搞了,它需要等待 “合適” 的時機,才能被 遞達,繼而被 處理
“合適”的時機
??信號的產生是 異步 的
??也就是說,信號可能隨時產生,當信號產生時,進程可能在處理更重要的事,此時貿然處理信號顯然不夠明智
比如進程正在執行一個重要的 IO,突然一個終止信號發出,IO 立即終止,對進程、磁盤都不好
??因此信號在 產生 后,需要等進程將 更重要 的事忙完后(合適的時機),才進行 處理
??合適的時機:進程從 內核態 返回 用戶態 時,會在操作系統的指導下,對信號進行檢測及處理
??至于處理動作,分為:默認動作、忽略、用戶自定義
??搞清楚 “合適” 的時機 后,接下來我們需要來學習 用戶態 和 內核態 相關知識
二、用戶態與內核態
??對于 用戶態、內核態 的理解及引出的 進程地址空間 和 信號處理過程 相關知識是本文的重難點
概念
先來看看什么是 用戶態 和 內核態
- 用戶態:執行用戶所寫的代碼時,就屬于 用戶態
- 內核態:執行操作系統的代碼時,就屬于 內核態
自己寫的代碼被執行很好理解,操作系統的代碼是什么?
??操作系統也是由大量代碼構成的
??在對進程進行調度、執行系統調用、異常、中斷、陷阱等,都需要借助操作系統之手
??此時執行的就是操作系統的代碼
也就是說,用戶態 與 內核態 是兩種不同的狀態,必然存在相互轉換的情況
注意: 當你訪問用戶空間時你必須處于用戶態,當你訪問內核空間時你必須處于內核態。
用戶態 切換為 內核態:
- 當進程時間片到了之后,進行進程切換動作
- 調用系統調用接口,比如 open、close、read、write 等
- 產生異常、中斷、陷阱等
內核態 切換為 用戶態:
- 進程切換完畢后,運行相應的進程
- 系統調用結束后
- 異常、中斷、陷阱等處理完畢
??信號的處理時機就是 內核態 切換為 用戶態,也就是 當把更重要的事做完后,進程才會在操作系統的指導下,對信號進行檢測、處理
重談進程地址空間
- 進程地址空間 是虛擬的,依靠 頁表 + MMU機制 與 真實的地址空間 建立映射關系
- 每個進程都有自己的 進程地址空間,不同 進程地址空間 中地址可能沖突,但實際上地址是獨立的
- 進程地址空間 可以讓進程以統一的視角看待自己的代碼和數據
??感興趣的可以回看我這篇文章《Linux進程地址空間》
??不難發現,在 進程地址空間 中,存在 1 GB 的 內核空間,每個進程都有,而這 1 GB 的空間中存儲的就是 操作系統 相關代碼 和 數據,并且這塊區域采用 內核級頁表 與 真實地址空間 進行映射
我們不禁思考,為什么要這樣子區分 用戶態 和 內核態?
- 內核空間中存儲的可是操作系統的代碼和數據,權限非常高,絕不允許隨便一個進程對其造成影響
- 區域的合理劃分也是為了更好的進行管理
??所謂的 執行操作系統的代碼及系統調用,就是在使用這 1 GB 的內核空間
進程間具有獨立性,比如存在用戶空間中的代碼和數據是不同的,難道多個進程需要存儲多份操作系統的代碼和數據嗎?
- 當然不用,內核空間比較特殊,所有進程最終映射的都是同一塊區域,也就是說,進程只是將 操作系統代碼和數據 映射入自己的 進程地址空間 而已
- 而 內核級頁表 不同于 用戶級頁表,專注于對 操作系統代碼和數據 進行映射,是很特殊的
??當我們執行諸如 open 這類的 系統調用 時,會跑到 內核空間 中調用對應的函數
??而 跑到內核空間 就是 用戶態 切換為 內核態 了(用戶空間切換至內核空間)
這個 跑到 是如何實現的呢?
在 CPU 中,存在一個 CR3 寄存器,這個 寄存器 的作用就是用來表征當前處于 用戶態 還是 內核態
- 當寄存器中的值為 3 時:表示正在執行用戶的代碼,也就是處于 用戶態
- 當寄存器中的值為 0 時:表示正在執行操作系統的代碼,也就是處于 內核態
通過一個 寄存器,表征當前所處的 狀態,修改其中的 值,就可以表示不同的 狀態,這是很聰明的做法
重談 進程地址空間 后,得到以下結論
- 所有進程的用戶空間 [0, 3] GB 是不一樣的,并且每個進程都要有自己的 用戶級頁表 進行不同的映射
- 所有進程的內核空間 [3, 4] GB 是一樣的,每個進程都可以看到同一張內核級頁表,從而進行統一的映射,看到同一個 操作系統
- 操作系統運行 的本質其實就是在該進程的 內核空間內運行的(最終映射的都是同一塊區域)
- 系統調用 的本質其實就是在調用庫中對應的方法后,通過內核空間中的地址進行跳轉調用
那么進程又是如何被調度的呢?
- 操作系統的本質
- 操作系統也是軟件,并且是一個死循環式等待指令的軟件
- 存在一個硬件:操作系統時鐘硬件,每隔一段時間向操作系統發送時鐘中斷
- 進程被調度,就意味著它的時間片到了,操作系統會通過時鐘中斷,檢測到是哪一個進程的時間片到了,然后通過系統調用函數 schedule() 保存進程的上下文數據,然后選擇合適的進程去運行
信號的處理過程
??當在 內核態 完成某種任務后,需要切回 用戶態,此時就可以對信號進行 檢測 并 處理 了
情況1:信號被阻塞,信號產生 / 未產生
??信號都被阻塞了,也就不需要處理信號,此時不用管,直接切回 用戶態 就行了
??下面的情況都是基于 信號未被阻塞 且 信號已產生 的前提
情況2:當前信號的執行動作為 默認
??大多數信號的默認執行動作都是 終止 進程,此時只需要把對應的進程干掉,然后切回 用戶態 就行了
情況3:當前信號的執行動作為 忽略
??當信號執行動作為 忽略 時,不做出任何動作,直接返回 用戶態
情況4:當前信號的執行動作為 用戶自定義
??這種情況就比較麻煩了,用戶自定義的動作位于 用戶態 中,也就是說,需要先切回 用戶態,把動作完成了,重新墜入 內核態,最后才能帶著進程的上下文相關數據,返回 用戶態
在 內核態 中,也可以直接執行 自定義動作,為什么還要切回 用戶態 執行自定義動作?
- 因為在 內核態 可以訪問操作系統的代碼和數據,自定義動作 可能干出危害操作系統的事
- 在 用戶態 中可以減少影響,并且可以做到溯源
為什么不在執行完 自定義動作 直接后返回進程?
- 因為 自定義動作 和 待返回的進程 屬于不同的堆棧,是無法返回的
- 并且進程的上下文數據還在內核態中,所以需要先墜入內核態,才能正確返回用戶態
??注意: 用戶自定義的動作,需要先切換至 用戶態 中執行,執行結束后,還需要墜入 內核態
??通過一張圖快速記錄信號的 處理 過程
三、信號的捕捉
??接下來談談 信號 是如何被 捕捉 的
內核如何實現信號的捕捉?
??如果信號的執行動作為 用戶自定義動作,當信號 遞達 時調用 用戶自定義動作,這一動作稱為 信號捕捉
??用戶自定義動作 是位于 用戶空間 中的
??當 內核態 中任務完成,準備返回 用戶態 時,檢測到信號 遞達,并且此時為 用戶自定義動作,需要先切入 用戶態 ,完成 用戶自定義動作 的執行;因為 用戶自定義動作 和 待返回的函數 屬于不同的 堆棧 空間,它們之間也不存在 調用與被調用 的關系,是兩個 獨立的執行流,需要先墜入 內核態 (通過 sigreturn() 墜入),再返回 用戶態 (通過 sys_sigreturn() 返回)
上述過程可以總結為下圖:
sigaction
??sigaction 也可以 用戶自定義動作,比 signal 功能更豐富
#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); //實時信號相關,不用管
};
返回值:成功返回 0,失敗返回 -1 并將錯誤碼設置
參數1:待操作的信號
參數2:sigaction 結構體,具體成員如上所示
參數3:保存修改前進程的 sigaction 結構體信息
這個函數的主要看點是 sigaction 結構體
struct sigaction
{void (*sa_handler)(int); //自定義動作void (*sa_sigaction)(int, siginfo_t *, void *); //實時信號相關,不用管sigset_t sa_mask; //待屏蔽的信號集int sa_flags; //一些選項,一般設為 0void (*sa_restorer)(void); //實時信號相關,不用管
};
??其中部分字段不需要管,因為那些是與 實時信號 相關的,我們這里不討論
??重點可以看看 sa_mask 字段
??sa_mask:當信號在執行 用戶自定義動作 時,可以將部分信號進行屏蔽,直到 用戶自定義動作 執行完成
??也就是說,我們可以提前設置一批 待阻塞 的 屏蔽信號集,當執行 signum 中的 用戶自定義動作 時,這些 屏蔽信號集 中的 信號 將會被 屏蔽(避免干擾 用戶自定義動作 的執行),直到 用戶自定義動作 執行完成
可以簡單用一下 sigaction 函數
#include <iostream>
#include <cassert>
#include <cstring>
#include <signal.h>
#include <unistd.h>using namespace std;static void DisplayPending(const sigset_t& pending)
{// 打印 pending 表cout << "當前進程的 pending 表為: ";int i = 1;while (i < 32){if (sigismember(&pending, i))cout << "1";elsecout << "0";i++;}cout << endl;
}static void handler(int signo)
{cout << signo << " 號信號確實遞達了" << endl;// 最終不退出進程int n = 10;while (n--){// 獲取進程的 未決信號集sigset_t pending;sigemptyset(&pending);int ret = sigpending(&pending);assert(ret == 0);(void)ret; // 欺騙編譯器,避免 release 模式中出錯DisplayPending(pending);sleep(1);}
}int main()
{cout << "當前進程: " << getpid() << endl;//使用 sigaction 函數struct sigaction act, oldact;//初始化結構體memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));//初始化 自定義動作act.sa_handler = handler;//初始化 屏蔽信號集sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);sigaddset(&act.sa_mask, 5);//給 2號 信號注冊自定義動作sigaction(2, &act, &oldact);// 死循環while (true);return 0;
}
??當 2 號信號的循環結束(10 秒),3、4、5 信號的 阻塞 狀態解除,立即被 遞達,進程就被干掉了
??注意: 屏蔽信號集 sa_mask 中已屏蔽的信號,在 用戶自定義動作 執行完成后,會自動解除 阻塞 狀態
四、信號部分小結
??截至目前,信號 處理的所有過程已經全部學習完畢了
??信號產生階段:有四種產生方式,包括 鍵盤鍵入、系統調用、軟件條件、硬件異常
??信號保存階段:內核中存在三張表,blcok 表、pending 表以及 handler 表,信號在產生之后,存儲在 pending 表中
??信號處理階段:信號在 內核態 切換回 用戶態 時,才會被處理
五、可重入函數
??可以被重復進入的函數稱為 可重入函數
??比如單鏈表頭插的場景中,節點 node1 還未完成插入時,node2 也進行了頭插,最終導致 節點 node2 丟失,造成 內存泄漏
??導致 內存泄漏 的罪魁禍首:對于 node1 和 node2 來說,操作的 單鏈表 是同一個,同時進行并發訪問(重入)會出現問題的,因為此時的 單鏈表 是臨界資源
??我們學過的函數中,90% 都是 不可重入的
??函數是否可重入是一個特性,而非缺點,需要正確看待
不可重入的條件:
- 調用了內存管理相關函數
- 調用了標準 I/O 庫函數,因為其中很多實現都以不可重入的方式使用數據結構
本質上還是因為 main函數 和 sighandler函數 使用不同的堆棧空間,它們之間不存在調用與被調用的關系,是兩個獨立的控制流程
六、volatile
??volatile 關鍵字可以避免 編譯器 的優化,保證內存的 可見性
??比如我們現在來個例子,借助全局變量 flag 設計一個死循環的場景,在此之前將 2 號信號進行自定義動作捕捉,具體動作為:將 flag 改為 1,可以終止 main 函數中的循環體
#include <stdio.h>
#include <signal.h>int flag = 0; // 一開始為假void handler(int signo)
{printf("%d號信號已經成功發出了\n", signo);flag = 1;
}int main()
{signal(2, handler);while(!flag); // 故意不寫 while 的代碼塊 { }printf("進程已退出\n");return 0;
}
??初步結果符合預期,2 號信號發出后,循環結束,程序正常退出
??這段代碼能符合我們預期般的正確運行是因為 當前編譯器默認的優化級別很低,沒有出現意外情況
通過指令查詢 gcc 優化級別的相關信息
man gcc
: /o1
??其中數字越大,優化級別越高,理論上編譯出來的程序性能會更好
??事實真的如此嗎?
??讓我們重新編譯上面的程序,并指定優化級別為 o1
編譯成功后,再次運行程序
??此時得到了不一樣的結果:2 號信號發出后,對于 flag 變量的修改似乎失效了
??將優化級別設為更高是一樣的結果,如果設為 O0 則會符合預期般的運行,說明我們當前的編譯器默認的優化級別是 O0
查看編譯器的版本
gcc --version
??不同編譯器版本的優化策略可能存在差異,以上是我的 gcc 編譯環境
那么我們這段代碼哪個地方被優化了呢?
- 答案是 while 循環判斷
首先要明白:
- 對于程序中的數據,需要先被 load 到 CPU 中的 寄存器 中
- 判斷語句所需要的數據(比如 flag),在進行判斷時,是從 寄存器 中拿取并判斷
- 根據判斷的結果,判斷代碼的下一步該如何執行(通過 PC 指針指向具體的代碼執行語句)
所以程序在優化級別為 O0 或更低時,是這樣執行的:
??面對這種情況,我們就可以使用volatile關鍵字對flag變量進行修飾,告知編譯器,對flag變量的任何操作都必須真實的在內存中進行,即保持了內存的可見性。
七、SIGCHLD 信號
??在 進程控制 學習時期,我們明白了一個事實:父進程必須等待子進程退出并回收,并為其 “收尸”,避免變成 “僵尸進程” 占用系統資源、造成內存泄漏
那么 父進程是如何知道子進程退出了呢?
- 在之前的場景中,父進程要么就是設置為 阻塞式 專心等待,要么就是 設置為 WNOHANG 非阻塞式等待,這兩種方法都需要 父進程 主動去檢測 子進程 的狀態
如今學習了 進程信號 相關知識后,可以思考一下:子進程真的是安安靜靜的退出的嗎?
- 答案當然不是,子進程在退出后,會給父進程發送 SIGCHLD 信號
??可以通過 SIGCHLD 信號 通知 父進程,子進程 要退出了,這樣可以解放 父進程,不必再去 主動檢測 ,而是 子進程 要退出的時候才通知其來 “收尸”
SIGCHLD 信號比較特殊,默認動作 SIG_DEF 是 什么都不做
通過自定義捕捉,打印相關信息
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int signo)
{printf("進程 %d 捕捉到了 %d 號信號\n", getpid(), signo);
}int main()
{signal(SIGCHLD, handler);pid_t id = fork();if(id == 0){int n = 5;while(n)printf("子進程剩余生存時間: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid());// 子進程退出exit(-1);}waitpid(id, NULL, 0);return 0;
}
??因此可以證明 SIGCHLD 是被子進程真實發出的,當然,我們可以自定義捕捉動作為 回收子進程,讓父進程不再主動檢測子進程的狀態,可以自己忙自己的事
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>pid_t id; // 將子進程的id設為全局變量,方便對比void handler(int signo)
{printf("進程 %d 捕捉到了 %d 號信號\n", getpid(), signo);// 這里的 -1 表示父進程等待時,只要是已經退出了的子進程,都可以進行回收pid_t ret = waitpid(-1, NULL, 0);if(ret > 0)printf("父進程: %d 已經成功回收了 %d 號進程,之前的子進程是 %d\n", getpid(), ret, id);
}int main()
{signal(SIGCHLD, handler);id = fork();if(id == 0){int n = 5;while(n){printf("子進程剩余生存時間: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid());sleep(1);}// 子進程退出exit(-1);}// 父進程很忙的話,可以去做自己的事while(1){// TODOprintf("父進程正在忙...\n");sleep(1);}return 0;
}
??父進程和子進程各忙各的,子進程退出后會發信號通知父進程,并且能做到正確回收
那么這種方法就一定對嗎?
- 答案是不一定,在只有一個子進程的場景中,這個代碼沒問題,但如果是涉及多個子進程回收時,這個代碼就有問題了
??根本原因:SIGCHLD 也是一個信號啊,它可能也會在 block 表和 pending 表中被置為 1,當多個子進程同時向父進程發出信號時,父進程只能先回收最快發出信號的子進程,并將隨后發出信號的子進程 SIGCHLD 信號保存在 blcok 表中,除此之外,其他的子進程信號就丟失了,父進程處理完這兩個信號后,就認為沒有信號需要處理了,這就造成了內存泄漏
??解決方案:自定義捕捉函數中,采取 while 循環式回收,有很多進程都需要回收沒問題,排好隊一個個來就好了,這樣就可以確保多個子進程同時發出 SIGCHLD 信號時,可以做到一一回收
??細節:多個子進程運行時,可能有的退了,有的沒退,這會導致退了的子進程發出信號后,觸發自定義捕捉函數中的循環等待機制,回收完已經退出了的子進程后,會阻塞式的等待還沒有退出的子進程,如果子進程一直不退,就會一直被阻塞,所以我們需要把進程回收設為 WNOHANG 非阻塞式等待
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int signo)
{printf("進程 %d 捕捉到了 %d 號信號\n", getpid(), signo);// 這里的 -1 表示父進程等待時,只要是已經退出了的子進程,都可以進行回收while (1){pid_t ret = waitpid(-1, NULL, WNOHANG);if (ret > 0)printf("父進程: %d 已經成功回收了 %d 號進程\n", getpid(), ret);elsebreak;}printf("子進程回收成功\n");
}int main()
{signal(SIGCHLD, handler);// 創建10個子進程int n = 10;while (n--){pid_t id = fork();if (id == 0){int n = 5;while (n){printf("子進程剩余生存時間: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid());sleep(1);}// 子進程退出exit(-1);}}// 父進程很忙的話,可以去做自己的事while (1){// TODOprintf("父進程正在忙...\n");sleep(1);}return 0;
}
??我們可以注意到程序分幾個批次把所有子進程都回收了
總結
??結束了,開始進入線程!