Linux 信号FAQ
可重入、线程安全以及异步信号安全的区别?
参考可重入、线程安全和异步信号安全,需要强调的是异步信号安全,这个概念知道的人不多,平常大家在编写代码的时候也很少考虑这个因素,也不清楚哪些函数是异步信号安全的,哪些不是,典型的像printf
就不是异步信号安全的,内部会加锁,但是平时很多人都喜欢在信号处理函数中调用。大多数情况下都不会出现问题的,所以让使用者错误的认为这是正确的写法。第二个需要注意的是可重入的概念,Linux有很不少系统调用的实现都是不可重入的,会将结果保存在内部的静态数据存储中,同时这类系统调用也提供了可重入版本的实现,其函数名就是尾部添加_r
来标识。
信号的内部实现是怎么样的?
如上图所示一个进程/线程就是一个task_struct结构,该结构包含了属于这个进程/线程的阻塞信号集、pending的信号等,所有投递到该进程/线程的信号都会通过双向链表组织在一起,链表的元素是sigqueue,所有的信号对应的信号处理函数存放在sighand_struct中的一个类型为k_sigaction数组,每次程序由核心态切换到用户态时,内核都会发起信号处理,执行信号处理程序的时候为了避免对内核产生影响,所以使用的是用户栈,还可以自定义信号处理的备用栈。
信号处理函数是每次程序从核心态切换到用户态的时候,内核才会负责发起信号处理,也就是说信号处理的时机有以下两种:
- 进程在当前时间片用完后,获得了新的时间片时(会发生内核态到用户态的切换)
- 系统调用执行完成时(信号的传递可能会引起正在阻塞的系统调用过早完成)
如何查看一个进程当前等待的、阻塞的、忽略的、捕获的信号?
通过查看/proc/PID/status
文件,该文件中有几个字端的值,这些值按照十六进制的形式显示,最低的有效位表示信号1,相邻的左边一位代表信号2,依次类推,例如下面这几个数值:
1 2 3 4 5 6 |
|
信号的默认处理方式有哪些?
当信号到达的时候,默认情况下信号有如下几种处理方式:
- 忽略信号,内核直接将信号丢弃,不对进程产生任何影响
- 终止进程,是一种异常的终止方式,和调用exit而发生的终止不同
- 产生核心存储文件,同时进程终止
- 停止进程,暂停进程的运行
- 恢复之前暂停的进程
- 执行用户自定义的信号处理器
signal和sigaction区别?
这两者都可以用来改变信号处置,signal很原始,提供的接口也比较简单,而sigaction提供了signal所不具备的功能。为了兼容,signal系统调用仍然保存,但是glibc是使用sigaction实现了signal的功能。sigaction同时支持两种形式的信号处理,通过不用的flags区分,通过设置不同的flags可以得到不同的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
通过Kill来检查进程是否存在?
kill系统调用可以用来向指定进程发送信号,如果指定的信号是0的时候,kill仅会进行错误的检查,查看是否可以想目标进程发送信号,而这一特点恰好可以用来检测特定进程ID所对应的进程是否存在,如果不存在那么kill调用失败,并且errno设置为ESRCH
如何打印信号枚举值对应的信号描述?
1 2 3 4 5 6 7 8 9 |
|
信号集的内部实现?
信号集是一种用来表示一系列信号集合的数据结构,使用sigset_t来表示,它的底层存储类型其实只是一个unsigned long类型,如下:
1 |
|
如何阻塞信号,阻塞的信号在解除阻塞后是否会投递?
阻塞信号的实现不难,通过上文中对信号内部实现的分析可知,通过将要阻塞的信号放到task_struct结构中的blocked成员中,那么在信号的投递时会先查看下要投递的信号是否在阻塞信号集中,如果在就停止投递,否则就触发对应的信号处理,通过sigprocmask可以设置当前进程的阻塞信号集,对应到内核的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
通过sigprocmask设置阻塞的信号集存在一个竞态,如果想在设置信号处理函数的同时再设置阻塞的信号集,那么这需要先调用signal/sigaction,然后再调用sigprocmask,在设置信号处理函数和调用sigprocmask之间存在一个间隙,如果在这个间隙期间后信号投递,那么就没有起到阻塞信号的作用了。为此sigaction的sa_mask成员可以用来设置阻塞信号集,这使得设置信号处理函数的同时就可以设置阻塞信号集。
另外一个问题就是被阻塞的信号在等待解除阻塞后是否会投递到进程进行处理?信号被阻塞后就会变成待决信号,并通过链表链接起来,task_struct结构中的pending成员就是链表头,如果一个信号发送多次,linux是不保证投递相同次数的,只会保存一次,也就是非实时,不对信号排队。其中SIGKILL和SIGSTOP是不能被阻塞的。
如何知道当前哪些信号被阻塞了?
说白了这里就是去查询待决信号的链表也就是task_struct结构中的pending成员,将里面的信号放到信号集中返回即可。对应到内核实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
在用户态通过sigpending函数就可以查询当前哪些被阻塞的信号是未决的(也就是已经投递到进程了,但是因为被屏蔽了还没有被处理,也就是保存在进程的pending成员中)
信号处理函数该怎么设计,需要注意什么?
信号处理函数和普通函数是有一些区别的,因为这个函数是异步被执行的,所以需要考虑异步信号安全的问题,在这个函数中没办法使用一些非异步信号安全的函数,为此编写信号处理函数一般要遵从一些设计,两种常见的设计如下:
- 信号处理函数设置全局性变量并退出,主程序周期性检查,一旦置位就立即采取动作,或者信号处理函数通过忘管道中写 入一个字节来通知主程序。
- 信号处理器函数执行某种类型的清理动作,然后终止进程或者执行非本地跳转,将栈解开并将控制权返回到主程序的预定位置。
信号处理函数在执行过程中是否会被信号再次打断?
一个信号到达后会触发信号处理函数,在信号处理函数执行过程中,如果该信号再次产生是不会打断当前信号处理函数的,但是如果有其他信号进行了投递这个会打断当前信号处理函数的。sigaction的sa_flags成员有一个值就是用来控制这个行为的,如果值为SA_NODEFER(参考上文中对sa_flags的解释)表明在执行信号处理函数时是可以被相同信号打断的。这很容易造成递归死循环。
sig_atomic_t数据类型的作用?
信号处理函数一般要遵从上文中提到的设计,处理函数中只对一些全局变量进行处理,然后主程序周期性的检查,那么这个全局变量的类型需要考量两点:
-
编译器一般会对变量的读写进行缓存,将刚写入的变量值放在寄存器中,下次读的时候直接从寄存器中读取,这个设计适用于gcc可以理解代码的上下文,但是信号处理函数是任何时候都有可能触发的,gcc没办法知道什么时候触发信号处理函数,如果某一时刻主程序对全局变量发生了写入,但是写入的值还没来得及回写内存,然后触发信号处理函数,编译器并不知道要从寄存器中读该全局变量的值(因为没上下文),所以会直接从内存中读,这样读到的值就是一个脏值了。为了避免这个优化,在定义全局变量的时候会加上volatile关键字。
-
全局变量的读写可能不止一条机器指令,如果在操作全局变量的中途被打断,那么在信号处理函数中再次操作这个全局变量就很有可能造成该全局变量最终值是一个未定义的值。所以sig_atomic_t的类型其实就是一个原子类型,通过阅读源码,发现这个数据类型其实就是一个int类型,代码如下,主要原因是因为在x86_64架构的CPU下,对于8、16、32、64这样的对齐大小对齐的数据类型,其参考是原子的,所以sig_atomic_t就是一个对int类型的别名。
1 2 |
|
终止信号处理器函数的方法有哪些?
大多数情况下信号处理函数都是处理完一些事情后就回到了主程序继续执行,或者是做一些资源的释放和清理,接着就退出了程序, 除此之外其实还有更多的选择。
- 使用_exit终止进程,处理器函数可以事先做一些清理工作,但是这里注意不能使用exit来终止,因为它不在异步信号安全函数的列表中。
- 使用kill发送信号来杀掉进程
- 在信号处理函数中执行非本地跳转
- 使用abort终止进程,并产生核心转储
对于1、2、4我觉得都是可以理解的,问题不大,重点是第三个,非本地跳转,跳转到另外一个地方后,栈会解旋转,但是有一些点还需要探讨,比如说默认情况下当一个信号开始触发信号处理函数时,默认会讲该信号加入到阻塞的信号集中,这样信号处理函数就不会被相同信号打断了,如果使用非本地跳转的化,带来的问题就是这个阻塞的信号集需要被恢复,早期的BSD实现时会将阻塞的信号恢复的,但是Linux是遵循System V的实现,是不会将阻塞的信号进行恢复的,鉴于这个行为在不通的平台其实现不同,这将有损于可移植性,POSIX通过定义了一堆新的函数来规范非本地跳转的行为,sigsetjmp和siglongjmp,其函数原型如下:
1 2 3 |
|
为什么系统调用会中断和重启?
这个问题可能大多数人都只是知道要怎么去处理,但是却不知道为什么会发生中断? 如果一个系统调用在执行的时候,一个信号到来,这个时候需要切换到信号处理函数中执行,但是系统调用是在内核态中执行的,如果现在执行以内核态来运行用户态编写的信号处理函数,这会给内核带来安全风险,所以理论上这里应该要切换运行态岛用户态,既然要切换那么久需要保存当前的状态,但是这就涉及到一个问题就是中间状态,这个时候执行信号处理函数可能看到一个中间的无效状态,导致程序行为时未定义的,还有锁的问题,资源占用等问题。所以这种切换状态的方式是不可行的,linux使用了一种更加灵活的方式来处理,当系统调用在执行的过程中有信号到来则中断当前的系统调用,然后返回-1,并设置错误码为EINTR,交给用户来重新再运行该系统调用,更多的细节见Interruption of system calls when a signal is caught chromium的基础库中就提供了一种比较方便的重启系统调用的宏。
1 2 3 4 5 6 7 |
|
除了上面这个HANDLE_EINTR外,GNU C库还提供了一个非标准的宏,TEMP_FAILURE_RETRY,需要定义特性测试宏_GNU_SOURCE,在unistd头文件中还有另外一个宏可以起到相同的作用NO_EINTR,最后一个方法就是使用sigaction中的SA_RESTART标志,通过设置该标志后,但是很不幸的是这个标志并不能处理所有系统调用的自重启的问题。
信号备选栈是什么、如何使用?
我们都知道进程的栈空间大小是有限制的,如果某一时刻栈空间增长到最大值,然后触发了信号处理函数,但是栈已经达到了最大值了,无法为信号处理函数创建栈帧,也就没有办法调用信号处理函数了,为此可以借助信号备选栈来创建一个额外的堆栈,用于执行信号处理函数,信号备选栈的创建过程如下 :
- 分配一块被称为"备选信号栈"的内存区域,作为信号处理函数的栈帧
- 调用sigaltstack,告之内核备选栈的存在(也可以将已创建的备选信号栈的相关信息返回)
- 在创建信号处理函数时指定SA_ONSTACK,也就是通知内核在备选信号栈上为处理器函数创建栈帧。
1 2 3 4 5 6 7 8 |
|
大多数情况下这个信号备选栈的用途还是比较有限的,只要重度依赖信号处理函数,对信号处理函数的执行成功与否比较敏感的程序才会考虑使用备选栈,比如说google的breakpad,重度依赖信号处理函数的,它通过注册新号处理函数的方式将要程序的coredump行为捕获,然后产生minidump,为了保证信号处理函数成功被执行,breakpad就使用了信号备选栈的方式来执行。下面通过模拟堆栈溢出,然后通过备选栈的方式顺利执行信号处理函数,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
如何通过信号传递更多的信息?
传统的信号处理函数只会传递一个信号值,也不能自定义传递参数,通过设置sigaction的sa_flags为SA_SIGINFO就可以获取到信号的一些附加信息,设置了SA_SIGINFO后,信号处理函数的原型就变成了如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
使用了新的信号处理函数后,带来了几点变化,第一个就是可以传递一个siginfo_t的结构,该结构可以携带更多的信息,第二个是一个void 参数,是一个指向ucontext_t类型的结构,该结构提供了所谓的上下文信息,用来描述调用信号处理器函数前的进程上下文(可以用来实现协程,目前在信号处理函数中没有使用,对应的设置和获取进程上下文的函数getcontext和setcontext*因为可移植性问题已经从POSIX中废弃)
信号和核心转储文件的关系?
一些信号的默认处理方式就是让进程产生coredump文件,该文件就是进程运行时的内存镜像,除了可以通过信号来产生外,还有通过执行gcore命令产生,默认情况下会将全部的内存映射区域都写入到核心存储文件中,通过/proc/PID/coredump_filter可以控制对哪些内存映射区域写入,更详细的内容可以man core来查询,最后就是核心存储文件产生的条件,下面列出了不会产生核心转储文件的情况:
- 进程对核心转储文件没有写权限
- 存在一个同名、可写的普通文件,但是指向该文件的(硬)链接数超过一个(也就是无法删除)
- 将要创建的核心转储文件所在目录并不存在
- 进程的核心存储文件大小限制为0
- 对进程正在执行的二进制可执行文件没有读权限
- 磁盘空间满了、inode资源耗尽了、达到磁盘配额限制、当前目录是只读挂载的文件系统
- Set-user-ID程序在由非文件属主(或属组)执行时,不会产生核心转储文件(通过prctl和PR_SET_DUMPABLE可以控制这个行为,还可以通过/proc/sys/fs/suid_dumpable进行系统级的控制)
产生的core文件其名称还可以通过/proc/sys/kernel/core_pattern进行控制。
SIGKILL和SIGSTOP信号和其他信号不同的地方?
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
实时信号和标准信号的区别?
实时信号是为了弥补标准信号的投递顺序未定义、信号不排队会丢失等问题的,相比于标准信号,实时信号具备如下优势:
- 实时信号的信号范围有所扩大,可供应用程序自定义的目的,而标准信号中用于自定义的只有SIGUSER1和SIGUSER2。
- 实时信号采取的是队列化管理,如果某一个信号多次发送给一个进程,那么该进程会多次收到这个信号,而标准信号才会丢失,只会接收到一次信号(不过队列是有大小限制的)。
- 当发送一个实时信号时,可为信号指定伴随数据,供接收进程的信号处理器使用(标准信号目前也是可以的)。
- 不同的实时信号的传递顺序是有保障的,信号的编号越小优先级越高,而标准信号这个行为是未定义的,取决于具体的实现。
对于排队的信号,是有一个上限的,这个上限值可以通过查看RLIMIT_SIGPENDING资源限制的值,至于等待某一个进程的实时信号数量,可以从Linux专有文件夹/proc/PID/status中的SigQ字段读取
通sigqueue可以给实时信号发送伴随数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
实时信号的编号是从32~63,RTSIG_MAX常量代表了实时信号的数量,SIGRTMIN和SIGRTMAX则表示的是实时信号的最小值和最大值。
如何主动等待信号到来?
我们都知道信号是异步到来的,程序在运行的过程中时刻都有可能被信号打断,对于一些在运行关键任务的程序来说这可能是一个噩梦,通过sigprocmask或者是sigaction的sa_mask可以屏蔽信号。等关键任务执行完成后可能需要等待信号到来,然后开始处理信号,对于这样的场景可以通过sigprocmask解除屏蔽信号后,接着调用pause来等待信号到来。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
很显然sigprocmask解除信号屏蔽和pause等待信号这两步并不是原子的,所以可能会导致潜在的bug,信号可能在pause之前达到,导致pause一致在等待信号。为了解决这个问题Linux提供了sigsuspend,将解除信号屏蔽和等待信号变成了原子的,代码如下:
1 2 3 4 5 6 7 8 9 10 |
|
到此为止我介绍了两种等待信号的方式,一种就是pause,另外一种就是sigsuspend,但是这两种等待信号的方式都很原始,只是知道有信号到来了,具体是什么信号是不知道的,还需要依靠信号处理函数去处理发生的信号。如果把信号比做一种消息的话,我希望可以同步的等待接收这个消息,然后同步的去处理这个消息,而不是靠信号处理函数打断当前执行流异步的处理。Linux提供了sigwaitinfo相应的还有一个sigtimedwait,前者是永久的等待信号,后者是带有超时功能的等待。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
通过sigwaitinfo来等待信号已经基本算满足了需求,但是对于一个网络程序来说,信号、网络IO、定时器等都属于事件,理想情况下应该将这些事件统一来处理,使用fd来管理这些事件这个在Linux下算是一种共识了,网络IO自然不用说,定时器可以通过timerfd_create来创建一个fd然后和一个定时器关联即可。而信号的化早期的做法是创建一个管道fd,然后在信号处理函数中往这个fd写入信号值,这样所有的事件就都可以使用fd来统一管理了,在Linux 2.6.27的时候提供了一个原生的解决方案就是signalfd,下面是一个简单的示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|