← 파이썬 강의 목록으로
🚀
고급 (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)   # 400000

Lock 없으면 결과가 작거나 가변적입니다.

자주 하는 실수

  1. CPU 작업에 thread 사용 — GIL 때문에 느려질 수도
  2. __main__ 가드 누락(Windows + multiprocessing) — 무한 루프
  3. 공유 자원에 Lock 누락 — 결과 비결정적
  4. Future 결과를 안 받음 — 예외가 묻힘

FAQ

Q1. asyncio 와 비교? — asyncio는 단일 스레드 협력적. threading은 OS 스케줄링. I/O 가벼우면 asyncio.

Q2. multiprocessing 단점? — 프로세스 시작 비용. 작은 작업은 오히려 느림. 객체 직렬화(pickle) 필요.

💻 예제 (examples)

실제로 실행해 결과를 확인할 수 있는 예제 코드입니다.

01_thread_pool.pyThreadPoolExecutor (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.pyProcessPoolExecutor (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.pythreading.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)
▶ 실행 결과
400000
04_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
예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗