PID 대신 파일 디스크립터로 프로세스를 잡는다: 리눅스 pidfd와 PID 재사용 문제
kill(PID)은 PID가 재사용되면 엉뚱한 프로세스에 신호를 보낼 수 있습니다. pidfd_open, poll로 종료 감지, pidfd_send_signal, waitid(P_PIDFD)가 무엇을 보장하고 무엇은 보장하지 않는지 man 페이지 기준으로 정리합니다.
감시 프로세스가 작업 프로세스의 PID를 기억해 두었다가, 시간이 지나 kill(pid, SIGTERM)을 보냅니다. 그사이 그 작업이 이미 끝났고, 누군가 그 프로세스를 거두어 갔고, 커널이 같은 번호를 새 프로세스에 줬다면 신호는 전혀 상관없는 프로세스에 도착합니다. PID는 “지금 이 번호를 쓰는 프로세스”를 가리킬 뿐, 처음 의도한 그 프로세스를 가리키지 않기 때문입니다.
리눅스의 PID 파일 디스크립터(pidfd)는 이 문제에 대한 답입니다. 이 글은 pidfd_open(2), pidfd_send_signal(2), waitid(2) man 페이지를 기준으로, pidfd가 무엇을 고정해 주는지와 무엇은 여전히 해결하지 않는지를 나눠서 정리합니다.
문제: PID는 재사용되는 번호다
pidfd_send_signal(2)의 NOTES는 문제를 이렇게 설명합니다. kill(2) 같은 전통적인 인터페이스는 대상을 PID로 지정합니다. 그래서 원래 대상이 종료되고 그 PID가 다른 프로세스에 재활용되었다면, 보내는 쪽이 실수로 엉뚱한 프로세스에 신호를 보낼 수 있습니다. 반면 PID 파일 디스크립터는 특정 프로세스에 대한 안정적인 참조이고, 그 프로세스가 종료되면 pidfd_send_signal()은 ESRCH로 실패합니다.
여기서 하나 짚어 둘 점이 있습니다. 자기 자식이라면 PID가 언제 재활용될 수 있는지가 정해져 있습니다. waitid(2)에 따르면 종료했지만 아직 wait되지 않은 자식은 좀비로 남고, 커널은 부모가 나중에 wait할 수 있도록 PID와 종료 상태 같은 최소한의 정보를 유지합니다. 좀비가 wait으로 제거되기 전에는 그 PID가 다른 프로세스에 가지 않으므로, 아직 거두지 않은 자기 자식에게 kill()을 보내는 것은 그 자체로 위험하지 않습니다.
위험해지는 것은 다음과 같은 경우입니다.
- 다른 스레드나 비동기 신호 핸들러가 자식을 먼저 거두어 갈 수 있는 프로그램
SIGCHLD를 명시적으로SIG_IGN으로 두거나SA_NOCLDWAIT을 써서, 자식이 좀비로 남지 않는 프로그램- 자기 자식이 아닌 프로세스. 그 프로세스가 언제 거두어지는지는 이쪽이 통제하지 못합니다.
pidfd_open: PID로부터 파일 디스크립터를 얻는다
#include <sys/syscall.h> /* SYS_* 상수 */
#include <unistd.h>
int syscall(SYS_pidfd_open, pid_t pid, unsigned int flags);
pidfd_open()은 pid가 가리키는 태스크를 참조하는 파일 디스크립터를 만들어 돌려줍니다. 돌려받은 디스크립터에는 close-on-exec 플래그가 설정되어 있습니다. 이 man 페이지(man-pages 6.19)는 glibc가 이 호출의 래퍼를 제공하지 않으므로 syscall(2)로 불러야 한다고 적습니다. Linux 5.3에서 들어왔고, 표준은 “Linux”뿐입니다. 이식 가능한 API가 아닙니다.
flags는 0이거나 다음 플래그를 담습니다.
PIDFD_NONBLOCK(Linux 5.10부터): 논블로킹 디스크립터를 돌려줍니다. 참조하는 태스크가 아직 종료하지 않았다면, 이 디스크립터로waitid(2)를 해도 블록하지 않고 곧바로EAGAIN오류를 돌려줍니다.PIDFD_THREAD(Linux 6.9부터): 프로세스(스레드 그룹 리더)가 아니라 특정 스레드를 가리키는 디스크립터를 만듭니다. 이 플래그가 없으면pid는 프로세스여야 합니다.
오류로는 잘못된 flags나 pid(EINVAL), 파일 디스크립터 한도(EMFILE, ENFILE), 익명 inode 파일 시스템이 없는 커널(ENODEV), 메모리 부족(ENOMEM), 그리고 그 PID의 프로세스가 없음(ESRCH)이 문서화되어 있습니다.
경쟁이 사라지는 것은 디스크립터를 얻은 뒤부터다
pidfd_open()도 결국 PID를 받습니다. 그러니 호출하는 순간에 그 번호를 쓰고 있는 태스크를 가리키게 됩니다. 기억해 둔 PID가 이미 다른 프로세스에 재활용된 뒤라면, pidfd_open()은 성공하지만 엉뚱한 프로세스의 pidfd를 돌려줍니다. pidfd는 “얻은 시점 이후로 같은 프로세스를 계속 가리킨다”는 것을 보장할 뿐, 손에 쥔 PID가 아직 의도한 프로세스인지를 검증해 주지는 않습니다.
pidfd_open(2)의 NOTES는 이 구간이 안전한 경우를 하나 명시합니다. fork(2) 직후 부모가 자식 PID로 pidfd_open()을 부르는 경우입니다. 자식이 그 사이에 이미 종료했더라도 PID는 재활용되지 않았고, 돌려받은 디스크립터는 그 좀비 프로세스를 가리킵니다. 단, 다음 조건이 모두 성립할 때만 보장됩니다.
SIGCHLD의 처리 방식을 명시적으로SIG_IGN으로 설정하지 않았다.SIGCHLD의 핸들러를 등록하거나 처리 방식을SIG_DFL로 둘 때SA_NOCLDWAIT플래그를 쓰지 않았다.- 그 좀비를 프로그램의 다른 곳(비동기로 실행되는 신호 핸들러, 다른 스레드의
wait(2)등)에서 거두지 않았다.
하나라도 성립하지 않으면, man 페이지는 clone(2)에 CLONE_PIDFD 플래그를 주어 자식과 그 pidfd를 한 번에 만들라고 안내합니다. 그러면 PID를 거치는 구간 자체가 없습니다.
자기 자식이 아닌 프로세스에는 이런 보장이 없습니다. 다른 경로(예: PID 파일)로 얻은 PID를 pidfd_open()에 넘기는 시점의 경쟁은 그대로 남습니다.
종료 감지: poll, select, epoll
pidfd는 poll(2), select(2), epoll(7)로 감시할 수 있습니다. pidfd_open(2)에 따르면 참조하는 태스크가 종료해 좀비가 되면 디스크립터가 읽기 가능(EPOLLIN)으로 표시되고, 태스크가 거두어지면 hangup 이벤트(EPOLLHUP)가 납니다.
몇 가지 세부 사항이 있습니다.
- “읽기 가능”이라고 해도 실제로 읽을 것은 없습니다. 현재 구현에서 이 디스크립터에 대한
read(2)는EINVAL로 실패합니다. 종료 상태를 얻으려면 아래의waitid()를 써야 합니다. PIDFD_THREAD없이 얻은 디스크립터는 스레드 그룹의 마지막 스레드가 종료할 때에만 읽기 가능이 됩니다.PIDFD_THREAD로 얻은 디스크립터는 스레드 그룹이 비어 있지 않아도 그 스레드가 종료해 좀비가 되면 읽기 가능이 됩니다.
이 감시는 자식이 아닌 프로세스에도 쓸 수 있습니다. pidfd_open(2)의 예제 프로그램도 명령줄로 받은 임의의 PID에 대해 pidfd_open()을 하고 poll()로 POLLIN을 기다립니다. SIGCHLD 핸들러 없이 이벤트 루프 안에서 “그 프로세스가 끝났다”는 사실을 받을 수 있다는 것이 실용적인 이점입니다.
종료 상태 얻기: waitid(P_PIDFD)
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
waitid(2)는 Linux 5.4부터 idtype에 P_PIDFD를 받습니다. 이때 id로 넘긴 PID 파일 디스크립터가 가리키는 자식을 기다립니다. 중요한 단어는 자식입니다. pidfd_open(2)도 “pidfd가 호출한 프로세스의 자식을 가리킬 때” waitid(2)로 기다릴 수 있다고 적습니다. waitid(2)의 ECHILD는 지정한 프로세스가 없거나 호출한 프로세스의 자식이 아닐 때 납니다. 자기 자식이라도 SIGCHLD가 SIG_IGN이면 이 오류가 날 수 있습니다.
정리하면 이렇습니다.
| 대상 | poll로 종료 감지 | waitid(P_PIDFD) |
|---|---|---|
| 호출한 프로세스의 자식 | 가능 | 가능. 종료 상태를 얻고 좀비를 거둠 |
| 자식이 아닌 프로세스 | 가능 | ECHILD |
자식이 아닌 프로세스의 종료 상태를 pidfd로 얻는 방법은 이번에 확인한 세 man 페이지에 나오지 않습니다. 끝났다는 사실까지만 알 수 있다고 보는 편이 안전합니다.
options에는 기다릴 상태 변화를 WEXITED(종료), WSTOPPED(정지), WCONTINUED(재개) 가운데 하나 이상 넣고, 필요하면 다음을 더합니다.
WNOHANG: 대기 가능한 자식이 없으면 블록하지 않습니다.WNOWAIT: 자식을 대기 가능한 상태로 남겨 둡니다. 나중에 다시 wait해서 같은 정보를 얻을 수 있습니다.
성공하면 infop의 si_pid, si_uid, si_signo(항상 SIGCHLD), si_status, si_code가 채워집니다. si_code가 CLD_EXITED면 si_status는 종료 코드이고, CLD_KILLED나 CLD_DUMPED면 자식을 끝낸 신호 번호입니다. POSIX.1-2008에 따르면 infop는 반드시 siginfo_t를 가리켜야 합니다. Linux에서는 NULL을 넘겨도 성공하지만 man 페이지는 이 비표준 동작에 기대지 말라고 적습니다.
참고로 이 시그니처는 glibc와 POSIX의 인터페이스이고, 원시 시스템 호출은 struct rusage *를 다섯 번째 인수로 더 받습니다. wait한 자식의 CPU 시간이 부모 쪽 집계에 언제 더해지는지는 real, user, sys 글에서 다뤘습니다.
PIDFD_NONBLOCK과 WNOHANG은 다르다
둘 다 “기다리지 않는다”처럼 보이지만 결과를 돌려주는 방식이 다릅니다.
| 걸리는 곳 | 아직 종료하지 않았을 때 | |
|---|---|---|
PIDFD_NONBLOCK |
파일 디스크립터 (pidfd_open()의 flags) |
waitid()가 -1을 돌려주고 errno는 EAGAIN |
WNOHANG |
그 한 번의 waitid() 호출 (options) |
waitid()가 0을 돌려줌 |
WNOHANG일 때 0은 성공에도 쓰이는 값입니다. 대기 가능한 자식이 없어서 0이 돌아왔다면 siginfo_t의 내용은 구현에 따라 다릅니다. waitid(2)는 이 경우를 이식성 있게 구분하려면 호출 전에 si_pid를 0으로 지워 두고, 호출 뒤 0이 아닌지 확인하라고 안내합니다. Linux는 POSIX.1-2008 TC1 요구대로 si_pid와 si_signo를 0으로 채우지만, 모든 구현이 그렇지는 않습니다.
PIDFD_NONBLOCK은 디스크립터 쪽 설정이므로 그 디스크립터로 하는 waitid() 모두에 적용되고, 실패로 알려 주니 errno를 확인해야 합니다. 문서의 조건 표현도 다릅니다. EAGAIN은 “그 프로세스가 아직 종료하지 않았을 때”, WNOHANG의 0은 “대기 가능한 상태의 자식이 없을 때”입니다.
pidfd_send_signal: 신호 보내기
#include <linux/signal.h> /* SIG* 상수 */
#include <signal.h> /* SI_* 상수 */
#include <sys/syscall.h> /* SYS_* 상수 */
#include <unistd.h>
int syscall(SYS_pidfd_send_signal, int pidfd, int sig,
siginfo_t *_Nullable info, unsigned int flags);
pidfd가 가리키는 프로세스에 sig를 보냅니다. Linux 5.1에서 들어왔고, 이 호출 역시 man 페이지 기준 glibc 래퍼가 없습니다. info가 NULL이면 kill(2)로 보낼 때와 같은 값(si_code는 SI_USER, si_pid는 호출자의 PID, si_uid는 호출자의 real UID)이 채워진 것으로 취급합니다. info를 직접 채운다면 rt_sigqueueinfo(2)의 규칙을 따라야 합니다.
flags는 기본값 0이면 pidfd에서 범위를 추론합니다. PIDFD_THREAD로 얻은 pidfd라면 그 스레드에 보냅니다. Linux 6.9부터는 PIDFD_SIGNAL_THREAD, PIDFD_SIGNAL_THREAD_GROUP, PIDFD_SIGNAL_PROCESS_GROUP으로 범위를 명시할 수 있습니다.
ESRCH에 대해서는 문서의 표현이 두 곳에서 조금 다릅니다. NOTES는 “그 프로세스가 종료하면 ESRCH로 실패한다”고 하고, ERRORS는 ESRCH를 “대상 프로세스가 존재하지 않음(즉, 종료했고 wait되었음)“으로 설명합니다. 어느 쪽이든 핵심은 같습니다. 원래 프로세스가 사라진 뒤에 같은 PID를 받은 다른 프로세스로 신호가 넘어가지는 않습니다. 다만 아직 거두어지지 않은 좀비에 보냈을 때 정확히 어떤 결과가 나오는지는 이 문서만으로는 단정하지 않는 편이 좋습니다.
pidfd는 pidfd_open() 말고도 /proc/<pid> 디렉터리를 열거나, clone(2)/clone3(2)에 CLONE_PIDFD를 주어 얻을 수 있습니다. 다만 pidfd_open(2)에 따르면 /proc/<pid>로 얻은 디스크립터는 proc 파일 시스템이 마운트되어 있어야 하고, poll할 수 없으며 waitid(2)로 기다릴 수도 없습니다. 신호를 보내는 데는 쓸 수 있습니다.
pidfd가 해결하지 않는 것
pidfd는 “어느 프로세스인가”라는 참조 문제를 다룹니다. 다음은 그대로 남습니다.
- 권한. pidfd를 가졌다고 신호를 보낼 권한이 생기지 않습니다. 호출자에게 대상에 신호를 보낼 권한이 없으면
pidfd_send_signal()은EPERM으로 실패합니다. 대상이 자기 자신이 아닌데info.si_code가 유효하지 않을 때도EPERM입니다. - PID namespace. 호출자는 대상과 같은 PID namespace에 있거나 그 조상 namespace에 있어야 합니다. 그렇지 않으면
EINVAL입니다. pidfd가 namespace 경계를 넘어 보이게 해 주지 않습니다. - PID를 손에 쥐기까지의 경쟁. 앞에서 본 대로, 오래된 PID로
pidfd_open()을 부르는 순간의 경쟁은 남습니다. 자식이라면fork()직후(조건 충족 시)나CLONE_PIDFD로 처음부터 pidfd를 얻으세요. - 자식이 아닌 프로세스의 종료 상태. 종료 시점은 알 수 있지만
waitid(P_PIDFD)는ECHILD입니다. - 다른 모든 TOCTOU. pidfd가 고정해 주는 것은 프로세스 참조 하나입니다. 그 프로세스가 무엇을 하고 있는지, 확인한 뒤 상태가 바뀌었는지 같은 다른 검사-사용 간 경쟁까지 없애 주지는 않습니다.
- 이식성. 세 호출 모두 표준은 “Linux”뿐입니다(
waitid()자체는 POSIX지만P_PIDFD는 Linux 확장입니다).
이 밖에도 pidfd는 pidfd_getfd(2)로 상대 프로세스의 파일 디스크립터를 복제하거나, setns(2)로 대상과 같은 namespace에 들어가거나, process_madvise(2)로 메모리 사용 패턴을 알려 주는 데 쓰입니다. 이 글에서는 다루지 않습니다.
예제: 자식을 감시하다가 시간이 지나면 종료시키기
자식을 하나 만들고, 최대 2초 동안 종료를 기다린 뒤, 아직 살아 있으면 그 pidfd로 SIGTERM을 보내고, 마지막에 waitid(P_PIDFD)로 종료 상태를 얻습니다. 신호는 자기가 만든 자식에게만 갑니다.
#define _GNU_SOURCE
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int pidfd_open(pid_t pid, unsigned int flags)
{
return syscall(SYS_pidfd_open, pid, flags);
}
static int pidfd_send_signal(int pidfd, int sig, siginfo_t *info,
unsigned int flags)
{
return syscall(SYS_pidfd_send_signal, pidfd, sig, info, flags);
}
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
sleep(30); /* 오래 걸리는 작업 흉내 */
_exit(0);
}
/* SIGCHLD 기본 처리 + 아직 아무도 wait하지 않음: PID는 재활용되지 않았다 */
int pidfd = pidfd_open(pid, 0);
if (pidfd == -1) {
perror("pidfd_open");
return 1;
}
struct pollfd pfd = { .fd = pidfd, .events = POLLIN };
int ready = poll(&pfd, 1, 2000);
if (ready == -1) {
perror("poll");
return 1;
}
if (ready == 0 && pidfd_send_signal(pidfd, SIGTERM, NULL, 0) == -1)
perror("pidfd_send_signal");
siginfo_t info;
memset(&info, 0, sizeof(info));
if (waitid(P_PIDFD, (id_t) pidfd, &info, WEXITED) == -1) {
perror("waitid");
return 1;
}
if (info.si_code == CLD_EXITED)
printf("exited, status=%d\n", info.si_status);
else if (info.si_code == CLD_KILLED || info.si_code == CLD_DUMPED)
printf("killed by signal %d\n", info.si_status);
close(pidfd);
return 0;
}
몇 가지 메모입니다.
- 커널 5.4 이상, 그리고
SYS_pidfd_open,SYS_pidfd_send_signal,P_PIDFD를 정의하는 헤더가 필요합니다. 헤더에 없다면 번호를 직접 적어 넣기보다 헤더를 갱신하는 편이 안전합니다. 이 글을 쓴 환경은 Linux가 아니어서 이 코드를 직접 빌드하거나 실행하지 않았습니다. poll()이 0을 돌려준 직후 자식이 스스로 끝날 수도 있습니다. 그래도 아직 wait하지 않았으니 신호가 다른 프로세스로 가지는 않습니다. 이 경우pidfd_send_signal()이 성공하는지ESRCH인지는 위에서 말한 대로 단정하지 않았고, 예제는 실패해도 메시지만 출력하고 넘어갑니다.PIDFD_NONBLOCK은 쓰지 않았습니다.poll()로 종료를 확인한 뒤 블로킹waitid()를 하는 구조라 필요가 없습니다. 이벤트 루프에서 블록 없이 확인하고 싶다면PIDFD_NONBLOCK(EAGAIN) 또는WNOHANG(0 반환,si_pid확인) 중 하나를 고르고, 앞 절의 차이에 맞춰 결과를 처리하세요.- 바뀔 수 있는 이름(PID) 대신 열린 디스크립터로 대상을 가리킨다는 발상은 삭제한 파일의 디스크 공간 글에서 본 파일 이름과 디스크립터의 관계와 닮았습니다. 다만 pidfd가 프로세스의 종료나 회수를 막지는 않습니다. 회수되면
EPOLLHUP이 날 뿐입니다. 다 쓴 pidfd는 닫으세요.
확인한 범위와 한계
- man7.org에 게시된 Linux man-pages 6.19(페이지 날짜 2026-02-08) HTML을 2026-10-27에 확인했습니다. 배포판에 설치된 man 페이지, glibc, 커널 버전에 따라 내용이 다를 수 있습니다. 특히 “glibc 래퍼가 없다”는 설명은 이 판의 man 페이지 기준입니다.
- 문서에 적힌 버전:
pidfd_send_signalLinux 5.1,pidfd_openLinux 5.3,waitid의P_PIDFDLinux 5.4,PIDFD_NONBLOCKLinux 5.10,PIDFD_THREAD와PIDFD_SIGNAL_*Linux 6.9. clone(2)/clone3(2)의CLONE_PIDFD사용법,pidfd_getfd(2),setns(2),process_madvise(2)는 해당 man 페이지를 이번에 확인하지 않아서 이름만 소개했습니다.- 성능 수치나 실측 출력은 제시하지 않습니다.
출처
2026-10-27에 확인했습니다.