1.背景
下面要说明的是信号处理函数中调用了非异步信号安全函数导致的问题,如果读到这里就了解了标题中问题的原因,就可以不用再往下读了。
在项目开发过程中,经常会使用多进程的方式。然而在对已结束子进程回收资源的处理上,可能会遇到本文所描述的问题。问题是,在捕获到子进程结束信号,对子进程进行资源回收时,想通过调用封装好的日志输出接口打印进程的一些信息,结果导致父进程异常了,某个线程或整个进程被永久阻塞了。
2.验证
为了复现这个问题,我写了如下程序:
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <thread>
#include <mutex>
#define TEST_LOCK_IN_SIG 1
std::mutex my_mutex;
void LockToHandle(const std::string name) {
#if TEST_LOCK_IN_SIG
my_mutex.lock();
printf("%s lock\n", name.c_str());
// do something...
sleep(1);
my_mutex.unlock();
printf("%s unlock\n", name.c_str());
#endif
}
void sighandle(int sig_val) {
printf("sighandle: %d\n", sig_val);
switch (sig_val)
{
case SIGCHLD: {
pid_t pid = 0;
do {
int status = 0;
pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
if (WIFEXITED(status)) {
printf("pid: %d exit, status: %d\n", pid, status);
}
}
} while(pid > 0);
printf("sighandle ready to lock\n");
LockToHandle("sighandle");
printf("to sleep\n");
sleep(5);
printf("sleep done\n");
}break;
default:
break;
}
}
void CreateThread() {
std::thread thread = std::thread([](void) {
while(true) {
static int j = 0;
printf("parent thread 2 process\n");
sleep(1);
// LockToHandle("thread 2");
if (++j > 3) {
printf("parent thread 2 done\n");
return;
}
}
});
thread.detach();
}
int main(int argc, char *argv[]) {
sigset_t oldmask;
sigset_t mask;
signal(SIGCHLD, sighandle);
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
pid_t pid = fork();
if (pid > 0) {
// parents
printf("child pid: %d\n", pid);
CreateThread();
// pthread_sigmask(SIG_BLOCK,&mask,&oldmask);
while (true) {
static int i = 0;
printf("parent thread 1 process\n");
sleep(1);
LockToHandle("thread 1");
if (++i > 3) {
printf("parent thread 1 process done\n");
return 0;
}
}
} else if (pid == 0) {
// child process
printf("child process done\n");
sleep(1);
return 1;
} else {
printf("fork error\n");
return -1;
}
return 0;
}
运行日志1如下:
child pid: 23436
child process done
parent thread 1 process
parent thread 2 process
parent thread 2 process
thread 1 lock
sighandle: 17
pid: 23436 exit, status: 256
sighandle ready to lock
parent thread 2 process
parent thread 2 process
parent thread 2 done
死机不再输出日志
打开line 58,line 82,屏蔽line 89,重新编译后运行日志2如下:
child pid: 23551
child process done
parent thread 1 process
parent thread 2 process
thread 2 lock
parent thread 1 process
sighandle: 17
pid: 23551 exit, status: 256
sighandle ready to lock
parent thread 1 process
parent thread 1 process
parent thread 1 process done
运行结束
3.分析与结论
日志1中,由主线程处理了信号,在日志1的06行主线程获取了锁,主线程因为捕获到子进程退出的信号而被打断,信号处理函数阻塞了主线程导致“parent thread 1 process”不再输出,信号处理函数又无法获取到锁导致代码41行不能输出,触发死锁。由日志“parent thread 2 process”可以看出线程2并没有受到影响,因为代码58行对锁的操作被屏蔽。最终整个进程无法结束。
日志2中,由于代码82行被打开,主线程不再处理SIGCHLD信号。代码58行打开,89行屏蔽,可以看到捕获信号时,线程2被阻塞,信号处理函数仍然无法获取到锁,触发死锁。由“parent thread 1 process”可以看出主线程(线程1)并没有受到影响。最终主线程运行结束使整个进程运行结束。
由于日志输出接口大部分会被设计成多线程安全访问的接口(我们的项目设计也是如此),并且这种设计也是正确的。当进程捕获到子进程状态改变的信号时,会中断接收信号的线程,并跳转到信号处理函数中,在信号处理函数中,如果调用了同一个日志输出接口,就会与被中断的线程争夺资源,如果很不幸被中断的线程正好获得一把互斥锁还未及时释放就被中断,那么信号处理函数就会永久等待这把无法得到释放的锁,从而导致接受信号的线程出现异常。如果这个接口还被其他多个线程使用,那么这些线程都会出现永远获取不到锁的现象,导致整个进程功能异常。
4.解决方法
尽量不要在信号处理函数中获取锁。如果一定要使用锁,需要确保捕获信号的线程(最好为主线程)一定不能与信号处理函数中访问的互斥量为同一个。
建议可以在信号处理函数中只是做简单的记录,在其他线程中获取记录并输出日志。