Linux 多執行緒應用中如何編寫安全的訊號處理函式
關於程式碼的可重入性,設計開發人員一般只考慮到執行緒安全,非同步訊號處理函式的安全卻往往被忽略。本文首先介紹如何編寫安全的非同步訊號處理函式;然後舉例說明在多執行緒應用中如何構建模型讓非同步訊號在指定的執行緒中以同步的方式處理。
Linux 多執行緒應用中編寫安全的訊號處理函式
在開發多執行緒應用時,開發人員一般都會考慮執行緒安全,會使用 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 的處理。另一方面,也可考慮使用上文介紹的同步模型處理訊號,簡化訊號處理函式的編寫,避免因為訊號處理函式執行上下文的不確定性而帶來的風險。