参考可重入、线程安全和异步信号安全,需要强调的是异步信号安全,这个概念知道的人不多,平常大家在编写代码的时候也很少考虑这个因素,也不清楚哪些函数是异步信号安全的,哪些不是,典型的像printf
就不是异步信号安全的,内部会加锁,但是平时很多人都喜欢在信号处理函数中调用。大多数情况下都不会出现问题的,所以让使用者错误的认为这是正确的写法。第二个需要注意的是可重入的概念,Linux有很不少系统调用的实现都是不可重入的,会将结果保存在内部的静态数据存储中,同时这类系统调用也提供了可重入版本的实现,其函数名就是尾部添加_r
来标识。
如上图所示一个进程/线程就是一个task_struct结构,该结构包含了属于这个进程/线程的阻塞信号集、pending的信号等,所有投递到该进程/线程的信号都会通过双向链表组织在一起,链表的元素是sigqueue,所有的信号对应的信号处理函数存放在sighand_struct中的一个类型为k_sigaction数组,每次程序由核心态切换到用户态时,内核都会发起信号处理,执行信号处理程序的时候为了避免对内核产生影响,所以使用的是用户栈,还可以自定义信号处理的备用栈。
信号处理函数是每次程序从核心态切换到用户态的时候,内核才会负责发起信号处理,也就是说信号处理的时机有以下两种:
- 进程在当前时间片用完后,获得了新的时间片时(会发生内核态到用户态的切换)
- 系统调用执行完成时(信号的传递可能会引起正在阻塞的系统调用过早完成)
通过查看/proc/PID/status
文件,该文件中有几个字端的值,这些值按照十六进制的形式显示,最低的有效位表示信号1,相邻的左边一位代表信号2,依次类推,例如下面这几个数值:
SigQ: 0/3872 0是当前信号队列中的信号数,3872是信号队列的最大长度
SigPnd: 0000000000000000 当前pending的信号,也就是没有信号投递给线程
ShdPnd: 0000000000000000 当前pengding的信号,也就是没有信号投递给进程
SigBlk: 7be3c0fe28014a03 当前阻塞的信号
SigIgn: 0000000000001000 当前被忽略的信号
SigCgt: 00000001800004ec 当前被捕获的信号
当信号到达的时候,默认情况下信号有如下几种处理方式:
这两者都可以用来改变信号处置,signal很原始,提供的接口也比较简单,而sigaction提供了signal所不具备的功能。为了兼容,signal系统调用仍然保存,但是glibc是使用sigaction实现了signal的功能。sigaction同时支持两种形式的信号处理,通过不用的flags区分,通过设置不同的flags可以得到不同的功能。
struct sigaction {
// 两种handler,兼容老的signal对应的信号处理函数
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask; // 要屏蔽的信号集
int sa_flags;
void (*sa_restorer)(void); // Not for application use
};
sa_flags:
1. SA_NOCLDSTOP 当接收一个信号而停止或恢复某一个子进程时,将不会产生SIGCHLD信号
2. SA_NOCLDWAIT 子进程终止时不会将其转化为僵尸
3. SA_NODEFER 捕获信号后,不会在执行信号处理器程序的时候自动将该信号添加到进程掩码中,也就是不会被这个 信号再次打断,成为死循环。
4. SA_ONSTACK 在执行信号处理函数时,使用sigaltstack安装的备选栈
5. SA_RESETHAND 捕获信号后,会在调用处理器函数之前将信号处置为默认值
6. SA_RESTART 自动重启由信号处理器程序中断的系统调用
7. SA_SIGINFO 调用信号处理器程序时,携带额外的参数,也就是使用sa_sigaction类型的信号处理函数
kill系统调用可以用来向指定进程发送信号,如果指定的信号是0的时候,kill仅会进行错误的检查,查看是否可以想目标进程发送信号,而这一特点恰好可以用来检测特定进程ID所对应的进程是否存在,如果不存在那么kill调用失败,并且errno设置为ESRCH
#include
#include
#include
int main() {
printf("signal: %s\n", strsignal(SIGKILL)); // 和strerror等同
psignal(SIGKILL, "signal"); // 和perror等同
return 0;
}
信号集是一种用来表示一系列信号集合的数据结构,使用sigset_t来表示,它的底层存储类型其实只是一个unsigned long类型,如下:
typedef unsigned long sigset_t;
unsigned long一共是八个字节,总共是64位,每一位表示一个信号的话,最多可以表示64个信号,这个和信号的最大值是吻合的。信号集也提供了一系列用来操作信号集的方法,sigemptyset、sigfillset、sigaddset、sigdelset、sigismember、sigisemptyset等
阻塞信号的实现不难,通过上文中对信号内部实现的分析可知,通过将要阻塞的信号放到task_struct结构中的blocked成员中,那么在信号的投递时会先查看下要投递的信号是否在阻塞信号集中,如果在就停止投递,否则就触发对应的信号处理,通过sigprocmask可以设置当前进程的阻塞信号集,对应到内核的实现如下:
int sigprocmask(int how, sigset_t *set, sigset_t *oldset)
{
struct task_struct *tsk = current;
sigset_t newset;
/* Lockless, only current can change ->blocked, never from irq */
if (oldset)
*oldset = tsk->blocked;
switch (how) {
case SIG_BLOCK:
sigorsets(&newset, &tsk->blocked, set);
break;
case SIG_UNBLOCK:
sigandnsets(&newset, &tsk->blocked, set);
break;
case SIG_SETMASK:
newset = *set;
break;
default:
return -EINVAL;
}
__set_current_blocked(&newset);
return 0;
}
通过sigprocmask设置阻塞的信号集存在一个竞态,如果想在设置信号处理函数的同时再设置阻塞的信号集,那么这需要先调用signal/sigaction,然后再调用sigprocmask,在设置信号处理函数和调用sigprocmask之间存在一个间隙,如果在这个间隙期间后信号投递,那么就没有起到阻塞信号的作用了。为此sigaction的sa_mask成员可以用来设置阻塞信号集,这使得设置信号处理函数的同时就可以设置阻塞信号集。
另外一个问题就是被阻塞的信号在等待解除阻塞后是否会投递到进程进行处理?信号被阻塞后就会变成待决信号,并通过链表链接起来,task_struct结构中的pending成员就是链表头,如果一个信号发送多次,linux是不保证投递相同次数的,只会保存一次,也就是非实时,不对信号排队。其中SIGKILL和SIGSTOP是不能被阻塞的。
说白了这里就是去查询待决信号的链表也就是task_struct结构中的pending成员,将里面的信号放到信号集中返回即可。对应到内核实现如下:
static int do_sigpending(void *set, unsigned long sigsetsize)
{
if (sigsetsize > sizeof(sigset_t))
return -EINVAL;
spin_lock_irq(¤t->sighand->siglock); //加锁
sigorsets(set, ¤t->pending.signal, // 将pengding和signal->shared_pending中的信号区取并集
¤t->signal->shared_pending.signal);
spin_unlock_irq(¤t->sighand->siglock);
/* Outside the lock because only this thread touches it. */
sigandsets(set, ¤t->blocked, set); //最后将待决信号和阻塞的信号取交集,因为待决信号并不一定是阻塞的,有可能是还没来得及投递的,所以这里要取交集
return 0;
}
在用户态通过sigpending函数就可以查询当前哪些被阻塞的信号是未决的(也就是已经投递到进程了,但是因为被屏蔽了还没有被处理,也就是保存在进程的pending成员中)
信号处理函数和普通函数是有一些区别的,因为这个函数是异步被执行的,所以需要考虑异步信号安全的问题,在这个函数中没办法使用一些非异步信号安全的函数,为此编写信号处理函数一般要遵从一些设计,两种常见的设计如下:
一个信号到达后会触发信号处理函数,在信号处理函数执行过程中,如果该信号再次产生是不会打断当前信号处理函数的,但是如果有其他信号进行了投递这个会打断当前信号处理函数的。sigaction的sa_flags成员有一个值就是用来控制这个行为的,如果值为SA_NODEFER(参考上文中对sa_flags的解释)表明在执行信号处理函数时是可以被相同信号打断的。这很容易造成递归死循环。
信号处理函数一般要遵从上文中提到的设计,处理函数中只对一些全局变量进行处理,然后主程序周期性的检查,那么这个全局变量的类型需要考量两点:
编译器一般会对变量的读写进行缓存,将刚写入的变量值放在寄存器中,下次读的时候直接从寄存器中读取,这个设计适用于gcc可以理解代码的上下文,但是信号处理函数是任何时候都有可能触发的,gcc没办法知道什么时候触发信号处理函数,如果某一时刻主程序对全局变量发生了写入,但是写入的值还没来得及回写内存,然后触发信号处理函数,编译器并不知道要从寄存器中读该全局变量的值(因为没上下文),所以会直接从内存中读,这样读到的值就是一个脏值了。为了避免这个优化,在定义全局变量的时候会加上volatile关键字。
全局变量的读写可能不止一条机器指令,如果在操作全局变量的中途被打断,那么在信号处理函数中再次操作这个全局变量就很有可能造成该全局变量最终值是一个未定义的值。所以sig_atomic_t的类型其实就是一个原子类型,通过阅读源码,发现这个数据类型其实就是一个int类型,代码如下,主要原因是因为在x86_64架构的CPU下,对于8、16、32、64这样的对齐大小对齐的数据类型,其参考是原子的,所以sig_atomic_t就是一个对int类型的别名。
typedef int __sig_atomic_t;
typedef __sig_atomic_t sig_atomic_t;
大多数情况下信号处理函数都是处理完一些事情后就回到了主程序继续执行,或者是做一些资源的释放和清理,接着就退出了程序, 除此之外其实还有更多的选择。
对于1、2、4我觉得都是可以理解的,问题不大,重点是第三个,非本地跳转,跳转到另外一个地方后,栈会解旋转,但是有一些点还需要探讨,比如说默认情况下当一个信号开始触发信号处理函数时,默认会讲该信号加入到阻塞的信号集中,这样信号处理函数就不会被相同信号打断了,如果使用非本地跳转的化,带来的问题就是这个阻塞的信号集需要被恢复,早期的BSD实现时会将阻塞的信号恢复的,但是Linux是遵循System V的实现,是不会将阻塞的信号进行恢复的,鉴于这个行为在不通的平台其实现不同,这将有损于可移植性,POSIX通过定义了一堆新的函数来规范非本地跳转的行为,sigsetjmp和siglongjmp,其函数原型如下:
#include
int sigsetjmp(sigjmp_buf env, int savesigs); // savesigs非0时,会将当前的信号掩码保存
void siglongjmp(sigjmp_buf env, int val); // 恢复sigsetjmp保存的信号掩码
这个问题可能大多数人都只是知道要怎么去处理,但是却不知道为什么会发生中断? 如果一个系统调用在执行的时候,一个信号到来,这个时候需要切换到信号处理函数中执行,但是系统调用是在内核态中执行的,如果现在执行以内核态来运行用户态编写的信号处理函数,这会给内核带来安全风险,所以理论上这里应该要切换运行态岛用户态,既然要切换那么久需要保存当前的状态,但是这就涉及到一个问题就是中间状态,这个时候执行信号处理函数可能看到一个中间的无效状态,导致程序行为时未定义的,还有锁的问题,资源占用等问题。所以这种切换状态的方式是不可行的,linux使用了一种更加灵活的方式来处理,当系统调用在执行的过程中有信号到来则中断当前的系统调用,然后返回-1,并设置错误码为EINTR,交给用户来重新再运行该系统调用,更多的细节见Interruption of system calls when a signal is caught chromium的基础库中就提供了一种比较方便的重启系统调用的宏。
#define HANDLE_EINTR(x) ({ \
decltype(x) eintr_wrapper_result; \
do { \
eintr_wrapper_result = (x); \
} while (eintr_wrapper_result == -1 && errno == EINTR); \
eintr_wrapper_result; \
})
除了上面这个HANDLE_EINTR外,GNU C库还提供了一个非标准的宏,TEMP_FAILURE_RETRY,需要定义特性测试宏_GNU_SOURCE,在unistd头文件中还有另外一个宏可以起到相同的作用NO_EINTR,最后一个方法就是使用sigaction中的SA_RESTART标志,通过设置该标志后,但是很不幸的是这个标志并不能处理所有系统调用的自重启的问题。
我们都知道进程的栈空间大小是有限制的,如果某一时刻栈空间增长到最大值,然后触发了信号处理函数,但是栈已经达到了最大值了,无法为信号处理函数创建栈帧,也就没有办法调用信号处理函数了,为此可以借助信号备选栈来创建一个额外的堆栈,用于执行信号处理函数,信号备选栈的创建过程如下 :
#include
int sigaltstack(const stack_t *ss, stack_t *oss);
typedef struct {
void *ss_sp; /* Base address of stack */
int ss_flags; /* Flags */
size_t ss_size; /* Number of bytes in stack */
} stack_t;
大多数情况下这个信号备选栈的用途还是比较有限的,只要重度依赖信号处理函数,对信号处理函数的执行成功与否比较敏感的程序才会考虑使用备选栈,比如说google的breakpad,重度依赖信号处理函数的,它通过注册新号处理函数的方式将要程序的coredump行为捕获,然后产生minidump,为了保证信号处理函数成功被执行,breakpad就使用了信号备选栈的方式来执行。下面通过模拟堆栈溢出,然后通过备选栈的方式顺利执行信号处理函数,代码如下:
#include
#include
void handler(int sig)
{
write(2, "stack overflow\n", 15);
_exit(1);
}
unsigned infinite_recursion(unsigned x) {
return infinite_recursion(x)+1;
}
int main()
{
static char stack[SIGSTKSZ];
stack_t ss = {
.ss_size = SIGSTKSZ,
.ss_sp = stack,
};
struct sigaction sa = {
.sa_handler = handler,
.sa_flags = SA_ONSTACK
};
sigaltstack(&ss, 0);
sigfillset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, 0);
infinite_recursion(0);
}
传统的信号处理函数只会传递一个信号值,也不能自定义传递参数,通过设置sigaction的sa_flags为SA_SIGINFO就可以获取到信号的一些附加信息,设置了SA_SIGINFO后,信号处理函数的原型就变成了如下:
void (*sa_sigaction)(int, siginfo_t *, void *);
siginfo_t {
int si_signo; /* Signal number */
int si_errno; /* An errno value */
int si_code; /* Signal code */
int si_trapno; /* Trap number that caused hardware-generated signal (unused on most architectures) */
pid_t si_pid; /* Sending process ID */
uid_t si_uid; /* Real user ID of sending process */
int si_status; /* Exit value or signal */
clock_t si_utime; /* User time consumed */
clock_t si_stime; /* System time consumed */
sigval_t si_value; /* Signal value */
int si_int; /* POSIX.1b signal */
void *si_ptr; /* POSIX.1b signal */
int si_overrun; /* Timer overrun count; POSIX.1b timers */
int si_timerid; /* Timer ID; POSIX.1b timers */
void *si_addr; /* Memory location which caused fault */
long si_band; /* Band event (was int in glibc 2.3.2 and earlier) */
int si_fd; /* File descriptor */
short si_addr_lsb; /* Least significant bit of address (since Linux 2.6.32) */
}
使用了新的信号处理函数后,带来了几点变化,第一个就是可以传递一个siginfo_t的结构,该结构可以携带更多的信息,第二个是一个void* 参数,是一个指向ucontext_t类型的结构,该结构提供了所谓的上下文信息,用来描述调用信号处理器函数前的进程上下文(可以用来实现协程,目前在信号处理函数中没有使用,对应的设置和获取进程上下文的函数getcontext和setcontext因为可移植性问题已经从POSIX中废弃)
一些信号的默认处理方式就是让进程产生coredump文件,该文件就是进程运行时的内存镜像,除了可以通过信号来产生外,还有通过执行gcore命令产生,默认情况下会将全部的内存映射区域都写入到核心存储文件中,通过/proc/PID/coredump_filter可以控制对哪些内存映射区域写入,更详细的内容可以man core来查询,最后就是核心存储文件产生的条件,下面列出了不会产生核心转储文件的情况:
产生的core文件其名称还可以通过/proc/sys/kernel/core_pattern进行控制。
SIGKILL可以用来终止一个进程,SIGSTOP则是可以停止一个进程,二者的默认行为都是无法被改变的,一个停止的进程通过发送SIGCONT可以使得该信号恢复执行,这两个信号在大多数情况下都可以立即终止一个进程或者是停止一个进程,但是有一种情况除外,就是内核处于TASK_UNINTERRUPTIBLE 状态时,也就是睡眠状态,Linux上有两类睡眠状态,一类就是TASK_INTERRUPTIBLE,这个状态下进程时可以被中断的,处于这个状态下的进程一般时等待终端输入、等待数据写入当前的空管道等,通过PS查询的时候,显示为S。另外一种就是上文说道的TASK_UNINTERRUPTIBLE,不可中断的睡眠,这类进程一般都是在等待某些特定类型的事件,比如磁盘IO的完成,处于这类状态的进程时无法被信号终止的,通过PS查询的时候,显示为D,极端情况下这类进程可能会因为磁盘故障等原因,永远无法被终止,这个时候就只能通过重启机器来消灭这类进程了,在linux 2.6.25开始Linux加入了第三种状态TASK_KILLABLE,这个状态和TASK_UNINTERRUPTIBLE类似,但是却可以被致命信号唤醒。通过使用该状态可以避免因为进程挂起处于TASK_UNINTERRUPTIBLE状态而重启系统的情况。
当痛过sigprocmask阻塞信号的时候,在此期间产生的信号都会变成待决信号,一旦阻塞信号被恢复,那么所有的待决信号都会被投递,而投递的顺序则取决于具体的实现。下面这个例子演示了信号的投递顺序。TODO
#include
#include
#include
#include
void handler(int sig) {
printf("signal: %d\n", sig);
}
int main()
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set, 1);
sigaddset(&set, 2);
sigaddset(&set, 3);
sigaddset(&set, 4);
struct sigaction sa = {0};
sa.sa_handler = handler;
sigaction(1, &sa, NULL);
sigaction(2, &sa, NULL);
sigaction(3, &sa, NULL);
sigaction(4, &sa, NULL);
sigprocmask(SIG_BLOCK, &set, NULL);
sleep(60);
printf("remove block signal\n");
sigprocmask(SIG_UNBLOCK, &set, NULL);
sleep(10);
return 0;
}
开启另外一个终端,使用kill向该进程发送多个信号,
实时信号是为了弥补标准信号的投递顺序未定义、信号不排队会丢失等问题的,相比于标准信号,实时信号具备如下优势:
对于排队的信号,是有一个上限的,这个上限值可以通过查看RLIMIT_SIGPENDING资源限制的值,至于等待某一个进程的实时信号数量,可以从Linux专有文件夹/proc/PID/status中的SigQ字段读取
通sigqueue可以给实时信号发送伴随数据
// 发送端
#include
int main(int argc, char *argv[]) {
union sigval sa;
sa.sival_int = std::stoi(argv[2]);
sigqueue(std::stoi(argv[1]), 34, sa);
return 0;
}
// 消费端
#include
#include
#include
void handler_real_signal(int signum, siginfo_t* v, void* ucontext) {
std::cout << v->si_value.sival_int << std::endl;
}
int main() {
struct sigaction sa;
sa.sa_sigaction = handler_real_signal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO;
sigaction(34, &sa, nullptr);
sleep(60);
return 0;
}
实时信号的编号是从32~63,RTSIG_MAX常量代表了实时信号的数量,SIGRTMIN和SIGRTMAX则表示的是实时信号的最小值和最大值。
我们都知道信号是异步到来的,程序在运行的过程中时刻都有可能被信号打断,对于一些在运行关键任务的程序来说这可能是一个噩梦,通过sigprocmask或者是sigaction的sa_mask可以屏蔽信号。等关键任务执行完成后可能需要等待信号到来,然后开始处理信号,对于这样的场景可以通过sigprocmask解除屏蔽信号后,接着调用pause来等待信号到来。代码如下:
#include
#include
#include
#include
void handler(int signum) {
printf("Hello World\n");
}
int main() {
sigset_t prevMask, intMask;
struct sigaction sa;
sigemptyset(&intMask);
sigaddset(&intMask, SIGINT);
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sa.sa_handler = handler;
if (sigaction(SIGINT, &sa, nullptr) == -1) {
perror("sigaction:");
exit(EXIT_FAILURE);
}
if (sigprocmask(SIG_BLOCK, &intMask, &prevMask) == -1) {
perror("sigprocmask:");
exit(EXIT_FAILURE);
}
// deal with critical section
if (sigprocmask(SIG_SETMASK, &prevMask, nullptr) == -1) {
perror("sigprocmask:");
exit(EXIT_FAILURE);
}
// 信号如果在此刻到来,这就会导致bug
pause();
return 0;
}
很显然sigprocmask解除信号屏蔽和pause等待信号这两步并不是原子的,所以可能会导致潜在的bug,信号可能在pause之前达到,导致pause一致在等待信号。为了解决这个问题Linux提供了sigsuspend,将解除信号屏蔽和等待信号变成了原子的,代码如下:
if (sigprocmask(SIG_SETMASK, &prevMask, nullptr) == -1) {
perror("sigprocmask:");
exit(EXIT_FAILURE);
}
// 信号如果在此刻到来,这就会导致bug
pause();
return 0;
替换成如下:
sigsuspend(&prevMask);
到此为止我介绍了两种等待信号的方式,一种就是pause,另外一种就是sigsuspend,但是这两种等待信号的方式都很原始,只是知道有信号到来了,具体是什么信号是不知道的,还需要依靠信号处理函数去处理发生的信号。如果把信号比做一种消息的话,我希望可以同步的等待接收这个消息,然后同步的去处理这个消息,而不是靠信号处理函数打断当前执行流异步的处理。Linux提供了sigwaitinfo相应的还有一个sigtimedwait,前者是永久的等待信号,后者是带有超时功能的等待。
#include
#include
#include
int main() {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, nullptr);
siginfo_t sv;
int signum = sigwaitinfo(&set, &sv);
printf("signal number:%d pid: %d, uid: %d\n",signum, sv.si_pid, sv.si_uid);
return 0;
}
通过sigwaitinfo来等待信号已经基本算满足了需求,但是对于一个网络程序来说,信号、网络IO、定时器等都属于事件,理想情况下应该将这些事件统一来处理,使用fd来管理这些事件这个在Linux下算是一种共识了,网络IO自然不用说,定时器可以通过timerfd_create来创建一个fd然后和一个定时器关联即可。而信号的化早期的做法是创建一个管道fd,然后在信号处理函数中往这个fd写入信号值,这样所有的事件就都可以使用fd来统一管理了,在Linux 2.6.27的时候提供了一个原生的解决方案就是signalfd,下面是一个简单的示例代码:
#include
#include
#include
#include
#include
#define handle_error(msg) \
do { perror(msg); exit(EXIT_FAILURE); } while (0)
int main(int argc, char *argv[]) {
sigset_t mask;
int sfd;
struct signalfd_siginfo fdsi;
ssize_t s;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGQUIT);
/* Block signals so that they aren't handled
according to their default dispositions */
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1)
handle_error("sigprocmask");
sfd = signalfd(-1, &mask, 0);
if (sfd == -1)
handle_error("signalfd");
for (;;) {
s = read(sfd, &fdsi, sizeof(struct signalfd_siginfo));
if (s != sizeof(struct signalfd_siginfo))
handle_error("read");
if (fdsi.ssi_signo == SIGINT) {
printf("Got SIGINT\n");
} else if (fdsi.ssi_signo == SIGQUIT) {
printf("Got SIGQUIT\n");
exit(EXIT_SUCCESS);
} else {
printf("Read unexpected signal\n");
}
}
}