此問題來自項目上,應用程序本身由它的父進程啓動,父進程監聽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);
}