信号最早是为了单进程的环境而设计的,用于在进程中捕捉各种事件,比如硬件异常、终止请求等。
每个信号都有对应的处理动作(默认或自定义),例如:
SIGTERM 用于请求进程终止;
SIGINT 是通过键盘中断(Ctrl+C)触发的信号;
SIGSEGV 则用于处理段错误(非法内存访问)。
这些信号的处理方式原本是进程级别的,也就是一个信号影响整个进程。
而随着多线程模型的引入,进程内部可以有多个线程同时运行,信号处理的复杂性也大大增加。
1
信号与多线程结合的复杂性
多线程应用程序不仅需要继承原有的信号处理特性,还要保证线程之间的信号处理逻辑不会冲突。
在传统的单进程模型中,信号被设计为能够中断当前的执行流(如捕捉异常或处理终止请求),但在多线程环境下,多个线程并行运行,同一进程的信号可以由任意线程接收并处理。
因此,这种多线程与信号处理的结合引发了以下问题:
信号由哪个线程处理:当一个信号发给进程时,内核必须决定哪个线程来处理信号,这可能会影响应用程序的行为。
信号处理与线程安全问题:信号处理函数可能在任意时刻被调用,打断当前线程的执行流,如果线程正在操作共享资源,可能引发竞争条件或不一致性。
信号屏蔽(masking):信号掩码决定了线程是否能够接收到特定信号,而每个线程可以有独立的信号掩码设置,这样的设计带来了更多的灵活性,但也增加了复杂性。
2
信号在多线程环境中的映射与处理
信号的映射方式取决于其触发源以及信号的类型。
我们可以将信号的映射机制分为进程层面和线程层面。
2.1、进程级信号
大多数信号是针对整个进程的。
例如通过 kill() 发送的信号,或者来自操作系统的控制台中断信号。
这类信号发送给进程,默认情况下,内核会从进程的所有线程中随机选择一个线程来处理信号。
kill(getpid(), SIGINT); // 给当前进程发送 SIGINT 信号
当进程中的某个线程处理这个信号时,其他线程的执行不会受到影响。
内核负责决定哪个线程接收到信号,通常是未屏蔽该信号的线程。
2.2、线程级信号
某些信号只能由特定线程处理。
例如,当线程遇到异常情况时(如段错误 SIGSEGV,浮点异常 SIGFPE),信号只会发送给引发该错误的线程。
以下例子中,访问空指针将触发段错误,SIGSEGV 信号只会发送给导致错误的线程。
void* faulty_thread(void* arg) {
int* invalid_ptr = NULL;
*invalid_ptr = 42; // 这将触发 SIGSEGV
return NULL;
}
在使用 kill() 或 sigqueue() 发送信号时,信号是针对整个进程的,内核会选择进程中的某个线程来处理信号。
而在多线程程序中,可以使用 pthread_kill() 向同一进程中的指定线程发送信号,具体如下:
int pthread_kill(pthread_t thread, int sig);
参数说明:
thread:线程 ID,指定要接收信号的线程。
sig:信号编号,指定要发送的信号。
如果 sig 为 0,pthread_kill() 不会发送信号,但会执行错误检查。
成功时返回 0,失败时返回错误编号。
除了 pthread_kill(),还可以使用 pthread_sigqueue() 发送信号。
该函数与 sigqueue() 类似,但它是将信号发送给指定的线程,而不是整个进程:
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);
参数说明:
thread:线程 ID,指定接收信号的线程。
sig:要发送的信号。
value:伴随数据,类型为 union sigval,与 sigqueue() 的 value 参数类似。
3
信号处理函数与多线程环境
无论是单线程还是多线程,信号处理函数在进程中是全局的。
也就是说,注册的信号处理函数可能会被进程中的任何一个线程调用。
以下示例当用户按下 Ctrl+C 发送 SIGINT 信号时,signal_handler 会被调用。
void signal_handler(int sig) {
printf("Caught signal %d\n", sig);
}
int main() {
signal(SIGINT, signal_handler); // 注册信号处理函数
while (1) {
printf("Running...\n");
sleep(1);
}
return 0;
}
在多线程环境下,多个线程可能会同时触发信号。
假设我们在每个线程中都执行某种操作,信号处理函数可能会在任意线程中执行。
信号处理函数必须是线程安全的,避免数据竞争或死锁等问题。
以下示例按下 Ctrl+C 时,任意线程都有可能捕获 SIGINT 信号。
信号处理函数必须能在不同线程中正确处理信号事件。
void signal_handler(int sig) {
printf("Thread %ld caught signal %d\n", pthread_self(), sig);
}
void* thread_function(void* arg) {
while (1) {
printf("Thread %ld is running...\n", pthread_self());
sleep(1);
}
}
int main() {
pthread_t thread1, thread2;
signal(SIGINT, signal_handler); // 所有线程共享的信号处理函数
pthread_create(&thread1, NULL, thread_function, NULL);
pthread_create(&thread2, NULL, thread_function, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
4
信号掩码与线程独立性
在多线程环境中,每个线程可以有自己独立的信号掩码。
通过信号掩码,线程可以选择是否接收某些信号。这为线程的信号处理提供了极大的灵活性。
pthread_sigmask() 函数用于设置线程的信号掩码,控制哪些信号应该被阻止或接收。
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
how:指定如何修改当前线程的信号屏蔽字。它的取值有以下几种:
SIG_BLOCK:将 set 中的信号添加到当前线程的信号屏蔽字中,阻塞这些信号。
SIG_UNBLOCK:将 set 中的信号从当前线程的信号屏蔽字中移除,解除阻塞这些信号。
SIG_SETMASK:将当前线程的信号屏蔽字设置为 set 中的信号集合,替换原有的阻塞信号。
set:指向 sigset_t 类型的信号集,指定要阻塞或解除阻塞的信号集合。
当 how 为 SIG_SETMASK 时,set 中的信号会替换当前屏蔽字;
当 how 为 SIG_BLOCK 或 SIG_UNBLOCK 时,set 中的信号将被添加到或从屏蔽字中移除。
oldset:如果不为 NULL,此参数将用来存储调用前的信号屏蔽字。这允许程序在修改信号屏蔽字后恢复原来的状态。
返回值:成功时,返回 0。失败时,返回错误码,通常为 errno 中定义的错误。
以下示例中,线程会屏蔽 SIGINT 信号,即使按下 Ctrl+C,该线程也不会处理 SIGINT 信号。
void* thread_function(void* arg) {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT); // 屏蔽 SIGINT 信号
pthread_sigmask(SIG_BLOCK, &set, NULL);
while (1) {
printf("Thread %ld is running...\n", pthread_self());
sleep(1);
}
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL);
pthread_join(thread, NULL);
return 0;
}
5
异步信号安全函数
异步信号安全函数是指那些可以在信号处理程序中调用的函数。
这些函数必须是可重入的,能够在信号处理期间中断正常执行流程而不会引发不一致行为。
Linux 提供了一组异步信号安全的系统调用,例如:
上表列出的这些函数被认为是异步信号安全函数。你可以通过执行命令 man 7 signal 来查阅相关文档,获取更多信息:
man 7 signal
这些函数可以在信号处理函数中安全调用。
反之,像 printf()、malloc() 等函数并不安全,因为它们可能涉及内部的缓冲机制或全局状态,容易在信号处理中引发竞争条件。
通过理解信号在多线程环境中的复杂性和设计局限性,开发者可以更好地编写安全可靠的多线程程序。
避免在多线程程序中使用全局信号处理函数:因为信号处理函数是全局共享的,它很容易引发线程之间的竞争。尽可能将信号处理逻辑与线程独立运行的机制分离。
合理使用信号掩码:通过为不同线程设置独立的信号掩码,开发者可以避免不必要的信号干扰。尤其是在执行关键任务时,可以临时屏蔽所有不相关的信号。
使用异步信号安全函数:在编写信号处理函数时,尽量只调用那些已知的异步信号安全函数,如 write()、_exit() 等,避免使用 malloc()、free() 或 printf() 这样的非异步信号安全函数。
信号与线程同步:避免在信号处理函数中直接操作复杂的数据结构或进行同步操作(如加锁),因为信号处理函数可能随时中断当前线程,导致死锁或数据不一致。