Linux线程(八)线程与信号之间的关系详解
本小节将对线程各方面的细节做深入讨论,其主要包括线程与信号之间牵扯的问题、线程与进程控制(fork()、exec()、exit()等)之间的交互。之所以出现了这些问题,其原因在于线程技术的问世晚于信号、进程控制等,然而线程的出现必须要能够兼容现有的这些技术,不能出现冲突,这就使得线程与它们之间的结合使用将会变得比较复杂!当中所涉及到的细节问题也会比较多。
线程与信号
Linux 信号模型是基于进程模型而设计的,信号的问世远早于线程;自然而然,线程与信号之间就会存在一些冲突,其主要原因在于:信号既要能够在传统的单线程进程中保持它原有的功能、特性,与此同时,又需要设计出能够适用于多线程环境的新特性!
信号与多线程模型之间结合使用,将会变得比较复杂,需要考虑的问题将会更多,在实际应用开发当中,如果能够避免我们应尽量避免此类事情的发生;但尽管如此,事实上,信号与多线程模型确实存在于实际的应用开发项目中。本小节我们就来讨论信号与线程之间牵扯的问题。
1. 信号如何映射到线程
信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个线程层面的,以下对其进行汇总:
-
信号的系统默认行为是属于进程层面。8.3 小节介绍到,每一个信号都有其对应的系统默认动作,当进程中的任一线程收到任何一个未经处理(忽略或捕获)的信号时,会执行该信号的默认操作,信号的默认操作通常是停止或终止进程。
-
信号处理函数属于进程层面。进程中的所有线程共享程序中所注册的信号处理函数;
-
信号的发送既可针对整个进程,也可针对某个特定的线程。在满足以下三个条件中的任意一个时,信号的发送针对的是某个线程:
-
产生了硬件异常相关信号,譬如 SIGBUS、SIGFPE、SIGILL 和 SIGSEGV 信号;这些硬件异常信号在某个线程执行指令的过程中产生,也就是说这些硬件异常信号是由某个线程所引起;那么在这种情况下,系统会将信号发送给该线程。
-
当线程试图对已断开的管道进行写操作时所产生的 SIGPIPE 信号;
-
由函数 pthread_kill()或 pthread_sigqueue()所发出的信号,稍后介绍这两个函数;这些函数允许线程向同一进程下的其它线程发送一个指定的信号。
除了以上提到的三种情况之外,其它机制产生的信号均属于进程层面,譬如其它进程调用 kill()或sigqueue()所发送的信号;用户在终端按下 Ctrl+C、Ctrl+\、Ctrl+Z 向前台进程发送的 SIGINT、SIGQUIT 以及 SIGTSTP 信号。
-
-
当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接收这个信号,意味着由该线程接收信号并调用信号处理函数对其进行处理,并不是每个线程都会接收到该信号并调用信号处理函数;这种行为与信号的原始语义是保持一致的,让进程对单个信号接收重复处理多次是没有意义的。
-
信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言。8.9 小节向大家介绍了信号掩码的概念,并介绍了 sigprocmask()函数,通过 sigprocmask()可以设置进程的信号掩码,事实上,信号掩码是并不是针对整个进程来说,而是针对线程,对于一个多线程应用程序来说,并不存在一个作用于整个进程范围内的信号掩码(管理进程中的所有线程);那么在多线程环境下,各个线程可以调用 pthread_sigmask()函数来设置它们各自的信号掩码,譬如设置线程可以接收哪些信号、不接收哪些信号,各线程可独立阻止或放行各种信号。
-
针对整个进程所挂起的信号,以及针对每个线程所挂起的信号,内核都会分别进行维护、记录。
8.11.1 小节介绍到,调用 sigpending()会返回进程中所有被挂起的信号,事实上,sigpending()会返
回针对整个进程所挂起的信号,以及针对每个线程所挂起的信号的并集。
2. 线程的信号掩码
对于一个单线程程序来说,使用 sigprocmask()函数设置进程的信号掩码,在多线程环境下,使用pthread_sigmask()函数来设置各个线程的信号掩码,其函数原型如下所示:
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
pthread_sigmask()函数就像 sigprocmask() 一 样 ,不 同 之 处 在 于 它 在 多线 程 程 序 中 使用,所以pthread_sigmask()函数的用法与 sigprocmask()完全一样,这里就不再重述!
每个刚创建的线程,会从其创建者处继承信号掩码,这个新的线程可以调用 pthread_sigmask()函数来改变它的信号掩码。
3. 向线程发送信号
调用 kill()或 sigqueue()所发送的信号都是针对整个进程来说的,它属于进程层面,具体该目标进程中的哪一个线程会去处理信号,由内核进行选择。事实上,在多线程程序中,可以通过 pthread_kill()向同一进程中的某个指定线程发送信号,其函数原型如下所示:
#include <signal.h>
int pthread_kill(pthread_t thread, int sig);
参数 thread,也就是线程 ID,用于指定同一进程中的某个线程,调用 pthread_kill()将向参数 thread 指定的线程发送信号 sig。
如果参数 sig 为 0,则不发送信号,但仍会执行错误检查。函数调用成功返回 0,失败将返回一个错误编号,不会发送信号。
除了 pthread_kill()函数外,还可以调用 pthread_sigqueue()函数;pthread_sigqueue()函数执行与 sigqueue类似的任务,但它不是向进程发送信号,而是向同一进程中的某个指定的线程发送信号。其函数原型如下所示:
#include <signal.h>
#include <pthread.h>
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);
参数 thread 为线程 ID,指定接收信号的目标线程(目标线程与调用 pthread_sigqueue()函数的线程是属于同一个进程),参数 sig 指定要发送的信号,参数 value 指定伴随数据,与 sigqueue()函数中的 value 参数意义相同。
pthread_sigqueue()函数的参数的含义与 sigqueue()函数中对应参数相同意义相同。它俩的唯一区别在于,sigqueue()函数发送的信号针对的是整个进程,而 pthread_sigqueue()函数发送的信号针对的是某个线程。
4. 异步信号安全函数
应用程序中涉及信号处理函数时必须要非常小心,因为信号处理函数可能会在程序执行的任意时间点被调用,从而打断主程序。接下来介绍一个概念---异步信号安全函数(async-signal-safe function)。
前面介绍了线程安全函数,作为线程安全函数可以被多个线程同时调用,每次都能得到预期的结果,但是这里有前提条件,那就是没有信号处理函数参与;换句话说,线程安全函数不能在信号处理函数中被调用,否则就不能保证它一定是安全的。所以就出现了异步信号安全函数。
异步信号安全函数指的是可以在信号处理函数中可以被安全调用的线程安全函数,所以它比线程安全函数的要求更为严格!可重入函数满足这个要求,所以可重入函数一定是异步信号安全函数。而线程安全函函数的要求更为严格!可重入函数满足这个要求,所以可重入函数一定是异步信号安全函数。而线程安全函数则不一定是异步信号安全函数了。
举个例子,下面列举出来的一个函数是线程安全函数:
static pthread_mutex_t mutex;static int glob = 0;static void func(int loops){int local;int j;for (j = 0; j < loops; j++) {pthread_mutex_lock(&mutex); //互斥锁上锁local = glob;local++;glob = local;pthread_mutex_unlock(&mutex);//互斥锁解锁}}
该函数虽然对全局变量进行读写操作,但是在访问全局变量时进行了加锁,避免了引发竞争冒险;它是一个线程安全函数,假设线程 1 正在执行函数 func,刚刚获得锁(也就是刚刚对互斥锁上锁),而这时进程收到信号,并分派给线程 1 处理,线程 1 接着跳转去执行信号处理函数,不巧的是,信号处理函数中也调用了 func()函数,同样它也去获取锁,由于此时锁处于锁住状态,所以信号处理函数中调用 func()获取锁将会陷入休眠、等待锁的释放。这时线程 1 就会陷入死锁状态,线程 1 无法执行,锁无法释放;如果其它线程也调用 func(),那它们也会陷入休眠、如此将会导致整个程序陷入死锁!
通过上面的分析,可知,涉及到信号处理函数时要非常小心。之所以涉及到信号处理函数时会出现安全问题,笔者认为主要原因在以下两个方面:
⚫ 信号是异步的,信号可能会在任何时间点中断主程序的运行,跳转到信号处理函数处执行,从而形成一个新的执行流(信号处理函数执行流)。
⚫ 信号处理函数执行流与线程执行流存在一些区别,信号处理函数所产生的执行流是由执行信号处理函数的线程所触发的,它俩是在同一个线程中,属于同一个线程执行流。
在异步信号安全函数、可重入函数以及线程安全函数三者中,可重入函数的要求是最严格的,所以通常会说可重入函数一定是线程安全函数、也一定是异步信号安全函数。通常对于上面所列举出的线程安全函数 func(),如果想将其实现为异步信号安全函数,可以在获取锁之前通过设置信号掩码,在锁期间禁止接收该信号,也就是说将函数实现为不可被信号中断。经过这样处理之后,函数 func()就是一个异步信号安全函数了。
Linux 标准 C 库和系统调用中以下函数被认为是异步信号安全函数:
_Exit()_exit()abort()accept()access()aio_error()aio_return()aio_suspend()alarm()bind()cfgetispeed()cfgetospeed()cfsetispeed()cfsetospeed()chdir()chmod()chown()clock_gettime()close()connect()creat()dup()dup2()execle()execve()fchmod()fchown()fcntl()fdatasync()fork()execl()fstat()fsync()ftruncate()getegid()geteuid()getgid()getgroups()getpeername()getpgrp()getpid()getppid()getsockname()getsockopt()getuid()kill()link()listen()lseek()lstat()mkdir()mkfifo()open()execv()pause()pipe()poll()posix_trace_event()pselect()raise()read()readlink()recv()recvfrom()recvmsg()rename()rmdir()select()sem_post()send()sendmsg()sendto()setgid()setpgid()setsid()setsockopt()setuid()shutdown()sigaction()sigaddset()sigdelset()sigemptyset()sigfillset()sigismember()signal()sigpause()sigpending()sigprocmask()sigqueue()sigset()sigsuspend()sleep()sockatmark()socket()socketpair()stat()symlink()faccessat()tcdrain()tcflow()tcflush()tcgetattr()tcgetpgrp()tcsendbreak()tcsetattr()tcsetpgrp()time()timer_getoverrun()timer_gettime()timer_settime()times()umask()uname()unlink()utime()wait()waitpid()write()fchmodat()fchownat()fexecve()fstatat()futimens()linkat()mkdirat()mkfifoat()mknod()mknodat()openat()readlinkat()renameat()symlinkat()unlinkat()utimensat()utimes()fchdir()pthread_kill()pthread_self()pthread_sigmask()
上所列举出的这些函数被认为是异步信号安全函数,可以通过 man 手册查询,执行命令"man 7 signal",
如下所示:
异步信号安全函数大家可以通过对比 man 手册查询到的这些异步信号安全函数,来确定自己调用的库函数或系统调用是
不是异步信号安全函数,这里需要说,在本书的示例代码中,并没有完全按照安全性要求,在信号处理函数中使用异步信号安全函数,譬如在本书中的示例代码中,信号处理函数中调用了 printf()用于打印信息,事实上这个函数是一个非异步信号安全函数,当然在一个实际的项目应用程序当中不能这么用,但是本书只是为了方便输出打印信息而已。
所以对于一个安全的信号处理函数来说,需要做到以下几点:
⚫ 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;
⚫ 当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。