父/子进程文件描述符继承机制导致socket bind失败的问题
此问题来自项目上,应用程序本身由它的父进程启动,父进程监听SIGCHLD
信号,即子进程退出时,父进程会收到这个信号,然后立即通过execlp
重新启动子进程,确保子进程异常崩溃会被重新拉起来。而子进程(我们实际的业务应用)也会在某些地方fork新的进程,干别的事情。
出现的问题是,进程被重新拉起来后,一个socket的bind动作失败,错误为bind: Address already in use
。netstat查看,发现是crond占用了这个端口。最开始觉得比较奇怪,crond按道理不会使用socket,更不可能恰好绑定这个端口。并且还发现crond进程的/proc/$(pidof crond)/fd居然打开了显卡设备节点,这个就完全不可能了。打开显卡的行为是我们的应用程序,这两者有什么关联呢?查看代码发现,我们的应用会fork子进程,然后执行shell命令/etc/init.d/crond restart
。经同事提醒,子进程会继承父进程打开的文件描述符!原来问题在这里,几年前看APUE(Unix环境高级编程)时,确实记得这一点,太久没搞忘记了。第8章 <进程控制>提到的这点。
为了加深映像,模拟测试验证一下:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <sys/socket.h> #include <string.h> #include <netinet/in.h> int main() { int fd; pid_t pid; struct sockaddr_in addr; fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0){ perror("socket"); return -1; } memset(&addr, 0, sizeof(struct sockaddr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(4567); if (bind(fd, (const struct sockaddr *)&addr, sizeof(addr)) < 0){ perror("bind"); close(fd); return -1; } if (listen(fd, 5) < 0){ perror("listen"); close(fd); return -1; } pid = fork(); if (pid == 0){ printf("I am child\n"); while (1) { sleep(1); } }else if (pid > 0){ printf("I am parent\n"); close(fd); return 0; }else{ perror("fork"); close(fd); return -1; } close(fd); return 0; }
上面代码父进程中bind 4567端口,然后fork后,父进程退出,子进程继续运行,此时子进程成为孤儿进程,由1号进程托管,在ubuntu20.04上是由systemd托管。先查看成为孤儿进程的子进程打开的文件描述符:
ls /proc/$(pidof ctest)/fd -l total 0 lrwx------ 1 a a 64 Aug 18 18:02 0 -> '/dev/pts/2 (deleted)' lrwx------ 1 a a 64 Aug 18 18:02 1 -> '/dev/pts/2 (deleted)' lrwx------ 1 a a 64 Aug 18 18:02 2 -> '/dev/pts/4 (deleted)' lrwx------ 1 a a 64 Aug 18 18:02 3 -> 'socket:[28406147]' netstat -antp | grep 4567 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 0.0.0.0:4567 0.0.0.0:* LISTEN 3535349/ctest
发现子进程确实继承了父进程打开的文描述符,并且端口的占用也继承了。再次启动程序
./ctest bind: Address already in use
问题复现。如何解决这个问题呢?
man socket
可知,socket的第二个参数type,可以通过OR的形式指定bit标识,具体参数为SOCK_CLOEXEC
,它表示socket创建的fd在exec时,做close动作。即代码改为:
fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
编译重新验证,先杀掉最开始的成为孤儿进程的子进程。重复验证过程,问题确认得到解决。
- 以此类推,如果不是socket,是其他类型的东西,例如文件,设备节点等。则可以在open时,指定flags:
O_CLOEXEC
,或者对fd进行fcntl操作
open(path, O_RDWR | O_CLOEXEC) 或者开时不指定,后续通过fcntl更改flags int flags = fcntl(fd, F_GETFD); flags |= FD_CLOEXEC; fcntl(fd, F_SETFD, flags);
-
还有一种情况,父进程调用第三方库,第三方库未指定
O_CLOEXEC
标识,而我们又不想子进程继承打开的描述符,避免误操作到,引发不必要的麻烦,此时可以通过clone
方式,而不是fork
来创建子进程,clone
可以指定标志,选择继承父进程的哪些东西,例如CLONE_FILES
控制是否继承父进程打开的文件描述符,我们这里可以选择不继承。 -
手动关闭文件描述符,fork和exec之间是允许我们做自己想做的事情,例如在这里,我们关闭所有文件描述符,一个典型的参考例子时AUEP中守护进程里面的例子,先获得进程最大的文件描述符编号,然后逐个close。
struct rlimit rl; getrlimit(RLIMIT_NOFILE, &rl); for(i=0;i<rl.rlim_max; i++) { close(i); }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)