프로세스와 스레드: 차이, GIL, 자식 프로세스 정리
들어가며
파이썬으로 이미지 변환 작업을 병렬로 돌리려고 스레드를 여러 개 띄웠는데, 한 개로 돌릴 때와 시간이 거의 같았던 적이 있으신가요? 반대로 작업을 취소했는데 뒤에서 외부 프로그램이 계속 돌며 CPU 를 쓰고 있던 적도 있을 겁니다.
두 경우 모두 프로세스와 스레드가 무엇을 나누고 무엇을 공유하는지에서 출발합니다. 이번 글에서는 두 실행 단위의 차이, 파이썬에서 고르는 기준, 자식 프로세스를 안전하게 끝내는 방법까지 다룹니다.
1. 프로세스와 스레드: 메모리를 나누느냐, 공유하느냐
프로세스는 운영체제가 메모리 공간과 파일 핸들 같은 자원을 따로 내주는 독립 실행 단위입니다. 스레드는 한 프로세스 안에서 그 자원을 함께 쓰는 실행 흐름입니다. 한 프로세스에 스레드가 여러 개 있을 수 있습니다.
부엌에 비유하면 이해가 쉽습니다.
프로세스는 부엌이 따로 있는 집, 스레드는 한 부엌을 같이 쓰는 요리사예요.
한 부엌을 같이 쓰는 요리사들은 재료를 바로 나눠 쓸 수 있습니다. 대신 같은 냄비를 동시에 잡지 않도록 순서를 정해야 하는데, 이것이 동기화(락 등)입니다.
집이 다르면 부딪힐 일은 없지만, 재료를 건네려면 따로 포장해서 보내야 합니다. 프로세스 간 통신(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 와 열어 둔 파일을 쥐고 있습니다.
자식 프로세스는 따로 불러 놓은 출장 요리사예요. 주문을 취소해도 직접 알려야 멈춰요.
끝나는 것은 기다리던 코루틴뿐이다. 자식은 고아가 되어 계속 돈다.
정중히 끝내 달라고 한 뒤, 안 끝나면 강제로 끝내고 wait 로 거둔다.
주문만 취소하고 출장 요리사에게 알리지 않으면 가스 불은 계속 켜져 있습니다. 그래서 취소나 타임아웃 경로에서 직접 종료 신호를 보내고, 끝난 프로세스를 거둬야 합니다.
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 로 거둡니다.
메모리를 따로 쓰면 프로세스, 같이 쓰면 스레드입니다. 따로 사는 프로세스는 부모가 멈춰도 알아서 멈추지 않으니, 신호를 보내고 거두는 것까지가 한 세트입니다.