CS 기초
운영체제·동시성
프로세스와 스레드: 차이, GIL, 자식 프로세스 정리

프로세스와 스레드: 차이, GIL, 자식 프로세스 정리

들어가며

파이썬으로 이미지 변환 작업을 병렬로 돌리려고 스레드를 여러 개 띄웠는데, 한 개로 돌릴 때와 시간이 거의 같았던 적이 있으신가요? 반대로 작업을 취소했는데 뒤에서 외부 프로그램이 계속 돌며 CPU 를 쓰고 있던 적도 있을 겁니다.

두 경우 모두 프로세스와 스레드가 무엇을 나누고 무엇을 공유하는지에서 출발합니다. 이번 글에서는 두 실행 단위의 차이, 파이썬에서 고르는 기준, 자식 프로세스를 안전하게 끝내는 방법까지 다룹니다.


1. 프로세스와 스레드: 메모리를 나누느냐, 공유하느냐

프로세스는 운영체제가 메모리 공간과 파일 핸들 같은 자원을 따로 내주는 독립 실행 단위입니다. 스레드는 한 프로세스 안에서 그 자원을 함께 쓰는 실행 흐름입니다. 한 프로세스에 스레드가 여러 개 있을 수 있습니다.

부엌에 비유하면 이해가 쉽습니다.

프로세스는 부엌이 따로 있는 집, 스레드는 한 부엌을 같이 쓰는 요리사예요.

프로세스 A부엌 공유
스레드 1·2각자 스택·레지스터
힙·전역함께 씀동기화 필요
파일·소켓함께 씀
프로세스 B부엌 따로
스레드 1자기 스택
힙·전역A와 분리
파일·소켓A와 분리
운영체제스케줄링 · IPC 통로
한 프로세스 안의 스레드는 힙을 같이 써서 데이터를 바로 나누지만, 락 같은 동기화가 필요하다. 프로세스끼리는 메모리가 갈라져 안전한 대신, 데이터를 넘기려면 IPC 비용을 낸다.

한 부엌을 같이 쓰는 요리사들은 재료를 바로 나눠 쓸 수 있습니다. 대신 같은 냄비를 동시에 잡지 않도록 순서를 정해야 하는데, 이것이 동기화(락 등)입니다.

집이 다르면 부딪힐 일은 없지만, 재료를 건네려면 따로 포장해서 보내야 합니다. 프로세스 간 통신(IPC)에 비용이 드는 이유입니다.

정리하면 스레드는 가볍고 데이터 공유가 쉬운 대신 동기화 부담이 있습니다. 프로세스는 격리가 강한 대신 생성과 통신 비용이 큽니다. 한 스레드가 메모리를 망가뜨리면 같은 프로세스 전체가 영향을 받지만, 프로세스 하나가 죽어도 다른 프로세스는 살아 있습니다.

2. 파이썬에서 고르는 기준: GIL

기본 CPython 에는 GIL(Global Interpreter Lock)이 있습니다. 한 프로세스 안에서 한 번에 한 스레드만 파이썬 바이트코드를 실행하게 하는 잠금입니다. 그래서 계산만 하는 CPU 작업은 스레드를 늘려도 빨라지지 않습니다. 들어가며의 사례가 이 경우입니다.

반면 다운로드나 API 호출처럼 기다리는 시간이 긴 I/O 작업은 스레드로도 빨라집니다. I/O 를 기다리는 동안 스레드가 GIL 을 내려놓고, 그 사이 다른 스레드가 실행되기 때문입니다.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
 
# I/O 대기 (다운로드·API 호출) → 스레드
with ThreadPoolExecutor() as ex:
    list(ex.map(download, urls))
 
# CPU 계산 (인코딩·압축) → 프로세스
if __name__ == "__main__":
    with ProcessPoolExecutor() as ex:
        list(ex.map(encode, files))

list() 로 결과를 받아야 작업 안에서 난 예외가 묻히지 않습니다. 프로세스 풀은 자식이 메인 모듈을 다시 불러오는 시작 방식(spawn, forkserver)에서 if __name__ == "__main__": 보호가 필요합니다. macOS·Windows 는 spawn 이 기본이고, 리눅스도 3.14 부터 forkserver 가 기본이므로 운영체제와 관계없이 넣어 두면 됩니다.

Python 3.13 부터 GIL 을 끈 free-threaded 빌드를 실험적으로 제공하고, 3.14 에서 공식 지원 단계가 되었습니다. 다만 별도로 골라 설치하는 빌드이고 기본 배포판에는 여전히 GIL 이 있습니다.

3. 자식 프로세스는 부모를 따라 멈추지 않는다

프로세스가 독립 실행 단위라는 성질은 정리할 때도 드러납니다. asyncio 에서 외부 프로그램을 자식 프로세스로 띄운 뒤 그 태스크를 취소하면, 끝나는 것은 기다리던 코루틴뿐입니다. 자식 프로세스는 계속 돌면서 CPU·GPU 와 열어 둔 파일을 쥐고 있습니다.

자식 프로세스는 따로 불러 놓은 출장 요리사예요. 주문을 취소해도 직접 알려야 멈춰요.

주문만 취소계속 돎
📋호출자task.cancel()🐍부모 코드코루틴🎞️자식ffmpeg① cancel()취소 예외 발생② 취소 끝계속 실행 · 자원점유

끝나는 것은 기다리던 코루틴뿐이다. 자식은 고아가 되어 계속 돈다.

요리사에게 통보정리됨
📋호출자task.cancel()🐍부모 코드코루틴🎞️자식ffmpeg① cancel()② SIGTERM유예 시간 대기③ SIGKILL(남았으면)④ 종료 코드⑤ 취소 끝

정중히 끝내 달라고 한 뒤, 안 끝나면 강제로 끝내고 wait 로 거둔다.

프로세스는 독립 실행 단위라 부모 코드가 멈춰도 저절로 끝나지 않는다. 취소·타임아웃 경로마다 종료 신호와 회수를 넣어야 GPU·파일 점유가 남지 않는다.

주문만 취소하고 출장 요리사에게 알리지 않으면 가스 불은 계속 켜져 있습니다. 그래서 취소나 타임아웃 경로에서 직접 종료 신호를 보내고, 끝난 프로세스를 거둬야 합니다.

proc = await asyncio.create_subprocess_exec(*cmd)
try:
    await proc.wait()
except BaseException:              # 취소(CancelledError) 포함
    if proc.returncode is None:
        proc.terminate()           # SIGTERM
        try:
            await asyncio.wait_for(proc.wait(), 5)
        except asyncio.TimeoutError:
            proc.kill()            # SIGKILL
            await proc.wait()
    raise                          # 취소는 삼키지 않는다

CancelledError 는 3.8 부터 Exception 이 아니라 BaseException 을 상속합니다. 그래서 except Exception 으로는 취소를 잡지 못합니다. 마지막의 raise 로 취소를 다시 올려 보내야 호출한 쪽도 취소된 것을 압니다.

4. SIGTERM 다음에 SIGKILL

SIGTERM 은 프로세스가 받아서 처리할 수 있는 종료 요청입니다. 임시 파일을 지우거나 쓰던 파일을 닫는 마무리를 할 기회가 있습니다. SIGKILL 은 막거나 무시할 수 없는 즉시 종료라 정리할 틈이 없습니다.

그래서 먼저 SIGTERM 을 보내고, 정해 둔 유예 시간 안에 끝나지 않을 때만 SIGKILL 을 보냅니다. docker stop 도 같은 순서입니다. SIGTERM 을 보내고 기본 10초를 기다린 뒤 SIGKILL 을 보냅니다.

한눈에 비교

프로세스스레드
메모리각자 따로프로세스 안에서 공유
생성 비용큼작음
데이터 주고받기IPC·직렬화 필요바로 공유(동기화 필요)
장애 격리강함한 스레드 문제가 전체에 번짐
파이썬 CPU 작업병렬로 빨라짐GIL 때문에 거의 그대로

헷갈리기 쉬운 점

  • 스레드를 늘리면 파이썬 CPU 작업이 빨라진다는 오해. 기본 CPython 에서는 GIL 때문에 빨라지지 않습니다. CPU 작업은 멀티프로세스로 나눕니다.
  • 태스크를 취소하면 그 태스크가 띄운 프로세스도 끝난다는 오해. 끝나는 것은 기다리던 코루틴입니다. 프로세스는 직접 신호를 보내 끝내야 합니다.
  • 부모 프로세스가 죽으면 자식도 같이 죽는다는 오해. 리눅스에서 자식은 그대로 남아 고아 프로세스가 됩니다. 함께 끝내려면 별도 처리가 필요합니다.

면접에서 자주 받는 질문

GIL 이 있는데 왜 I/O 작업에는 스레드가 효과가 있나요?

I/O 를 기다리는 동안 스레드가 GIL 을 내려놓기 때문입니다. 그 사이 다른 스레드가 실행되어 기다리는 시간이 겹치고, 전체 시간이 줄어듭니다.

멀티프로세스의 비용은 무엇인가요?

프로세스마다 메모리를 따로 쓰고, 데이터를 넘길 때 직렬화(pickle) 비용이 듭니다. 시작 시간도 스레드보다 깁니다.

좀비 프로세스와 고아 프로세스는 어떻게 다른가요?

좀비는 이미 끝났지만 부모가 wait 로 종료 상태를 거두지 않아 프로세스 표에 남은 것입니다. 고아는 부모가 먼저 끝난 자식으로, init(PID 1)이 새 부모가 됩니다. 컨테이너에서는 PID 1 이 자식을 거두지 않으면 좀비가 쌓이기 때문에 tini 같은 작은 init 을 씁니다.

SIGTERM 과 SIGKILL 은 왜 순서대로 보내나요?

SIGTERM 은 프로세스가 받아서 마무리 작업을 할 수 있고, SIGKILL 은 정리 기회 없이 즉시 끝냅니다. 그래서 먼저 정중히 요청하고, 유예 시간이 지나도 끝나지 않을 때만 강제로 끝냅니다.

정리

  • 프로세스는 메모리를 따로 쓰는 독립 실행 단위, 스레드는 한 프로세스 안에서 메모리를 공유하는 실행 흐름입니다.
  • 기본 CPython 에서는 CPU 작업은 멀티프로세스, I/O 작업은 스레드나 asyncio 로 나눕니다.
  • 자식 프로세스는 부모의 취소를 따라 멈추지 않습니다. SIGTERM, 유예, SIGKILL 순으로 끝내고 wait 로 거둡니다.

메모리를 따로 쓰면 프로세스, 같이 쓰면 스레드입니다. 따로 사는 프로세스는 부모가 멈춰도 알아서 멈추지 않으니, 신호를 보내고 거두는 것까지가 한 세트입니다.