父/子进程文件描述符继承机制导致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);
}
posted @ 2023-08-18 18:38  thammer  阅读(177)  评论(0编辑  收藏  举报