linux信号处理实践
本篇总结一下信号的必要知识,以及实际场景下的处理,主要参考UNIX环境高级编程(第三版)
要先从子进程fork开始总结,因为信号和子进程息息相关
wait系统调用
根据书中8.3节 fork以及8.6节 wait
- fork出子进程以后,假如子进程退出,需要用wait或者waitpid检查子进程的退出状态,否则子进程会进入僵死状态,直到父进程退出,被系统的1号进程接管进行wait才会释放。
- 父进程可以直接wait等待,也可以啥也不做,在SIGCHLD信号处理函数中,再进行wait调用
- waitpid比wait多一些功能,最主要的就是可以在第二个参数传入WNOHANG进行非阻塞调用
根据书中10.5节,wait系统调用是默认自带重启动的,但是Linux的man 2 wait
显示EINTR的含义是
EINTR:WNOHANG was not set and an unblocked signal or a SIGCHLD was caught; see signal(7).
在下面重启动系统调用中会提到Linux什么情况下才会重启动wait
1 | //while (waitpid(pid, &status, 0) < 0) { |
WEXITSTATUS
wait和waitpid的status可以用来判断子进程的退出状态
1 |
|
WEXITSTATUS,实现上是
1 | #define WEXITSTATUS(status) (((status) & 0xff00) >> 8) |
实际上就是对status除以256
signal vs sigaction
引用https://stackoverflow.com/questions/231912/what-is-the-difference-between-sigaction-and-signal
Use
sigaction()
unless you've got very compelling reasons not to do so.尽量使用
sigaction()
,除非你有非常令人信服的理由不这样做。The
signal()
interface has antiquity (and hence availability) in its favour, and it is defined in the C standard. Nevertheless, it has a number of undesirable characteristics thatsigaction()
avoids - unless you use the flags explicitly added tosigaction()
to allow it to faithfully simulate the oldsignal()
behaviour.
signal()
接口因为历史悠久(因此在可用性上有优势),并且在 C 标准中有所定义。然而,它有许多不可取的特性,而sigaction()
避免了这些问题,除非你使用明确添加到sigaction()
中的标志来让它忠实地模拟旧的signal()
行为。
The
signal()
function does not (necessarily) block other signals from arriving while the current handler is executing;sigaction()
can block other signals until the current handler returns.
signal()
函数在当前处理程序执行期间不(必然)阻塞其他信号到达;而sigaction()
可以阻塞其他信号,直到当前处理程序返回。The
signal()
function (usually) resets the signal action back toSIG_DFL
(default) for almost all signals. This means that thesignal()
handler must reinstall itself as its first action. It also opens up a window of vulnerability between the time when the signal is detected and the handler is reinstalled during which if a second instance of the signal arrives, the default behaviour (usually terminate, sometimes with prejudice - aka core dump) occurs.
signal()
函数(通常)会将信号处理动作重置为SIG_DFL
(默认值),这适用于几乎所有信号。这意味着signal()
处理程序必须作为其第一个动作重新安装自身。这也导致了一个脆弱的时机窗口,即在信号被检测到并且处理程序被重新安装期间,如果有第二个该信号实例到达,默认行为(通常是终止,有时是以核心转储的方式)会发生。The exact behaviour of
signal()
varies between systems — and the standards permit those variations.
signal()
的确切行为在不同的系统之间有所不同,而且标准允许这些变化。
阻塞当前信号处理程序
sigaction最大的好处是可以阻塞当前的信号处理程序,以这个例子来说:
1 |
|
sigaction可以在sa_mask中指定屏蔽哪些信号,当使用如下顺序kill信号
1 | kill -s SIGUSR1 xxx |
会打印
1 | Handled signal 10 with sigaction (1 times) |
而使用注释掉的signal来设置信号处理程序,会打印
1 | Handled signal 10 with sigaction (1 times) |
也就是signal只会阻塞相同信号,但是不会阻塞不同信号进入信号处理程序
重启动系统调用
另外,signal会默认重启动系统调用,而sigaction则不会,例如wait
1 |
|
运行后kill -s SIGUSR1 xxx
,会打印
1 | waiting |
而使用注释掉的signal,则只会打印
1 | waiting |
说明wait系统调用被自动重启了
标准化问题
signal最大的问题就是没有标准化,引用man signal
的DESCRIPTION对传入的函数信号处理程序是这么描述的
If the disposition is set to a function, then first either the disposition is reset to SIG_DFL, or the signal is blocked (see Portability below), and then handler is called with argument signum. If invocation of the handler caused the signal to be blocked, then the signal is unblocked upon return from the han‐ dler.
如果设置为一个函数,那么首先要么将信号处理设置为
SIG_DFL
,要么该信号被阻塞(具体行为请参考可移植性部分),接着调用处理程序,并传递信号编号signum
作为参数。如果处理程序调用导致信号被阻塞,则在处理程序返回后信号将被解除阻塞。
接着详细描述了可移植性的部分,以这一段话开头
The only portable use of signal() is to set a signal's disposition to SIG_DFL or SIG_IGN. The semantics when using signal() to establish a signal handler vary across systems (and POSIX.1 explicitly permits this variation); do not use it for this purpose.
signal()
的唯一可移植用法是将信号的处理设置为SIG_DFL
(默认)或SIG_IGN
(忽略),因为在为信号建立自定义处理程序时,signal()
的行为因系统不同而有差异,POSIX.1 明确允许这种变化。因此,不建议用signal()
来建立信号处理程序。
下面详细描述了原始UNIX上,触发信号处理程序时,会将该信号的信号处理程序设置回SIG_DFL的默认行为,并且不阻塞该信号,相当于sigaction的
1 | sa.sa_flags = SA_RESETHAND | SA_NODEFER; |
当希望为这个信号多次触发这个信号处理程序时,需要自己重新signal一下,但是由于该信号未被阻塞,在还未signal的时候有可能信号就进入了,大部分信号在这个时候会导致程序退出
在BSD系统上优化了这一点,触发信号处理程序时,不会将该信号的信号处理程序设置回SIG_DFL的默认行为,并且阻塞该信号,但是会自动重启某些系统调用,相当于sigaction的
1 | sa.sa_flags = SA_RESTART; |
在Linux上,上述两种都有可能,得看是哪个版本,或者有没有_BSD_SOURCE
宏
根据上面阻塞当前信号处理程序和重启动系统调用的测试可以发现,默认情况下Linux的signal应该是符合BSD系统语义的
也就是相当于使用sigaction的sa.sa_flags = SA_RESTART;
实践1:将程序的coredump改成gstack打印堆栈
这里有一个前提,因为调用的某些函数会影响到进程权限,必须fork子进程来做吐coredump的操作
一开始没有用参考资料的时候,写了一个特别丑陋的版本:
1 |
|
这里有几个问题:
首先没有考虑signal导致的信号重入信号处理函数的问题
根据标准化问题,Linux系统可以保证同一个信号进行排队,但是先来一个SIGSEGV,再来一个SIGABRT,就会fork两次,打印堆栈两次
由于在fork前父子进程共享同样的信号处理,第二个SIGABRT发生时
- 如果父进程接收到信号时已经把信号处理程序设回默认,那么会直接core掉
- 子进程在popen也会收到干扰core掉
为了在子进程中杀掉父进程时触发coredump,需要在父进程把信号处理程序设回默认,这涉及到一个先执行父进程signal语句,再执行子进程kill语句的顺序要求,系统调度器无法严格保证这个,需要进程间同步
为了避免进程间同步问题,需要把子进程的kill放到父进程去做
为了避免信号重入问题,初版方案是使用原子变量来加锁解决(这里不能用互斥锁,会导致死锁),最终方案使用sigaction
修改test和main代码如下:
1 | //略去这2个函数,上面已经实现 |
main函数第三行的while(1) sleep(1)
和test函数的sleep(100)
取消注释,就可以手动kill测试多个信号,是否阻塞了当前信号处理程序
实践2:用文件锁执行命令并且重定向到标准输出
用popen执行flock -xn /tmp/lock -c 'xxx'
的问题,是/tmp/lock
没有写入一个pid文件来协助debug,只能通过lslocks或者fuser来查看谁占用了文件锁
所以需要自己轮一个popen_with_cb,用于在子进程中传入文件锁设置,虽然这个造轮子理由有点勉强,但是是一个很好的理解原理的机会
正确实现system
popen就是加上输出重定向的system,所以先实现system,就可以实现popen
在书中的8.13和10.18中都介绍了system函数的实现
在8.13的简单版本中,只是fork了进程,然后在子进程中execl("/bin/sh", "sh", "-c", cmdstring, (char *)0)
由于执行的是/bin/sh
不需要考虑coredump问题,因此wait以后可以直接将status重置为-1
execl执行的新程序替换当前进程映像,并且不会返回,因此只有执行失败才会到_exit(127)
1 |
|
在10.18中,做了一些更新,主要是阻塞了SIGCHLD信号,以及屏蔽了SIGINT和SIGQUIT信号
当使用编译好的a.out
二进制调用system("/bin/ed")
时,进程关系如下,/bin/sh
本身忽略了大部分信号
flowchart LR
subgraph 后台进程组
A[登录 shell]
end
subgraph 前台进程组
B[a.out] --fork/exec--> C["/bin/sh"]
C --fork/exec--> D["/bin/ed"]
end
A -- fork/exec --> B
SIGINT和SIGQUIT
由于信号是父子进程都会进行响应,所以为了能让
/bin/ed
接收到SIGINT和SIGQUIT,而不是a.out
响应,system的时候必须屏蔽这两个SIGCHLD
SIGCHLD是只会发给自己的直接父进程的,那么阻塞SIGCHLD信号是为了什么呢?
参考https://stackoverflow.com/questions/59212770/treating-signals-correctly-inside-system
主要是为了防止调用system的进程,自己的SIGCHLD信号处理程序触发了以后,wait吃掉了本应该在waitpid中接收的子进程,示例如下:
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
int system(char *command) {
int status;
pid_t childPid = fork();
if (childPid == 0) {
execl("/bin/sh", "sh", "-c", command, (char*) NULL);
_exit(127); /* If reached this line than execl failed*/
} else if (childPid > 0) {
sleep(1);
if (waitpid(childPid, &status, 0) == -1)
return -1;
return status;
}
return -1;
}
void sigchld(int Sig){ wait(0);}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigchld;
sigaction(SIGCHLD, &sa, NULL);
int status = system("true");
printf("%d %s\n", status, strerror(errno));
}测试输出
-1 No child processes
也就是说,当SIGCHLD发生以后,打断了父进程的sleep,sigchld函数的wait调用接收了子进程
从信号处理函数出来以后,执行waitpid,会发现已经没有子进程了,导致报错
No child processes
也就是ECHILDPS:这里和SIGINT和SIGQUIT不一样,只能阻塞掉ECHILD信号,不能屏蔽,否则会造成system的调用者漏掉子进程信号处理
书中实现源码如下:
主要是屏蔽并阻塞信号以后,在fork出的子进程立刻恢复原信号处理,并且在子进程成功执行返回以后,恢复父进程的信号处理
1 |
|
这里使用了sigaction
,sigprocmask
的api,相比signal,可以保存当前的信号选项,用于进行信号恢复
实现popen_with_cb
首先新增管道用于重定向输出
1 | int pipefd[2]; |
然后主要修改在fork的子进程和父进程代码,在子进程中使用dup2重定向STDOUT_FILENO和STDERR_FILENO,并且在父进程中读取
1 | if (pid == 0) { // 子进程执行命令 |
完整代码如下:
1 |
|
当执行的./coredump.out
会coredump时,输出
1 | output: prestart |
/bin/sh的exit code
根据测试,sh的exit code会给fork出的子进程exit code再套一层,所以需要两次WEXITSTATUS,才可以获取到真的coredump情况:
先判断WEXITSTATUS(status) == EXIT_FAILURE,如果是的话说明还没执行
/bin/sh
就失败了,那么不需要第二次WEXITSTATUS然后更新status = WEXITSTATUS(status),得到
/bin/sh
它fork出的子进程的exit code:35584 / 256 - 128 = 11
flowchart LR
subgraph 后台进程组
A[登录 shell]
end
subgraph 前台进程组调用过程
B[a.out] --fork/exec--> C["/bin/sh"]
C --fork/exec--> D["./coredump"]
end
A -- fork/exec --> B
flowchart TB
subgraph 前台进程组exit过程
B["./coredump"] --exit 139--> C["/bin/sh"]
C --exit 35584--> D["./a.out"]
end
加上文件锁
文件锁的功能在书中14.3
1 | //上面一节实现的,略 |
执行输出:
1 | output: lock success, update hold lock pid:503595 |
此时另外一个抢锁失败进程会输出
1 | output: lock failed, cur hold lock pid:503595 |
参考资料
可重入函数
accept | fchmod | lseek | sendto | stat |
access | fchown | lstat | setgid | symlink |
aio_error | fcntl | mkdir | setpgid | sysconf |
aio_return | fdatasync | mkfifo | setsid | tcdrain |
aio_suspend | fork | open | setsockopt | tcflow |
alarm | fpathconf | pathconf | setuid | tcflush |
bind | fstat | pause | shutdown | tcgetattr |
cfgetispeed | fsync | pipe | sigaction | tcgetpgrp |
cfgetospeed | ftruncate | poll | sigaddset | tcsendbreak |
cfsetispeed | getegid | posix_trace_event | sigdelset | tcsetattr |
cfsetospeed | geteuid | pselect | sigemptyset | tcsetpgrp |
chdir | getgid | raise | sigfillset | time |
chmod | getgroups | read | sigismenber | timer_getoverrun |
chown | getpeername | readlink | signal | timer_gettime |
clock_gettime | getpgrp | recv | sigpause | timer_settime |
close | getpid | recvfrom | sigpending | times |
connect | getppid | recvmsg | sigprocmask | umask |
creat | getsockname | rename | sigqueue | uname |
dup | getsockopt | rmdir | sigset | unlink |
dup2 | getuid | select | sigsuspend | utime |
execle | kill | sem_post | sleep | wait |
execve | link | send | socket | waitpid |
_Exit & _exit | listen | sendmsg | socketpair | write |
-
2015-04-30
Why
nginx把整个时间的系统调用都封装了一遍。
其中有两个原因
1 不谈其他平台,至少在linux上,很多时间相关的系统调用不是async-signal-safe或thread-safe的。
例如local_time_r虽然是thread-safe的但不是async-signal-safe的,当然local_time两个都不是。
-
2019-03-14
本篇博客会深入分析条件变量实现来理解两个问题:
- 为什么条件变量要配合锁来使用 — 信号丢失问题
- 为什么条件变量需要用while循环来判断条件,而不是if — 虚假唤醒(spurious wakeup)问题
这两个问题不仅是C会遇到,所有语言的封装几乎都不可避免
先区分一下条件和条件变量,条件是指常用情况下,signal线程修改的值,使得cond_wait判断该值后不再阻塞