system(3) 与 SIGCHLD 信号

system(3) 常用于执行 shell 命令

#include <stdlib.h>
#include <stdio.h>


int main(int argc, char *argv[])
{
    int ret = system("ls -la");
    printf("ret: %d\n", ret);
    return ret;
}

正常情况下,命令执行成功会返回 0。

system(3) 在执行的命令结束时会发出 SIGCHLD 信号,收到 SIGCHLD 信号的线程会从系统调用(如:read,write)中断返回,errno 为 EINTR(4: Interrupted system call)。

应用程序应该重试被中断的系统调用,但很多时候是通过第三方库间接进行系统调用,而这些库并未考虑周到,误以为系统调用失败。

另外还有信号标志 SA_RESTART ,用于自动重试被中断的系统调用,考虑到它只支持部分系统调用,而且我用到的平台不支持这一标志,所以不作考虑。

那么该如何避免 SIGCHLD 信号中断系统调用呢?

根据 system(3) 函数的 源码 ,其关键逻辑如下:

  • 保存信号掩码
  • 设置信号掩码为阻塞(SIG_BLOCK) SIGCHLD 信号
  • fork(3)
    • 子进程

      重置为保存的信号掩码,然后 execl(3)

    • 父进程

      等待子进程(waitpid(3))结束,重置为保存的信号掩码

子进程结束时操作系统会给父进程发送 SIGCHLD 信号,父进程会遍历(通常从主线程找起)一个不阻塞 SIGCHLD 的线程进行处理。

在整个应用中忽略 SIGCHLD 信号会导致获取不到子进程退出码

在应用程序最开始的时候(main 函数),添加以下语句

signal(SIGCHLD, SIG_IGN);

子线程会继承主线程的信号处理,也就不会导致系统调用被中断。 signal(3) 设置的是整个进程的信号处理,由所有线程共享,千万不要以为子线程继承设置后就跟主线程没有关系了,只有程序中有代码动了 SIGCHLD 的处置方式,所有线程都会受影响。

然而,它会导致 system(3) 总是返回 -1,errno 为 ECHILD(10: No child processes),无法判断命令执行是否成功。

c - system() function while SIGCHLD is ignored - Stack Overflow 或 man wait(2)

POSIX.1-2001 指明,如果将 SIGCHLD 置为 SIG_IGN,或者为 SIGCHLD 指定 SA_NOCLDWAIT 标志(见 sigaction(2)),子进程结束后将不会成为僵尸进程,调用 wait() 或 waitpid() 将阻塞到所有子进程结束后返回错误,errno 设置为 ECHILD。

POSIX.1-2001 specifies that if the disposition of SIGCHLD is set to SIG_IGN or the SA_NOCLDWAIT flag is set for SIGCHLD (see sigaction(2)), then children that terminate do not become zombies and a call to wait() or waitpid() will block until all children have terminated, and then fail with errno set to ECHILD.

The Linux kernel: Signals

If the parent is not interested it can say so explicitly (before the fork) using

signal(SIGCHLD, SIG_IGN);

or

struct sigaction act;

act.sa_handler = something;

act.sa_flags = SA_NOCLDWAIT;

sigaction (SIGCHLD, &act, NULL);

and as a result it will not hear about deceased children, and children will not be transformed into zombies. Note that the default action for SIGCHLD is to ignore this signal; nevertheless signal(SIGCHLD, SIG_IGN) has effect, namely that of preventing the transformation of children into zombies. In this situation, if the parent does a wait(), this call will return only when all children have exited, and then returns -1 with errno set to ECHILD.

system(3) 调用 waitpid(3) 时,子进程已经被系统自动回收,消失得无影无踪,也就取不到子进程的返回值。

使用 popen(3)/pclose(3) 来代替 system(3) 通过分析标准输出来判断命令执行是否成功

int system2(const char* command, char* output, size_t output_size)
{
    FILE* p = popen(command, "r");
    if (p) {
        memset(output, '\0', output_size);
        fread(output, output_size - 1, 1, p);
        pclose(p);
        return 0;
    }

    return -1;
}

使用示例

char output[255];
if (0 == system2("mkdir /test; echo ret=$?", output, sizeof(output)) && strstr(output, "ret=0")) {
    printf("mkdir /test successed");
} else {
    printf("mkdir /test failed");
}

在整个应用中阻塞 SIGCHLD 信号可能导致出现僵尸进程

signal(3) 无法阻塞一个信号,只支持忽略(SIG_IGN)和恢复缺省处理(SIG_DFL)。

阻塞( SIG_BLOCK)和取消阻塞(SIG_UNBLOCK)用于信号掩码(Signal Mask),如 sigprocmask(3) ,多线程下请使用 pthread_sigmask(3)。

主线程在创建子线程之前阻塞 SIGCHLD 信号

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
pthread_sigmask(SIG_BLOCK, &set, NULL);

通过继承主线程的信号处理,子线程调用 system(3) 创建子进程时能够保证其中的 waitpid(3) 调用成功获取子进程的退出码。

man signal(7)

通过 fork(2) 创建的子进程继承父进程的信号掩码,该信号掩码即使在 execve(2) 后仍得以保留。

A child created via fork(2) inherits a copy of its parent's signal mask; the signal mask is preserved across execve(2).

system(3) 在替换进程为新程序(execl(3))之前,会重置为保存的信号掩码,也就是阻塞 SIGCHLD 信号状态,子进程继承这一掩码可能会产生问题。

在整个应用中阻塞 SIGCHLD 信号会导致一种常用的回收僵尸进程的方法失效

Reap zombie processes using a SIGCHLD handler 有详细描述

The method described here has two steps:

  1. Define a handler for SIGCHLD that calls waitpid.
  2. Register the SIGCHLD handler.

这种回收僵尸进程的方法不但我们自已不能使用,并且我们调用的子进程也不能使用,除非子进程聪明到先清除 SIGCHLD 信号掩码。

相关 BUG 报告 271 – SSHD should unblock SIGCHLD - POSIX signal blocks survive exec()

通过 system(3) 启动 sshd,有用户尝试登录,sshd 会再 fork(3) 一个孙进程,然后在 SIGCHLD 信号处理函数中通过 waitpid(3) 回收孙进程,但是从父进程(调用 system(3)的进程)继承而来信号掩码阻塞了 SIGCHLD 信号,导致孙进程结束后成为僵尸进程。

在调用 system(3) 前暂时取消对 SIGCHLD 的阻塞

// 封装 system(3) ,一方面避免中断系统调用,另一方面避免出现僵尸孙进程.
// 请记得全局堵塞 SIGCHLD 信号.
int system2(const char* command)
{
    // 调用 system(3) 前取消对 SIGCHLD 的阻塞
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    pthread_sigmask(SIG_UNBLOCK, &set, NULL);

    int code = system(command);

    // 调用 system(3) 后恢复对 SIGCHLD 的阻塞
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    pthread_sigmask(SIG_BLOCK, &set, NULL);

    return code;
}

在调用 system(3) 前暂时取消对 SIGCHLD 的阻塞,使子进程继承到正确的信号掩码,调用返回后恢复对 SIGCHLD 的阻塞,可以解决这个问题。

另外还有 popen(3)/pclose(3) 也可以用来创建子进程,也要相应进行替换。

进程的信号处理状态可在 proc 文件系统看到

如进程 pid 为 5526 ,获取到的进程忽略的信号发下

# grep SigIgn /proc/5526/status
SigIgn: 0000000000001004

这是十六进制掩码,转化为二进制

$ node -e 'console.log((0x0000000000001004).toString(2))'
1000000000100

表示信号 3(SIGQUIT) 和 信号 13 (SIGPIPE)被屏蔽。