동시성과 병렬성은 어떻게 다를까: Python 멀티스레드 & 멀티프로세스 실험으로 알아보기
Python thread/process 실험으로 동시성과 병렬성, 출력 순서, 실행 단위, Windows spawn, GIL을 정리합니다.
On this page
멀티스레드와 멀티프로세스 개념을 공부하다가 Python으로 직접 테스트해보면 감이 더 잘 올 것 같아 간단한 코드를 작성해봤다.
막상 코드를 돌려보니 출력 순서 외에도 중요한 포인트들이 있어서 정리해본다. 실행 단위, 메모리 공간, 운영체제 스케줄링, Windows의 spawn 방식, Python의 GIL 같은 것들이다.
Thread와 Process를 구분하기
엑셀에 비유해보자.
- 동시에 엑셀 파일을 여러 개 연다 = 멀티프로세스
- 한 엑셀 파일 안에서 데이터 정렬, 필터링, 함수 계산을 동시에 처리한다 = 멀티스레드
코드 관점에서는 이렇게 정리했다.
| 구분 | Thread | Process |
|---|---|---|
| 실행 단위 | 한 프로세스 안에서의 작업 | 독립된 프로그램 실행 단위 |
| 메모리 | 같은 프로세스의 메모리 공간을 공유 | 각자 독립된 메모리 공간을 가짐 |
| 상태 공유 | 비교적 쉽지만 동기화 필요 | 직접 공유하기 어렵고 IPC 필요 |
| 실패 영향 | 한 스레드 문제가 프로세스 전체에 영향 가능 | 한 프로세스 문제가 다른 프로세스와 분리되기 쉬움 |
| 생성 비용 | 상대적으로 가벼움 | 상대적으로 무거움 |
스레드, 프로세스 모두 겉으로는 동시에 실행되는 것처럼 보인다는 점에서 공통점이 있다고 할 수 있지만, 따져보면 차이가 있고 그 차이를 확인해보고자 하는 것이 이번 실험의 목적이었다.
첫 번째 실험: sleep 기반 작업을 thread로 실행하기
먼저 숫자를 출력하는 함수와 문자를 출력하는 함수를 각각 스레드로 실행했다.
import threading
import time
def print_numbers():
for i in range(10):
time.sleep(1)
print(i, flush=True)
def print_letters():
for letter in "가나다라마바사아자차":
time.sleep(1)
print(letter, flush=True)
t1 = threading.Thread(target=print_numbers)
t2 = threading.Thread(target=print_letters)
t1.start()
t2.start()
t1.join()
t2.join()
print("테스트 종료!")
실행하면 두 스레드가 동시적으로 출력 작업을 실시하는 것 처럼 보인다. 중요한 건 호출할 때마다 결과가 동일하지 않다. 결과 예측이 어렵다.
가
0
1
나
다
2
3
라
마
4
바
...
테스트 종료!
부연 설명을 하면 이 예제에는 time.sleep()이 들어 있으며 이는 “잠깐 기다리는 작업”이다. 이 대기 시간 동안 다른 스레드가 실행될 수 있기 때문에 스레드가 동시에 실행되는 것처럼 보이는 것이다.
하지만 이것만 보고 “Python 스레드는 항상 모든 작업을 병렬로 빠르게 처리하는구나!”라고 결론 내리긴 이르다. 여기서 확인한 건 입출력 대기 성격의 작업에서 스레드 동시 실행이 관찰되며 결과 예측이 어렵다는 정도라고 할 수 있겠다.
같은 작업을 process로 실행하기
같은 예제를 멀티프로세스로도 실행했다.
import multiprocessing
import time
def print_numbers():
for i in range(10):
time.sleep(1)
print(i, flush=True)
def print_letters():
for letter in "가나다라마바사아자차":
time.sleep(1)
print(letter, flush=True)
# 프로세스를 생성하는 작업은 메인 코드 블럭에 넣어야 한다
if __name__ == "__main__":
p1 = multiprocessing.Process(target=print_numbers)
p2 = multiprocessing.Process(target=print_letters)
p1.start()
p2.start()
p1.join()
p2.join()
print("테스트 종료!")
멀티스레드 예제와 거의 비슷해 보인다.
출력 결과는 숫자와 문자가 섞여 출력된다. 하지만 비교적 규칙적으로 p1 -> p2 순으로 출력되는 경우가 많았다.
0
가
1
나
2
다
3
라
4
마
...
테스트 종료!
중요한 건 그렇다고 순서가 보장되는 건 아니다! 이는 프로세스의 실행 순서 또한 운영체제 스케줄러, 표준 출력 버퍼, 실행 환경에 의존하고 있기 때문이다.
그렇지만 멀티프로세스에서는 스레드보다 출력 순서가 좀 더 규칙적으로 보이는 경우가 많았다. 이는 프로세스가 독립된 실행 단위이기 때문에 운영체제 스케줄러가 각 프로세스에 CPU 시간을 할당하는 방식과 관련이 있다.
thread -> 같은 프로세스 안에서 메모리를 공유하며 실행
process -> 독립된 메모리 공간을 가진 별도 프로세스로 실행
Windows multiprocessing에서 만난 RuntimeError
멀티프로세스 예제를 실행하다가 예상치 못한 에러도 만났다. 스레드 예제와 달리 프로세스 예제에는 메인 코드 블럭을 if __name__ == "__main__":로 감싸야 했다. 이걸 안 하면 Windows에서 다음과 같은 에러가 발생했다.
RuntimeError:
An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.
This probably means that you are not using fork to start your
child processes and you have forgotten to use the proper idiom
in the main module:
if __name__ == '__main__':
freeze_support()
...
처음에는 “왜 if __name__ == "__main__"를 쓰라는 거지?” 싶었다.
이유는 프로세스를 만드는 방식에 있었다.
Unix 계열에서는 새 프로세스를 만들 때 fork를 사용할 수 있다. fork는 현재 프로세스를 복제하는 방식에 가깝다. 반면 내가 사용하고 있는 Windows에서는 기본적으로 spawn 방식으로 새 프로세스를 만든다.
spawn 방식에서는 새 Python 인터프리터가 뜨고, 메인 모듈(그러니까 내가 실행한 저 코드, 파이썬 인터프리터로 직접 실행된 스크립트 파일)을 다시 import한다. 이때 프로세스를 생성하는 코드가 파일 최상단에 그대로 있으면 새 프로세스가 다시 새 프로세스를 만들려고 시도한다.
그래서 아래처럼 진입점을 보호해야 한다.
if __name__ == "__main__":
...
이 조건문의 의미는 __name__ 변수가 __main__일때만 다음 코드를 실행하라는 뜻이다. (파이썬의 스크립트가 직접 실행되면 __name__ 변수에 "__main__"이 할당되고, 만약 모듈이 임포트되면 해당 모듈의 이름이 할당된다.)
따라서 이 블록 안의 코드는 스크립트가 메인 모듈로 실행될 때만 실행된다. 새 프로세스에서 메인 모듈이 다시 로드되거나, 혹은 이 스크립트가 다른 파일로 임포트되어 실행될 때와 같은 경우에는 해당 코드가 실행되지 않는다. 원치 않는 코드 실행을 방지할 수 있고 부작용을 막을 수 있다.
처음에는 그냥 Python 문법 관용구처럼 보였는데 multiprocessing에서는 꽤 중요한 안전장치였다.
Python에서는 GIL도 고려해야 한다
여기까지 보면 thread와 process가 둘 다 동시에 잘 돌아가는 것처럼 보인다. 그런데 Python에서는 이와 관련하여 주요 개념이 하나 더 있다.
GIL(Global Interpreter Lock)이다.
일반적인 CPython 기준으로 GIL 때문에 한 프로세스 안에서는 한 번에 하나의 스레드만 Python 바이트코드를 실행한다. 그래서 CPU를 많이 쓰는 작업에서는 thread를 여러 개 만든다고 해도 여러 CPU core를 온전히 활용하기 어렵다.
물론 thread가 의미 없다는 말은 아니다.
time.sleep(), 파일 I/O, 네트워크 요청, DB 요청처럼 대기 시간이 긴 작업에서는 thread가 유용하다. 기다리는 동안 다른 작업을 진행할 수 있으니까.
하지만 CPU-bound 작업이라면 얘기가 달라진다.
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def cpu_bound_job(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
def measure(executor_cls, jobs: list[int]):
start = time.perf_counter()
with executor_cls() as executor:
list(executor.map(cpu_bound_job, jobs))
return time.perf_counter() - start
if __name__ == "__main__":
jobs = [20_000_000, 20_000_000, 20_000_000, 20_000_000]
thread_time = measure(ThreadPoolExecutor, jobs)
process_time = measure(ProcessPoolExecutor, jobs)
print(f"thread: {thread_time:.2f}s")
print(f"process: {process_time:.2f}s")
ThreadPoolExecutor: 같은 프로세스 안에서 여러 스레드가 작업한다.ProcessPoolExecutor: 여러 프로세스가 각자 Python 인터프리터를 가지고 작업한다.
내 환경에서는 대략 이런 결과가 나왔다. 숫자는 실행 환경마다 달라질 수 있다.
thread: 2.49s
process: 0.72s
결과만 보면 process가 더 빠르다. 이유는 이 작업이 계속 CPU를 쓰는 계산 작업이기 때문이다. ThreadPoolExecutor는 여러 스레드를 만들지만, CPython에서는 GIL 때문에 Python 바이트코드를 여러 스레드가 동시에 병렬로 실행하기 어렵다. 반면 ProcessPoolExecutor는 별도 프로세스를 만들기 때문에 여러 CPU core를 더 잘 활용할 수 있다.
다만 작업이 작으면 프로세스를 만드는 비용이 더 클 수 있고, 프로세스끼리 데이터를 주고받는 비용도 생긴다. 이 예제에서는 계산량이 충분히 커서 그 비용보다 병렬 처리 이득이 더 크게 나온 것이다.
언제 thread를 쓰고 언제 process를 써야 할까
대략 아래처럼 정리할 수 있겠다.
| 작업 성격 | 더 먼저 고려할 선택지 |
|---|---|
| 외부 API 호출, 파일 I/O, DB 요청처럼 기다림이 많은 작업 | thread 또는 async |
| CPU 계산이 많은 작업 | process |
| 상태를 많이 공유해야 하는 작업 | thread를 쓰되 동기화 주의 |
| 실패 격리가 중요한 작업 | process 분리 |
| 데이터 전달 비용이 큰 작업 | process 간 통신 비용까지 고려 |
Python으로 한 작은 실험이지만 서버에서 요청을 처리할 때도 비슷한 질문을 할 수 있다.
예를 들어 외부 API 호출, 파일 업로드, DB 요청처럼 대기 시간이 긴 작업은 동시성 모델이 중요하다. 요청 하나가 기다리는 동안 다른 요청을 처리할 수 있어야 한다.
반대로 이미지 처리, 대용량 데이터 변환, 암호화, 압축처럼 CPU를 많이 쓰는 작업은 조심해야 한다. 이런 작업을 한 요청에서 그대로 실행하면 다른 요청까지 밀릴 가능성이 있다. 이때는 작업을 별도 프로세스로 분리하는 방법을 검토해야 한다.
동시성과 병렬성은 어떻게 다를까
멀티스레드와 멀티프로세스를 보다 보면 동시성과 병렬성이라는 말이 같이 나온다. 처음에는 둘 다 “동시에 뭔가 하는 것”처럼 보여서 헷갈렸다.
일단 동시성은 여러 작업을 겹쳐서 다루는 능력에 가깝다. 꼭 같은 순간에 실행된다는 뜻은 아니다. 작업 A가 기다리는 동안 작업 B를 진행하고, 다시 작업 A로 돌아오는 식으로 여러 일을 번갈아 처리할 수 있으면 동시성이 있다고 볼 수 있다.
반면 병렬성은 실제로 같은 순간에 여러 작업이 실행되는 것이다. CPU 코어가 여러 개 있고, 작업들이 각각 다른 코어에서 동시에 계산되는 상황에 더 가깝다.
반대로 CPU 연산 예제에서 ProcessPoolExecutor가 ThreadPoolExecutor보다 빠르게 나온 것은 병렬성 쪽에 더 가까운 결과라고 볼 수 있다. 각 프로세스가 독립된 Python 인터프리터에서 실행되면서 여러 코어를 활용할 수 있기 때문이다.
짧게 정리하면 이렇게 볼 수 있다.
동시성 = 여러 일을 번갈아 다루는 능력
병렬성 = 여러 일을 실제로 동시에 실행하는 능력
Takeaways
- thread는 같은 프로세스 안에서 메모리를 공유한다.
- process는 독립된 메모리 공간을 가진다.
- 출력이 섞인다고 해서 바로 병렬성이라고 볼 수는 없다. 먼저 동시성인지 봐야 한다.
- 출력 순서는 스케줄러와 실행 환경에 따라 달라지므로 보장되지 않는다.
- Windows multiprocessing에서는
spawn방식 때문에if __name__ == "__main__"보호가 중요하다. - Python thread는 입출력 대기 작업에는 유용하지만, CPU-bound 작업에서는 GIL 때문에 한계가 있다.
- CPU 연산이 많은 작업은 process 분리를 검토해야 한다.
참고 자료
Keep reading
Related posts



Comments