🚀
고급 (Advanced)
GIL · ThreadPoolExecutor · ProcessPoolExecutor · Lock
4주차 — 동시성 (1) 스레딩과 멀티프로세싱
I/O 바운드 작업은 ThreadPoolExecutor 로, CPU 바운드는 ProcessPoolExecutor 로 — GIL 의 의미와 동시성 도구의 선택 기준을 익힙니다.
threadingmultiprocessingGILconcurrent.futures
소요 시간
⏱ 2시간
난이도
📊 고급
선수 조건
🎯 고급 3주차
결과물
I/O·CPU 작업에 맞는 동시성 도구 선택
이 강의에서 배우는 것
- 1I/O 바운드 vs CPU 바운드 작업을 구분한다
- 2ThreadPoolExecutor 로 I/O 작업을 가속한다
- 3ProcessPoolExecutor 로 CPU 작업을 가속한다
- 4threading.Lock 으로 동기화한다
1. GIL (Global Interpreter Lock)
CPython은 한 번에 하나의 스레드만 파이썬 바이트코드를 실행 → CPU 바운드는 스레딩으로 가속 X. I/O 작업(네트워크, 디스크) 에서는 효과 있음.
| 작업 종류 | 도구 |
|---|---|
| I/O 바운드 (네트워크, 파일) | threading, asyncio |
| CPU 바운드 (계산, 인코딩) | multiprocessing |
2. ThreadPoolExecutor
python
from concurrent.futures import ThreadPoolExecutor
import requests
urls = ["https://example.com"] * 10
def fetch(url):
return len(requests.get(url).text)
with ThreadPoolExecutor(max_workers=5) as ex:
results = list(ex.map(fetch, urls))순차 실행 대비 5배 정도 빨라집니다(네트워크 대기 동안 다른 작업).
3. ProcessPoolExecutor
python
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # Windows는 필수
with ProcessPoolExecutor() as ex:
print(list(ex.map(heavy, [1_000_000] * 4)))4. submit / as_completed
python
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor() as ex:
futures = [ex.submit(fetch, url) for url in urls]
for fut in as_completed(futures):
print(fut.result())5. threading.Lock
여러 스레드가 같은 자원을 변경할 때 데이터 깨짐 방지.
python
import threading
counter = 0
lock = threading.Lock()
def inc():
global counter
for _ in range(100_000):
with lock:
counter += 1
ts = [threading.Thread(target=inc) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(counter) # 400000Lock 없으면 결과가 작거나 가변적입니다.
자주 하는 실수
- CPU 작업에 thread 사용 — GIL 때문에 느려질 수도
- __main__ 가드 누락(Windows + multiprocessing) — 무한 루프
- 공유 자원에 Lock 누락 — 결과 비결정적
- Future 결과를 안 받음 — 예외가 묻힘
FAQ
Q1. asyncio 와 비교? — asyncio는 단일 스레드 협력적. threading은 OS 스케줄링. I/O 가벼우면 asyncio.
Q2. multiprocessing 단점? — 프로세스 시작 비용. 작은 작업은 오히려 느림. 객체 직렬화(pickle) 필요.
💻 예제 (examples)
실제로 실행해 결과를 확인할 수 있는 예제 코드입니다.
01_thread_pool.py— ThreadPoolExecutor (I/O)
CODE
from concurrent.futures import ThreadPoolExecutor
import time
def fake_io(i):
time.sleep(0.5)
return i * 10
with ThreadPoolExecutor(max_workers=5) as ex:
results = list(ex.map(fake_io, range(5)))
print(results)▶ 실행 결과
[0, 10, 20, 30, 40]02_process_pool.py— ProcessPoolExecutor (CPU)
CODE
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
with ProcessPoolExecutor() as ex:
results = list(ex.map(heavy, [100_000, 200_000, 300_000]))
print(results)▶ 실행 결과
[333328333350000, 2666646666700000, 8999955000050000]03_lock.py— threading.Lock
CODE
import threading
counter = 0
lock = threading.Lock()
def inc():
global counter
for _ in range(100_000):
with lock:
counter += 1
ts = [threading.Thread(target=inc) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(counter)▶ 실행 결과
40000004_compare.py— 순차 vs 스레드 시간 비교
CODE
import time
from concurrent.futures import ThreadPoolExecutor
def task(i):
time.sleep(0.5)
return i
start = time.perf_counter()
[task(i) for i in range(5)]
print(f"순차: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as ex:
list(ex.map(task, range(5)))
print(f"스레드: {time.perf_counter() - start:.2f}s")▶ 실행 결과
순차: 2.50s
스레드: 0.50s📝 과제 (exercises)
직접 풀어보고, 막힐 때 정답을 펼쳐 비교해보세요.
과제 1
동시 다운로드 시간 비교
목표: 여러 URL 다운로드를 순차/스레드로 비교한다.
요구사항
- ThreadPoolExecutor 사용
- 순차 시간과 스레드 시간 출력
💡 힌트
time.sleep 으로 네트워크 흉내
max_workers=5
입출력 예시
순차: 5.0s
스레드: 1.1s채점
- · 스레드 가속 확인
- · 시간 출력
▶정답 코드 펼치기 / 접기
SOLUTION
import time
from concurrent.futures import ThreadPoolExecutor
def fetch(i):
time.sleep(1)
return i
urls = list(range(5))
start = time.perf_counter()
[fetch(u) for u in urls]
print(f"순차: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as ex:
list(ex.map(fetch, urls))
print(f"스레드: {time.perf_counter() - start:.1f}s")▶ 실행 결과
순차: 5.0s
스레드: 1.0s과제 2
CPU 작업 프로세스 풀
목표: 큰 합 계산을 ProcessPoolExecutor 로 가속한다.
요구사항
- if __name__ == '__main__': 가드
- 결과 출력
💡 힌트
heavy 함수 정의를 모듈 최상위에
입출력 예시
[333328333350000, 333328333350000, 333328333350000]채점
- · __main__ 가드
- · 결과 list
▶정답 코드 펼치기 / 접기
SOLUTION
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
with ProcessPoolExecutor() as ex:
results = list(ex.map(heavy, [100_000] * 3))
print(results)▶ 실행 결과
[333328333350000, 333328333350000, 333328333350000]과제 3
Lock 적용 전후
목표: 공유 카운터에 Lock 적용 전후를 비교한다.
요구사항
- Lock 없이 / Lock 있이 두 번 실행
- 최종 값 출력
💡 힌트
증가량이 정확하면 Lock 적용 성공
입출력 예시
Lock 없음: 알 수 없음
Lock 있음: 400000채점
- · 두 번 실행
- · 차이 관찰
▶정답 코드 펼치기 / 접기
SOLUTION
import threading
def run(use_lock):
counter = 0
lock = threading.Lock()
def inc():
nonlocal counter
for _ in range(100_000):
if use_lock:
with lock:
counter += 1
else:
counter += 1
ts = [threading.Thread(target=inc) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
return counter
print("Lock 없음:", run(False))
print("Lock 있음:", run(True))▶ 실행 결과
Lock 없음: 218394
Lock 있음: 400000