標籤:
關於代碼的可重新進入性,設計開發人員一般只考慮到安全執行緒,非同步訊號處理函數的安全卻往往被忽略。本文首先介紹如何編寫安全的非同步訊號處理函數;然後舉例說明在多線程應用中如何構建模型讓非同步訊號在指定的線程中以同步的方式處理。
應用中編寫安全的訊號處理函數
在開發多線程應用時,開發人員一般都會考慮安全執行緒,會使用 pthread_mutex 去保護全域變數。如果應用中使用了訊號,而且訊號的產生不是因為程式運行出錯,而是程式邏輯需要,譬如 SIGUSR1、SIGRTMIN 等,訊號在被處理後應用程式還將正常運行。在編寫這類訊號處理函數時,應用程式層面的開發人員卻往往忽略了訊號處理函數執行的上下文背景,沒有考慮編寫安全的訊號處理函數的一些規則。本文首先介紹編寫訊號處理函數時需要考慮的一些規則;然後舉例說明在多線程應用中如何構建模型讓因為程式邏輯需要而產生的非同步訊號在指定的線程中以同步的方式處理。
線程和訊號
Linux 多線程應用中,每個線程可以通過調用 pthread_sigmask() 設定本線程的訊號掩碼。一般情況下,被阻塞的訊號將不能中斷此線程的執行,除非此訊號的產生是因為程式運行出錯如 SIGSEGV;另外不能被忽略處理的訊號 SIGKILL 和 SIGSTOP 也無法被阻塞。
當一個線程調用 pthread_create() 建立新的線程時,此線程的訊號掩碼會被新建立的線程繼承。
POSIX.1 標準定義了一系列線程函數的介面,即 POSIX threads(Pthreads)。Linux C 庫提供了兩種關於線程的實現:LinuxThreads 和 NPTL(Native POSIX Threads Library)。LinuxThreads 已經過時,一些函數的實現不遵循POSIX.1 規範。NPTL 依賴 Linux 2.6 核心,更加遵循 POSIX..1 規範,但也不是完全遵循。
基於 NPTL 的線程庫,多線程應用中的每個線程有自己獨特的線程 ID,並共用同一個進程ID。應用程式可以通過調用 kill(getpid(),signo)將訊號發送到進程,如果進程中當前正在執行的線程沒有阻礙此訊號,則會被中斷,線號處理函數會在此線程的上下文背景中執行。應用程式也可以通過調用 pthread_kill(pthread_t thread, int sig) 將訊號發送給指定的線程,則線號處理函數會在此指定線程的上下文背景中執行。
基於 LinuxThreads 的線程庫,多線程應用中的每個線程擁有自己獨特的進程 ID,getpid() 在不同的線程中調用會返回不同的值,所以無法通過調用 kill(getpid(),signo) 將訊號發送到整個進程。
下文介紹的在指定的線程中以同步的方式處理非同步訊號是基於使用了 NPTL 的 Linux C 庫。請參考“Linux 執行緒模式的比較:LinuxThreads 和 NPTL”和“pthreads(7) - Linux man page”進一步瞭解 Linux 的執行緒模式,以及不同版本的 Linux C 庫對 NPTL 的支援。
編寫安全的非同步訊號處理函數
訊號的產生可以是:
- 使用者從控制終端終止程式運行,如 Ctrk + C 產生 SIGINT;
- 程式運行出錯時由硬體產生訊號,如訪問非法地址產生 SIGSEGV;
- 程式運行邏輯需要,如調用
kill、raise 產生訊號。
因為訊號是非同步事件,即訊號處理函數執行的上下文背景是不確定的,譬如一個線程在調用某個庫函數時可能會被訊號中斷,庫函數提前出錯返回,轉而去執行訊號處理函數。對於上述第三種訊號的產生,訊號在產生、處理後,應用程式不會終止,還是會繼續正常運行,在編寫此類訊號處理函數時尤其需要小心,以免破壞應用程式的正常運行。
關於編寫安全的訊號處理函數主要有以下一些規則:
- 訊號處理函數盡量只執行簡單的操作,譬如只是設定一個外部變數,其它複雜的操作留在訊號處理函數之外執行;
errno 是安全執行緒,即每個線程有自己的 errno,但不是非同步訊號安全。如果訊號處理函數比較複雜,且調用了可能會改變 errno 值的庫函數,必須考慮在訊號處理函數開始時儲存、結束的時候恢複被中斷線程的 errno 值;
- 訊號處理函數只能調用可以重入的 C 庫函數;譬如不能調用
malloc(),free()以及標準 I/O 庫函數等;
- 訊號處理函數如果需要訪問全域變數,在定義此全域變數時須將其聲明為
volatile,以避免編譯器不恰當的最佳化。
從整個 Linux 應用的角度出發,因為應用中使用了非同步訊號,程式中一些庫函數在調用時可能被非同步訊號中斷,此時必鬚根據errno 的值考慮這些庫函數調用被訊號中斷後的出錯恢複處理,譬如socket 編程中的讀操作:
rlen = recv(sock_fd, buf, len, MSG_WAITALL); if ((rlen == -1) && (errno == EINTR)){ // this kind of error is recoverable, we can set the offset change //‘rlen’ as 0 and continue to recv }
回頁首
在指定的線程中以同步的方式處理非同步訊號
如上文所述,不僅編寫安全的非同步訊號處理函數本身有很多的規則束縛;應用中其它地方在調用可被訊號中斷的庫函數時還需考慮被中斷後的出錯恢複處理。這讓程式的編寫變得複雜,幸運的是,POSIX.1 規範定義了sigwait()、 sigwaitinfo() 和 pthread_sigmask() 等介面,可以實現:
這種在指定的線程中以同步方式處理訊號的模型可以避免因為處理非同步訊號而給程式運行帶來的不確定性和潛在危險。
sigwait
sigwait() 提供了一種等待訊號的到來,以串列的方式從訊號隊列中取出訊號進行處理的機制。sigwait()只等待函數參數中指定的訊號集,即如果新產生的訊號不在指定的訊號集內,則 sigwait()繼續等待。對於一個穩定可靠的程式,我們一般會有一些疑問:
- 如果訊號隊列中有多個訊號在等待,在訊號處理時有沒有優先順序規則?
- 即時訊號和非即時訊號在處理時有沒有什麼區別?
筆者寫了一小段測試程式來測試 sigwait 在訊號處理時的一些規則。
清單 1. sigwait_test.c
#include <signal.h>#include <errno.h>#include <pthread.h>#include <unistd.h>#include <sys/types.h>void sig_handler(int signum){ printf("Receive signal. %d\n", signum);}void* sigmgr_thread(){ sigset_t waitset, oset; int sig; int rc; pthread_t ppid = pthread_self(); pthread_detach(ppid); sigemptyset(&waitset); sigaddset(&waitset, SIGRTMIN); sigaddset(&waitset, SIGRTMIN+2); sigaddset(&waitset, SIGRTMAX); sigaddset(&waitset, SIGUSR1); sigaddset(&waitset, SIGUSR2); while (1) { rc = sigwait(&waitset, &sig); if (rc != -1) { sig_handler(sig); } else { printf("sigwaitinfo() returned err: %d; %s\n", errno, strerror(errno)); } }}int main(){ sigset_t bset, oset; int i; pid_t pid = getpid(); pthread_t ppid; sigemptyset(&bset); sigaddset(&bset, SIGRTMIN); sigaddset(&bset, SIGRTMIN+2); sigaddset(&bset, SIGRTMAX); sigaddset(&bset, SIGUSR1); sigaddset(&bset, SIGUSR2); if (pthread_sigmask(SIG_BLOCK, &bset, &oset) != 0) printf("!! Set pthread mask failed\n"); kill(pid, SIGRTMAX); kill(pid, SIGRTMAX); kill(pid, SIGRTMIN+2); kill(pid, SIGRTMIN); kill(pid, SIGRTMIN+2); kill(pid, SIGRTMIN); kill(pid, SIGUSR2); kill(pid, SIGUSR2); kill(pid, SIGUSR1);kill(pid, SIGUSR1); // Create the dedicated thread sigmgr_thread() which will handle signals synchronously pthread_create(&ppid, NULL, sigmgr_thread, NULL); sleep(10); exit (0);}
程式編譯運行在 RHEL4 的結果如下:
圖 1. sigwait 測試程式執行結果
從以上測試程式發現以下規則:
- 對於非即時訊號,相同訊號不能在訊號隊列中排隊;對於即時訊號,相同訊號可以在訊號隊列中排隊。
- 如果訊號隊列中有多個即時以及非即時訊號排隊,即時訊號並不會先於非即時訊號被取出,訊號數字小的會先被取出:如 SIGUSR1(10)會先於 SIGUSR2 (12),SIGRTMIN(34)會先於 SIGRTMAX (64), 非即時訊號因為其訊號數字小而先於即時訊號被取出。
sigwaitinfo() 以及 sigtimedwait() 也提供了與 sigwait() 函數相似的功能。
Linux 多線程應用中的訊號處理模型
在基於 Linux 的多線程應用中,對於因為程式邏輯需要而產生的訊號,可考慮調用 sigwait()使用同步模型進行處理。其程式流程如下:
- 主線程設定訊號掩碼,阻礙希望同步處理的訊號;主線程的訊號掩碼會被其建立的線程繼承;
- 主線程建立訊號處理線程;訊號處理線程將希望同步處理的訊號集設為
sigwait()的第一個參數。
- 主線程建立背景工作執行緒。
圖 2. 在指定的線程中以同步方式處理非同步訊號的模型程式碼範例
以下為一個完整的在指定的線程中以同步的方式處理非同步訊號的程式。
主線程設定訊號掩碼阻礙 SIGUSR1 和 SIGRTMIN 兩個訊號,然後建立訊號處理線程sigmgr_thread()和五個背景工作執行緒worker_thread()。主線程每隔10秒調用 kill() 對本進程發送 SIGUSR1 和 SIGTRMIN 訊號。訊號處理線程 sigmgr_thread()在接收到訊號時會調用訊號處理函數 sig_handler()。
程式編譯:gcc -o signal_sync signal_sync.c -lpthread
程式執行:./signal_sync
從程式執行輸出結果可以看到主線程發出的所有訊號都被指定的訊號處理線程接收到,並以同步的方式處理。
清單 2. signal_sync.c
#include <signal.h>#include <errno.h>#include <pthread.h>#include <unistd.h>#include <sys/types.h> void sig_handler(int signum){ static int j = 0; static int k = 0; pthread_t sig_ppid = pthread_self(); // used to show which thread the signal is handled in. if (signum == SIGUSR1) { printf("thread %d, receive SIGUSR1 No. %d\n", sig_ppid, j); j++; //SIGRTMIN should not be considered constants from userland, //there is compile error when use switch case } else if (signum == SIGRTMIN) { printf("thread %d, receive SIGRTMIN No. %d\n", sig_ppid, k); k++; }}void* worker_thread(){ pthread_t ppid = pthread_self(); pthread_detach(ppid); while (1) { printf("I‘m thread %d, I‘m alive\n", ppid); sleep(10); }}void* sigmgr_thread(){ sigset_t waitset, oset; siginfo_t info; int rc; pthread_t ppid = pthread_self(); pthread_detach(ppid); sigemptyset(&waitset); sigaddset(&waitset, SIGRTMIN); sigaddset(&waitset, SIGUSR1); while (1) { rc = sigwaitinfo(&waitset, &info); if (rc != -1) { printf("sigwaitinfo() fetch the signal - %d\n", rc); sig_handler(info.si_signo); } else { printf("sigwaitinfo() returned err: %d; %s\n", errno, strerror(errno)); } }}int main(){ sigset_t bset, oset; int i; pid_t pid = getpid(); pthread_t ppid; // Block SIGRTMIN and SIGUSR1 which will be handled in //dedicated thread sigmgr_thread() // Newly created threads will inherit the pthread mask from its creator sigemptyset(&bset); sigaddset(&bset, SIGRTMIN); sigaddset(&bset, SIGUSR1); if (pthread_sigmask(SIG_BLOCK, &bset, &oset) != 0) printf("!! Set pthread mask failed\n"); // Create the dedicated thread sigmgr_thread() which will handle // SIGUSR1 and SIGRTMIN synchronously pthread_create(&ppid, NULL, sigmgr_thread, NULL); // Create 5 worker threads, which will inherit the thread mask of // the creator main thread for (i = 0; i < 5; i++) { pthread_create(&ppid, NULL, worker_thread, NULL); } // send out 50 SIGUSR1 and SIGRTMIN signals for (i = 0; i < 50; i++) { kill(pid, SIGUSR1); printf("main thread, send SIGUSR1 No. %d\n", i); kill(pid, SIGRTMIN); printf("main thread, send SIGRTMIN No. %d\n", i); sleep(10); } exit (0);}注意事項
在基於 Linux 的多線程應用中,對於因為程式邏輯需要而產生的訊號,可考慮使用同步模型進行處理;而對會導致程式運行終止的訊號如 SIGSEGV 等,必須按照傳統的非同步方式使用 signal()、 sigaction()註冊訊號處理函數進行處理。這兩種訊號處理模型可根據所處理的訊號的不同同時存在一個 Linux 應用中:
- 不要線上程的訊號掩碼中阻塞不能被忽略處理的兩個訊號 SIGSTOP 和 SIGKILL。
- 不要線上程的訊號掩碼中阻塞 SIGFPE、SIGILL、SIGSEGV、SIGBUS。
- 確保
sigwait() 等待的訊號集已經被進程中所有的線程阻塞。
- 在主線程或其它背景工作執行緒產生訊號時,必須調用
kill() 將訊號發給整個進程,而不能使用 pthread_kill() 發送某個特定的背景工作執行緒,否則訊號處理線程無法接收到此訊號。
- 因為
sigwait()使用了串列的方式處理訊號的到來,為避免訊號的處理存在滯後,或是非即時訊號被丟失的情況,處理每個訊號的代碼應盡量簡潔、快速,避免調用會產生阻塞的庫函數。
小結
在開發 Linux 多線程應用中, 如果因為程式邏輯需要引入訊號, 在訊號處理後程式仍將繼續正常運行。在這種背景下,如果以非同步方式處理訊號,在編寫訊號處理函數一定要考慮非同步訊號處理函數的安全; 同時, 程式中一些庫函數可能會被訊號中斷,錯誤返回,這時需要考慮對 EINTR 的處理。另一方面,也可考慮使用上文介紹的同步模型處理訊號,簡化訊號處理函數的編寫,避免因為訊號處理函數執行內容的不確定性而帶來的風險。
linux 非同步訊號的同步處理方式