父/子進程文件描述符繼承機制導致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);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章