본문으로 건너뛰기

[CPython] subprocess.Popen.wait() 이벤트 기반 구현으로 효율성 개선

PR 링크: python/cpython#144047 상태: Merged | 변경: +301 / -5

들어가며

subprocess.Popen.wait(timeout=N)은 POSIX 시스템에서 waitpid(WNOHANG) + time.sleep()을 반복하는 busy loop으로 구현되어 있었다. 이 방식은 CPU를 불필요하게 소모하고, sleep 간격 때문에 프로세스 종료 감지에 최대 수백 밀리초의 지연이 발생한다. 이 PR은 Linux의 pidfd_open() + poll()과 macOS/BSD의 kqueue() + KQ_FILTER_PROC을 활용해 이벤트 기반 대기로 전환한다.

핵심 코드 분석

플랫폼 지원 감지

# Lib/subprocess.py
def _can_use_pidfd_open():
    # Availability: Linux >= 5.3
    if not hasattr(os, "pidfd_open"):
        return False
    try:
        pidfd = os.pidfd_open(os.getpid(), 0)
    except OSError as err:
        if err.errno in {errno.EMFILE, errno.ENFILE}:
            return True  # transitory 'too many open files'
        return False  # blocked by SECCOMP etc.
    else:
        os.close(pidfd)
        return True

def _can_use_kqueue():
    # Availability: macOS, BSD
    names = ("kqueue", "KQ_EV_ADD", "KQ_EV_ONESHOT",
             "KQ_FILTER_PROC", "KQ_NOTE_EXIT")
    if not all(hasattr(select, x) for x in names):
        return False
    # ... kqueue 실제 동작 검증 ...
    return True

_CAN_USE_PIDFD_OPEN = not _mswindows and _can_use_pidfd_open()
_CAN_USE_KQUEUE = not _mswindows and _can_use_kqueue()

모듈 로드 시점에 한 번만 감지하고 결과를 캐싱한다. SECCOMP 정책으로 차단된 환경이나 EMFILE 같은 일시적 오류도 올바르게 처리한다.

pidfd_open 기반 대기 (Linux >= 5.3)

Before:

def _wait(self, timeout):
    if self.returncode is not None:
        return self.returncode
    if timeout is not None:
        endtime = _time() + timeout
        # busy loop with exponential backoff
        delay = 0.0005
        while True:
            (pid, sts) = self._try_wait(os.WNOHANG)
            if pid == self.pid:
                self._handle_exitstatus(sts)
                return self.returncode
            remaining = endtime - _time()
            if remaining <= 0:
                raise TimeoutExpired(self.args, timeout)
            delay = min(delay * 2, remaining, .05)
            time.sleep(delay)

After:

def _wait_pidfd(self, timeout):
    if not _CAN_USE_PIDFD_OPEN:
        return False
    try:
        pidfd = os.pidfd_open(self.pid, 0)
    except OSError:
        return False
    try:
        poller = select.poll()
        poller.register(pidfd, select.POLLIN)
        events = poller.poll(timeout * 1000)
        if not events:
            raise TimeoutExpired(self.args, timeout)
        return True
    finally:
        os.close(pidfd)

pidfd_open()으로 프로세스 file descriptor를 얻고, select.poll()로 프로세스 종료를 이벤트 기반으로 대기한다. busy loop이 완전히 제거된다.

kqueue 기반 대기 (macOS/BSD)

def _wait_kqueue(self, timeout):
    if not _CAN_USE_KQUEUE:
        return False
    try:
        kq = select.kqueue()
    except OSError:
        return False
    try:
        kev = select.kevent(
            self.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 not events:
            raise TimeoutExpired(self.args, timeout)
        return True
    finally:
        kq.close()

macOS/BSD에서는 kqueueKQ_FILTER_PROC + KQ_NOTE_EXIT로 프로세스 종료를 감지한다. 마찬가지로 이벤트 기반이므로 CPU를 낭비하지 않는다.

Graceful Fallback 구조

def _wait(self, timeout):
    if self.returncode is not None:
        return self.returncode
    if timeout is not None:
        if timeout < 0:
            raise TimeoutExpired(self.args, timeout)
        started = _time()
        endtime = started + timeout

        # Try efficient wait first
        if self._wait_pidfd(timeout) or self._wait_kqueue(timeout):
            with self._waitpid_lock:
                if self.returncode is not None:
                    return self.returncode
                (pid, sts) = self._try_wait(os.WNOHANG)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
                    return self.returncode
                # Rare race: fallback to busy polling
                elapsed = _time() - started
                endtime -= elapsed

        # Fallback: busy loop (same as before)
        delay = 0.0005
        # ...

이벤트 기반 대기를 먼저 시도하고, 실패하면 기존 busy loop으로 fallback한다. PID 재사용 race condition도 WNOHANG으로 안전하게 처리한다.

왜 이게 좋은가

  1. CPU 사용량 감소: busy loop의 반복적인 sleep() + waitpid(WNOHANG) 호출 대신 커널 이벤트 알림을 사용한다.
  2. 즉시 감지: 프로세스 종료 시 busy loop의 sleep 간격(최대 50ms)만큼 기다릴 필요 없이 즉시 감지한다.
  3. 하위 호환성: 이벤트 기반 메커니즘을 사용할 수 없는 환경에서는 기존 방식으로 자동 fallback한다.
  4. Windows는 변경 없음: Windows는 이미 WaitForSingleObject를 사용하고 있어 변경이 불필요하다.

정리

이 PR은 7년 이상 된 이슈(#83069)를 해결한다. Linux 5.3+의 pidfd_open과 macOS/BSD의 kqueue라는 OS 수준 메커니즘을 활용해 subprocess.Popen.wait()의 효율성을 크게 개선했다. "가능하면 효율적으로, 안되면 기존 방식으로"라는 graceful degradation 설계가 표준 라이브러리다운 견고한 접근이다.

참고 자료

알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글