日常解bug之——LinuxC信号处理死锁问题(FUTEX_WAIT_PRIVATE)

标签:
ltracestrace信号处理异步安全杂谈 |
分类: 编程艺术 |
背景介绍
我们的服务A会对服务B进行管理操作,A服务定期去管理中心(下文简称为CC:Control
Center)下载相关信息,然后在本机上进行多个B服务进程的后台启动或者kill以及检测B服务进程是否异常终止并进行拉起等操作。周四的时候,因为异常报警信息,登陆线上机器才发现该机器上部分B服务进程成为了僵尸进程。如下图所示:
图-1 部分进程成为了僵尸进程
我们可以发现进程PID为29329、29340、29351、29362等五个进程成为了僵尸进程。
一、僵尸进程
“简单讲,当Linux上的一个进程(process)死亡的时候,它并没有直接从内存中完全移除——它的进程描述符(进程PID)依然存在于内存中(PID仅仅占用很少的内存空间)。进程的状态变为EXIT_ZOMBIE,并且该进程的父进程会被发送SIGCHLD信号来通知它的子进程已经死亡了。然后父进程应当执行wait()或者waitpid()系统调用来读取死亡进程的退出状态和其它信息。这将允许父进程获取死亡子进程的相关信息,当
wait() 或者 waitpid() 调用成功之后,僵尸进程将会被从内存中彻底移除。
通常这发生的非常迅速,因此你将看不到僵尸进程在系统中不断堆积。然而,如果父进程未能进行正确地编程处理或者永远没调用wait()或者waitpid(),它的僵尸子进程会一直驻留在内存中直到它们被清理掉。”
值得注意的是,对于僵尸进程,使用 kill -9 PID
是无法杀死它的。可以使用的方法之一为发送
SIGCHLD信号给父进程,这个信号告诉父进程去执行wait()或者waitpid()调用,清理它的僵尸子进程。但是如果父进程如果未能合适地处理或者忽略了SIGCHLD信号,这将不起作用。
另外一种方法是直接kill掉或者关闭僵尸子进程的父进程。当创建僵尸子进程的进程结束时,init进程将成为这些僵尸进程的父进程。init进程会周期性地执行wait()系统调用来清理僵尸子进程,因此init将迅速干掉僵尸进程。
根据上述关于僵尸进程的相关描述,可以基本确定A服务在处理B进程退出收到的SIGCHLD信号时,部分未成功执行,才导致部分B进程成为了僵尸进程。
二、A服务处理SIGCHLD信号的相关逻辑分析
那么A服务是如何处理SIGCHLD信号,导致了该现象呢?代码的逻辑比较清晰,大致的代码如下:
对于SIGCHLD信号,我们的代码中调用了 pid_t
waitpid(pid_t pid, int *status, int
options); 系统函数等待子进程状态的改变。
参数说明:
此处waitpid
函数的第一个参数pid的值为-1,表示等待任何子进程;
如果第二个参数status指针非空,waitpid会将进程的状态值写入第二个参数status所指向的变量中;
如果第三个参数options的值为WNOHANG,表示如果没有子进程退出就直接返回。
返回值说明:
如果waitpid执行成功,则会返回状态改变的子进程的进程id;
如果第三个参数为WNOHANG并且由第二个参数pid指定的一个或多个子进程存在,但状态没有改变,那么就会返回0。
如果执行出错,则会返回 -1。
WIFSIGNALED(status)和 WTERMSIG(status) 的说明:
如果进程是由于收到了信号退出的,那么WIFSIGNALED的结果为真;
只有当进程是收到信号退出的,也就是说只有当WIFSIGNALED返回结果为真时,才可以调用WTERMSIG获得
导致子进程退出的信号值。
注:以上所有关于函数参数、返回值和其它函数的信息均可以通过:man waitpid 进行查看。
在上述代码中,我们可以发现当父进程接收到SIGCHLD信号时,会将导致子进程退出的信号值打印输出到日志中。
既然上文谈到父进程对于SIGCHLD信号的不恰当处理会导致退出的子进程成为僵尸进程。那么首先有理由怀疑是
否是因为此处信号处理的问题导致了问题。首先就把A服务中关于信号处理的代码提取出来(移除了INFO打印操
作),用测试用例进行测试,结果发现相应的子进程均正常退出了,没有出现僵尸进程。
三、重现问题,进行定位
那就有意思了,表面上看信号处理函数中的代码没什么问题;但僵尸进程的出现根源又说明这部分代码有问题,那
到底是什么问题呢?最好的方法就是能够重现图-1的现象。
首先尝试用Go开发了一个简单的仿CC的http
server,根据A服务请求次数分别返回正常数据和异常数据来模拟
对于B服务的启动和kill操作。结果证明,我这么干路没走错,找到了问题。
在A服务正常运行了一会儿之后(正常请求,启动/kill
B服务),发现:与图-1中同样的现象出现了,并且此时A服务的日志也不再更新了。
可以基本判断A服务应该是卡在某处无法继续执行下去了(比如因为死锁或者死循环等),那么到底是哪里卡住了
呢?
重新启动A服务,并执行 strace -p
A_pid 命令来跟踪A服务调用系统函数的过程,最终发现A服务确实是卡住不动
了,并且是卡在了
FUTEX_WAIT_PRIVATE 系统调用这里了,相关信息如下:
图-2 "strace -p A_pid"
输出信息
关于Futex的详细内容可以参考这篇文章:Linux Futex解析
根据strace跟踪输出的结果看,父进程正常进入了SIGCHLD信号的处理函数中,并且在执行到写日志操作时发生了死锁操作FUTEX_WAIT_PRIVATE。难道是在信号处理函数中调用了
INFO,写日志文件导致的问题?似乎最终答案离我已经不太远了。
strace 只能输出程序系统调用的函数信息;而另一个工具 ltrace
则能输出程序库函数调用的信息,那么用ltrace
试试看能不能定位到具体的函数,结果ltrace没有让我失望。
重新启动A服务,并执行 ltrace -p
pid 来跟踪A服务调用库函数的过程,最后程序在
localtime 调用处卡住了。
根据ltrace跟踪输出的结果看,父进程正常进入了waitpid函数中,并且在执行到写日志操作调用localtime函数时发生了死锁操作。
到这里为止,最快的解决方法也出来了,那就是移除信号处理函数中的INFO打印语句。至于为什么在信号处理函数中间接调用localtime会导致死锁需要进一步深入libgcc的代码进行分析。其实zabbix官方case的文章中,给到了一些线索:那就是在信号处理函数中不能调用非异步安全(asynchronous-safe)的函数,localtime不是一个异步安全函数。
四、后话
其实我在Google搜索 FUTEX_WAIT_PRIVATE 时,发现了zabbix官方博客上有一个case跟我遇到的问题神似,这篇文章的思路,给了我很大的启发,非常感谢文章的作者。
关于strace的使用,我应该是在看火丁笔记的文章时了解了它的用法;同事也有提到过,此次终于派上了用场!
勤于总结,乐于分享,受益无穷。
参考资料
1.什么是僵尸进程?
4.异步安全函数