fork的坑:文件描述符继承
最近遇到一个神奇的fork问题,坑了我2天半的时间,最后在另一个小伙伴的帮助下,找到问题根源,然后修改。此时,对于前人说的,fork的坑,也终于有点认识了。基本的软件图如下:主进程A收到云端B的命令,fork出子进程A1、A2、….、An,然后执行execv函数,打开新的可执行文件。Execv执行完成后,子进程Ai就拥有了和主进程A不同的镜像文件,这是Linux下创建新进程的典型方式。主进程A收
最近遇到一个神奇的fork问题,坑了我2天半的时间,最后在另一个小伙伴的帮助下,找到问题根源,然后修改。此时,对于前人说的,fork的坑,也终于有点认识了。
基本的软件图如下:
主进程A收到云端B的命令,fork出子进程A1、A2、….、An,然后执行execv函数,打开新的可执行文件。Execv执行完成后,子进程Ai就拥有了和主进程A不同的镜像文件,这是Linux下创建新进程的典型方式。
主进程A收到云端的控制信令后,通过socket与子进程Ai通信:控制Ai,收集Ai的信息等(任意的子进程统称为子进程Ai,下同)。
主进程A有保活进程:如果A因为异常情况终止,会由保活进程自动拉起。
子进程Ai和主进程A之间有心跳机制及断线重连机制,因此子进程Ai会一直尝试连接主进程A,然后发送心跳报文。如果主进程A在指定时间内,没有收到子进程Ai的心跳报文,就标记子进程Ai下线,并反馈状态给云端。
上述是背景信息,问题出现在主进程A被主动Kill了,被保活进程拉起后,云端却显示很多子进程处于离线状态。
通过ps命令,发现子进程都在,并没有出异常。子进程Ai的日志,也显示心跳报文发送正常。通过GDB挂载离线的子进程Ai,发现其套接字状态也OK,心跳交互机制也正常。通过抓包,离线的子进程Ai,还在正常发送心跳报文,并且目的地、端口都没有问题,滑动窗口都正常。
奇怪的是,主进程A就是没有收到离线的子进程Ai的心跳报文,主进程A的日志也证实了这一点。
通过GDB挂载到主进程A中,发现其维护的相应的数据结构确实没问题。
一切都正常,但是结果却不对,太神奇了。
每次遇到这么的问题,我都说,肯定是个傻逼问题。
我开始怀疑主进程A的网络模型了。
主进程A用的是epoll网络模型,代码也是典型的《Unix网络编程》中的示例代码,确实没有什么问题。在accept那边进行打印,打印的次数,和在线的子进程Ai数相同,也即,离线的子进程Ai,根本就没有连接到主进程A上!
但是,抓包显示,离线的子进程Ai一直在发送心跳报文,并且端口确实是主进程的监听端口!
那么问题来了,到底是哪个进程在主进程被Kill期间,重新绑定了监听端口,进而收走了子进程Ai的报文?
太神奇了,太有意思了。
通过netstat查看网络端口情况,发现主进程的监听端口上的链接,并不是只有主进程拥有,子进程也有;通过lsof查看主、子进程打开的文件描述符信息,发现socket有点不对劲。子进程拥有的fd,超过了其自己打开的fd:这里包括socket,包括epoll。
于是在子进程的bind、listen、accept函数附近加日志,但并没有异常。
继续分析netstat、lsof的输出信息,突然间,小伙伴发现了问题所在:子进程是通过fork函数创建的,而fork出的子进程,默认是继承了父进程的句柄。
Fork手册中很清楚的写着:
The child inherits copies of the parent's set of open file descriptors. Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the parent. This means that the two descriptors share open file status flags, current file offset, and signal-driven I/O attributes (see the description of F_SETOWN and F_SETSIG in fcntl(2)). |
问题的根源在于,主进程A通过fork创建了子进程Ai,子进程Ai继承了主进程A的fd:包括监听套接字、epoll句柄、accept的套接字等。当主进程A被手动Kill时,这些句柄,对于子进程Ai依然有效。
子进程定期发心跳,监听套接字有效、epoll句柄有效、accept的套接字也有效,因此,某些子进程就扮演了主进程的角色:收走了其他子进程发往主进程的报文,这也正是抓包显示的报文都正常的原因。
当主进程A重新被拉起,重新绑定监听端口,子进程发出去的报文,也可能被其收到。被主进程A收到报文的子进程,状态就是在线,反之就是离线。
于是,排查网络操作相关的函数,在创建监听套接字时增加SOCK_CLOEXEC标志;将epoll_create修改为epoll_create1,参数设置为EPOLL_CLOEXEC;将accept修改为accept4,标志设置为SOCK_CLOEXEC。这些都是为了不让子进程继承主进程的相关句柄。
SOCK_CLOEXEC的说明如下:
Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See the description of the O_CLOEXEC flag in open(2) for reasons why this may be useful. |
O_CLOEXEC的说明如下:
O_CLOEXEC (Since Linux 2.6.23) Enable the close-on-exec flag for the new file descriptor. Specifying this flag permits a program to avoid additional fcntl(2) F_SETFD operations to set the FD_CLOEXEC flag. Additionally, use of this flag is essential in some multithreaded programs since using a separate fcntl(2) F_SETFD operation to set the FD_CLOEXEC flag does not suffice to avoid race conditions where one thread opens a file descriptor at the same time as another thread does a fork(2) plus execve(2). |
epoll_create和epoll_create1的说明如下
int epoll_create(int size); int epoll_create1(int flags);
DESCRIPTION epoll_create() creates an epoll(7) instance. Since Linux 2.6.8, the size argument is ignored, but must be greater than zero; see NOTES below.
epoll_create() returns a file descriptor referring to the new epoll instance. This file descriptor is used for all the subsequent calls to the epoll interface. When no longer required, the file descriptor returned by epoll_create() should be closed by using close(2). When all file descriptors referring to an epoll instance have been closed, the kernel destroys the instance and releases the associated resources for reuse.
epoll_create1() If flags is 0, then, other than the fact that the obsolete size argument is dropped, epoll_create1() is the same as epoll_create(). The following value can be included in flags to obtain different behavior:
EPOLL_CLOEXEC Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See the description of the O_CLOEXEC flag in open(2) for reasons why this may be useful. |
accept和accept4的说明如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
#define _GNU_SOURCE /* See feature_test_macros(7) */ #include <sys/socket.h>
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);
SOCK_CLOEXEC Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See the description of the O_CLOEXEC flag in open(2) for reasons why this may be useful. |
修改完成后,测试了几次,问题解决了,很开心,确实长了见识。
但是,当项目发测时,进行问题单回归时,我又多测试了几次。然后,又被发现出问题:依然有子进程处于离线状态。
先抓包,报文正常;然后通过netstat以及lsof查看,发现处于离线状态的子进程,和主进程A连接的套接字很特殊:本地端口和对端端口相同。也即,如果主进程A监听的是20000端口,这个子进程向20000端口连接的socket的本地端口也是20000。在这个情形下,主进程A被Kill了,由于这个套接字既监听了写,也监听了读,因此,它发出去的报文被自己给接收了,真是神奇(操作系统通过端口找进程)。操作系统的套接字管理机制,真是有意思,后续可以重点研究下。
这个现象的根源在于,端口复用,将主进程A的监听端口设置为不可复用即可。当然,这里和fork的文件描述符继承无关,只是验证问题的一个小插曲。
这次的问题定位,真是一波三折,也真是有意思,只能说明自己弱鸡,需要学习的还很多。
更多推荐
所有评论(0)