서브프로세스가 좀비로 남을 때, async generator를 안 닫으면 벌어지는 일

서브프로세스가 좀비로 남을

이 글에서는 async generator로 subprocess 출력을 스트리밍하다가 도중에 순회를 멈췄을 때 서브프로세스가 좀비로 남는 정확한 조건과, ps 명령으로 직접 확인하는 방법, 코드 한 줄로 막는 방법까지 확인할 수 있습니다. 원인을 먼저 짚으면, async generator의 GeneratorExitasync for 루프가 break로 끊기는 순간 곧바로 전달되지 않습니다. 그 generator를 참조하는 곳이 하나라도 남아 있으면 파이썬 가비지 컬렉터가 회수할 때까지 finally 블록 안의 proc.terminate()await proc.wait()가 실행되지 않고, 그 사이 서브프로세스는 종료돼도 회수되지 않은 채 좀비 상태로 남습니다.

이 내용은 CPython 3.10 이상, asyncio.create_subprocess_exec으로 자식 프로세스를 띄우는 Linux/macOS 환경을 전제로 씁니다. Windows는 waitpid 기반 프로세스 모델 자체가 없어 Z 상태(zombie)가 존재하지 않지만, 비슷한 성격의 핸들 미회수 문제가 다른 형태로 나타납니다.

async generator가 subprocess를 쥔 채 멈추는 순간

로그 스트리밍이나 실시간 명령 출력을 다룰 때 흔히 이런 패턴을 씁니다.

import asyncio

async def stream_lines(cmd: list[str]):
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
    )
    try:
        while True:
            line = await proc.stdout.readline()
            if not line:
                break
            yield line.decode().rstrip()
    finally:
        proc.terminate()
        await proc.wait()

async def main():
    async for line in stream_lines(["ping", "-c", "100", "127.0.0.1"]):
        print(line)
        if "1:" in line:
            break  # 100번 다 받지 않고 여기서 끊습니다

break가 실행되는 순간, stream_lines 함수 내부 실행은 yield line.decode()... 지점에서 그대로 멈춥니다. finally 블록은 아직 실행되지 않은 상태입니다. ping 프로세스는 여전히 살아 있고, 나머지 99번의 응답을 계속 보내는 중입니다. 이 함수가 다시 호출되지 않으면 누구도 이 generator를 재개시키지 않고, proc.terminate()도 언제 실행될지 알 수 없는 상태로 남습니다.

왜 곧바로 안 닫히고 좀비로 남을까요

PEP 525로 도입된 async generator는 close() 대신 aclose() 코루틴을 갖습니다. 이 aclose()가 호출돼야 내부에 GeneratorExit이 던져지고, 그제야 finally 블록이 실행됩니다. 문제는 async for 루프를 break로 빠져나오는 것만으로는 파이썬이 aclose()를 자동으로 호출해주지 않는다는 점입니다.

대신 asyncio 이벤트 루프는 sys.set_asyncgen_hooks로 등록한 finalizer 훅을 갖고 있습니다. 이 generator 객체가 더 이상 아무 곳에서도 참조되지 않아 가비지 컬렉션 대상이 되면, 훅이 발동해 loop.create_task(agen.aclose())를 예약합니다. CPython은 기본적으로 참조 카운트가 0이 되면 즉시 객체를 회수하므로, 대개는 break 직후 곧바로(다음 이벤트 루프 틱 정도) 정리가 이뤄집니다.

리눅스 터미널에서 좀비 프로세스를 ps 명령으로 확인하는 화면

문제는 이 generator에 대한 참조가 어딘가에 남아 있을 때입니다. 콜백에 클로저로 캡처됐거나, 리스트·캐시·태스크 결과 어딘가에 저장돼 있으면 참조 카운트가 0이 되지 않아 순환 가비지 컬렉터(cyclic GC)가 돌 때까지, 혹은 프로그램이 끝날 때까지 회수되지 않습니다. 이 사이 자식 프로세스가 자연 종료되면, 부모 프로세스가 wait()를 호출하지 않았기 때문에 커널은 종료 상태를 그대로 들고 있는 좀비 프로세스로 남겨둡니다.

다음 표는 상황별로 정리 시점이 어떻게 달라지는지 정리한 것입니다.

상황 finally(aclose) 실행 시점 서브프로세스 상태
async for 루프가 끝까지 소진됨 generator 함수 내부에서 즉시 terminate()+wait() 정상 수행
break 후 참조 없음 참조 카운트 0 → GC가 거의 즉시 aclose() 태스크 예약 짧은 지연 후 정리
break 후 어딘가에 참조가 남음 순환 GC 또는 루프 종료 시점까지 무기한 지연 실행 지속 또는 좀비 상태로 방치
asyncio.run() 종료 시점 loop.shutdown_asyncgens()가 강제로 aclose() 호출 그제야 일괄 정리

재현 코드로 좀비 프로세스를 직접 만들어보기

참조를 일부러 붙잡아 두면 지연을 눈으로 확인할 수 있습니다.

# zombie_repro.py
import asyncio

leaked_refs = []  # 참조를 일부러 붙잡아 둡니다

async def stream_lines(cmd):
    proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE)
    print(f"child pid: {proc.pid}")
    try:
        while True:
            line = await proc.stdout.readline()
            if not line:
                break
            yield line
    finally:
        print(f"cleanup 실행됨: pid {proc.pid}")
        proc.terminate()
        await proc.wait()

async def main():
    gen = stream_lines(["sleep", "5"])
    leaked_refs.append(gen)  # 참조가 남아버립니다
    async for _ in gen:
        break
    print("5초~6초 사이, 다른 터미널에서 ps로 확인해 보세요")
    await asyncio.sleep(6)

asyncio.run(main())

같은 서버에서 다른 터미널을 열고 아래 명령을 반복 실행해 보면, sleep 프로세스가 5초 뒤 스스로 끝난 다음에도 잠깐 STAT 칼럼이 Z로 표시되는 것을 확인할 수 있습니다.

ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'

leaked_refs가 generator를 붙들고 있어서 참조 카운트로는 회수되지 않지만, asyncio.run()이 끝나기 직전 loop.shutdown_asyncgens()가 열려 있는 generator를 강제로 닫아주기 때문에 cleanup 실행됨 로그가 마지막에 한 번은 찍힙니다. 다만 그 시점까지 좀비 상태가 그대로 유지된다는 점이 핵심입니다.

aclosing과 shutdown_asyncgens가 막아주는 범위, 못 미치는 지점

Python 3.10부터 contextlib.aclosing이 추가돼 이런 상황을 명시적으로 처리할 수 있습니다. 사용법은 contextlib.closing과 비슷합니다.

파이썬 비동기 프로그래밍 코드를 작성하는 개발자

from contextlib import aclosing

async def main():
    async with aclosing(stream_lines(["sleep", "5"])) as gen:
        async for line in gen:
            print(line)
            break
    # 이 시점에는 이미 terminate()와 wait()가 끝나 있습니다

async with 블록을 빠져나가는 순간 __aexit__gen.aclose()를 즉시 호출하므로, break든 예외든 정상 종료든 상관없이 finally 블록이 그 자리에서 실행됩니다. GC 타이밍에 의존하지 않는다는 점이 핵심입니다.

asyncio.run()이 내부적으로 호출하는 loop.shutdown_asyncgens()는 이 문제의 안전망 역할을 하지만, 프로그램 전체가 종료될 때 딱 한 번만 작동합니다. 웹 서버나 워커처럼 오래 떠 있는 프로세스에서는 이 시점이 몇 시간, 며칠 뒤일 수 있고, 그동안 정리되지 않은 subprocess가 계속 쌓입니다. 즉 shutdown_asyncgens는 “언젠가는 정리된다”는 보장이지, “제때 정리된다”는 보장은 아닙니다.

이 방법으로도 못 막는 상황이 남아 있습니다

aclosing을 쓴다고 모든 경우가 해결되지는 않습니다. finally 안의 await proc.wait()에 타임아웃이 없으면, 자식 프로세스가 SIGTERM을 무시하거나 자기 자식 프로세스를 더 만들어 두는 경우 aclose() 자체가 끝나지 않고 멈춰버립니다. async with aclosing(...) 블록도 그 안에서 멎어버리니, 실무 코드라면 아래처럼 타임아웃과 강제 종료를 함께 둬야 안전합니다.

finally:
    proc.terminate()
    try:
        await asyncio.wait_for(proc.wait(), timeout=3)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()

또한 이 글의 재현 예제는 표준 asyncio 이벤트 루프를 기준으로 합니다. uvloop처럼 C로 구현된 대체 루프를 쓰는 경우 asyncgen 훅과 자식 프로세스 회수 방식이 표준 구현과 완전히 동일하다고 문서에서 명시적으로 보장하지 않으므로, 운영 환경에 uvloop을 쓴다면 별도로 검증해 보는 편이 안전합니다. Windows에서는 ProactorEventLoop가 POSIX의 waitpid 대신 별도 메커니즘으로 자식 프로세스를 추적하기 때문에, 이 글에서 설명한 Z 상태 좀비 자체는 나타나지 않지만 프로세스 핸들이 남아 리소스가 누수되는 유사한 증상은 그대로 발생합니다.

지금 열려 있는 subprocess부터 점검해 보세요

정리하면, subprocess를 감싼 async generator를 끝까지 순회하지 않고 break나 예외로 빠져나오는 코드가 있다면, 그 호출부는 async with aclosing(...)으로 감싸져 있는지부터 확인해야 합니다. finallyawait proc.wait()에는 반드시 타임아웃을 걸어 aclose() 자체가 멎는 상황을 막아두는 편이 좋습니다.

바로 실행해볼 수 있는 점검 방법은 두 가지입니다. 코드베이스에서 create_subprocess_execPopenyield가 있는 함수 안에서 쓰는 곳을 찾아 aclosing 적용 여부를 확인하고, 운영 서버에서는 ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'를 주기적으로 돌려 실제로 좀비가 쌓이고 있는지부터 눈으로 확인해 보시기 바랍니다.

프롬프트를 코드에서 분리해 버전 관리하기, 배포 없이 고치면 생기는 문제들

Leave a Comment