POSIX에서 프로세스 종료를 기다릴 때 쓰이던 바쁜 폴링(busy-polling)을 pidfd/kqueue 기반의 이벤트 주도 방식으로 대체해 psutil과 CPython subprocess의 비효율을 개선한 과정을 설명한다.
작성일: 2026년 1월 28일,[태그: psutil, python, python-core, async](https://gmpy.dev/blog/2026/event-driven-process-waiting)
POSIX 시스템에서 프로세스 관리를 할 때 덜 즐거운 부분 중 하나는 프로세스가 종료될 때까지 기다리는 일입니다. 표준 라이브러리의 subprocess 모듈은 Python 3.3에서 Popen.wait()에 timeout 파라미터가 추가된 이후(약 15년 전, 소스 참조)로 바쁜 루프(busy-loop) 기반의 폴링 방식에 의존해 왔습니다. 그리고 psutil의 Process.wait() 메서드도 정확히 같은 기법을 사용합니다(소스 참조).
로직은 간단합니다. 논블로킹 waitpid(WNOHANG)로 프로세스가 종료했는지 확인하고, 잠깐 잠들었다가, 다시 확인하고, 좀 더 오래 잠들고… 이를 반복합니다.
pythonimport os, time def wait_busy(pid, timeout): end = time.monotonic() + timeout interval = 0.0001 while time.monotonic() < end: pid_done, _ = os.waitpid(pid, os.WNOHANG) if pid_done: return time.sleep(interval) interval = min(interval * 2, 0.04) raise TimeoutExpired
이 글에서는 제가 이 오래된 비효율을 마침내 해결한 방법을 보여 드리겠습니다. 먼저 psutil에서, 그리고 더 흥미롭게도 CPython 표준 라이브러리의 subprocess 모듈에서 직접 해결했습니다.
모든 POSIX 시스템은 파일 디스크립터가 준비(ready) 상태가 되었을 때 통지받는 메커니즘을 최소 하나 이상 제공합니다. select(), poll(), epoll()(Linux), kqueue()(BSD / macOS) 같은 시스템 콜이 그것입니다. 최근까지 저는 이들이 소켓, 파이프 등을 참조하는 파일 디스크립터에만 쓸 수 있다고 믿었는데, 알고 보니 프로세스 PID에 대한 이벤트를 기다리는 데도 사용할 수 있었습니다!
2019년 Linux 5.3은 새로운 시스템 콜 **pidfd_open()**을 도입했고, 이는 Python 3.9에서 os 모듈에 추가되었습니다. 이 함수는 프로세스 PID를 참조하는 파일 디스크립터를 반환합니다. 흥미로운 점은 pidfd_open()을 select(), poll(), epoll()과 함께 사용하면 사실상 프로세스가 종료될 때까지 기다릴 수 있다는 것입니다. 예를 들어 poll()을 사용하면:
pythonimport os, select def wait_pidfd(pid, timeout): pidfd = os.pidfd_open(pid) poller = select.poll() poller.register(pidfd, select.POLLIN) # 프로세스가 종료되거나 타임아웃이 발생할 때까지 블록 events = poller.poll(timeout * 1000) if events: return raise TimeoutError
이 방식에는 바쁜 루프가 전혀 없습니다. 커널은 프로세스가 종료되는 순간, 또는 PID가 살아 있는 채로 타임아웃이 만료되는 순간에 정확히 우리를 깨워줍니다.
select() 대신 poll()을 고른 이유는 select()에 역사적인 파일 디스크립터 제한(FD_SETSIZE)이 있기 때문입니다. 보통 프로세스당 1024 FD로 제한됩니다( BPO-1685000가 떠오르더군요 ).
epoll() 대신 poll()을 고른 이유는 추가 파일 디스크립터를 만들 필요가 없기 때문입니다. 또한 시스템 콜 1번만 필요하므로, 많은 FD가 아니라 단일 FD를 모니터링할 때는 조금 더 효율적일 것입니다.
BSD 계열 시스템( macOS 포함 )은 kqueue() 시스템 콜을 제공합니다. 개념적으로는 select(), poll(), epoll()과 유사하지만 더 강력합니다(예: 일반 파일도 처리 가능). kqueue()에는 PID를 직접 넘길 수 있고, PID가 사라지거나 타임아웃이 만료되면 반환합니다.
pythonimport select def wait_kqueue(pid, timeout): kq = select.kqueue() kev = select.kevent( pid, filter=select.KQ_FILTER_PROC, flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, fflags=select.KQ_NOTE_EXIT, ) # 프로세스가 종료되거나 타임아웃이 발생할 때까지 블록 events = kq.control([kev], 1, timeout) if events: return raise TimeoutError
Windows는 WaitForSingleObject 덕분에 psutil과 subprocess 모듈 모두에서 바쁜 루프를 돌지 않습니다. 즉 Windows는 처음부터 사실상 이벤트 주도 방식의 프로세스 대기를 제공해 왔습니다. 그래서 이 부분은 할 일이 없습니다.
pidfd_open()과 kqueue()는 여러 이유로 실패할 수 있습니다. 예를 들어 프로세스의 파일 디스크립터가 고갈되면 EMFILE(보통 1024)로 실패할 수 있고, 시스템 관리자가 시스템 차원에서 해당 시스템 콜을 명시적으로 차단했다면(예: SECCOMP로) EACCES / EPERM으로 실패할 수 있습니다. 모든 경우에 대해 psutil은 예외를 던지기보다, 조용히 전통적인 바쁜 루프 폴링 방식으로 되돌아갑니다.
이런 “빠른 경로(fast-path) + 폴백” 접근은, 제가 2018년에 zero-copy 시스템 콜을 활용해 shutil.copyfile()을 빠르게 만들었던 BPO-33671과도 정신적으로 비슷합니다. 그때는 더 효율적인 os.sendfile()을 먼저 시도하고, 실패하면(예: 네트워크 파일시스템에서) 일반 파일 복사를 위해 기존의 read() / write() 방식으로 폴백했습니다.
간단한 실험으로, 아래는 종료하지 않고 10초 동안 자기 자신을 기다리는 프로그램입니다:
python# test.py import psutil, os try: psutil.Process(os.getpid()).wait(timeout=10) except psutil.TimeoutExpired: pass
/usr/bin/time -v로 CPU 컨텍스트 스위칭을 측정할 수 있습니다. 패치 전(바쁜 루프)에는:
console$ /usr/bin/time -v python3 test.py 2>&1 | grep context Voluntary context switches: 258 Involuntary context switches: 4
패치 후(이벤트 주도 방식)에는:
console$ /usr/bin/time -v python3 test.py 2>&1 | grep context Voluntary context switches: 2 Involuntary context switches: 1
이는 사용자 공간에서 계속 스핀하는 대신, 프로세스가 poll() / kqueue()에서 블록되고 커널이 알림을 줄 때만 깨워지기 때문에, CPU 컨텍스트 스위치가 몇 번만 발생한다는 것을 보여 줍니다.
또 흥미로운 점은 poll()(또는 kqueue())로 기다릴 때 프로세스가 time.sleep() 호출과 정확히 동일한 수면 상태에 들어간다는 것입니다. 커널 관점에서 둘 다 인터럽트 가능한 수면(interruptible sleep)입니다. 프로세스는 스케줄에서 제외(de-schedule)되고 CPU를 전혀 쓰지 않으며, 커널 공간에서 조용히 대기합니다.
아래에서 ps가 보여주는 "S+" 상태는 프로세스가 “포그라운드에서 잠든다(sleeps in foreground)”는 뜻입니다.
time.sleep():console$ (python3 -c 'import time; time.sleep(10)' & pid=$!; sleep 0.3; ps -o pid,stat,comm -p $pid) && fg &>/dev/null PID STAT COMMAND 491573 S+ python3
poll():console$ (python3 -c 'import os,select; fd = os.pidfd_open(os.getpid(),0); p = select.poll(); p.register(fd,select.POLLIN); p.poll(10_000)' & pid=$!; sleep 0.3; ps -o pid,stat,comm -p $pid) && fg &>/dev/null PID STAT COMMAND 491748 S+ python3
psutil 구현을 머지한 뒤(psutil/PR-2706), 저는 한 걸음 더 나아가 CPython의 subprocess 모듈에 대해서도 대응되는 PR을 제출했습니다: cpython/PR-144047.
저는 특히 이 PR이 자랑스럽습니다. psutil의 17년+ 역사에서 psutil에서 개발된 기능이 Python 표준 라이브러리로 “업스트림”된 것은 이번이 두 번째입니다. 첫 번째는 2011년으로, psutil.disk_usage()가 shutil.disk_usage()에 영감을 준 일이었습니다(python-ideas ML 제안 참조).
재미있는 사실: 15년 전 Python 3.3이 subprocess.Popen.wait()에 timeout 파라미터를 추가했습니다(커밋 참조). 아마도 제가 비슷한 시기 psutil의 Process.wait()에 timeout 파라미터를 처음 추가할 때도 여기에서 영감을 받았을 겁니다(커밋 참조). 이제 15년이 지난 뒤, 저는 바로 그 timeout 파라미터를 위해 비슷한 개선을 다시 기여하고 있습니다. 원이 닫혔습니다.
관련 주제:
psutil.wait_procs()).psutil.Process.wait()를 asyncio와 통합하는 제안.kqueue()를 통해 asyncio 최적화를 가능하게 하도록 selectors 모듈을 확장하자는 제안.