Stage 3 · 병렬화 — Multiprocessing · 고루틴 병렬
한눈에 보기
2단계에서 다룬 I/O-집약 작업과 달리, CPU-집약(Compute-bound) 작업은 대기 시간이 아니라 연산 자체가 병목입니다. 소수 판별, 해시 계산, 이미지 필터, 행렬 연산 같은 작업입니다.
이런 작업에서의 핵심 질문:
- 파이썬은 왜 스레드로 CPU 작업을 병렬화하지 못하고, 프로세스(multiprocessing) 로 우회해야 하나?
- Go는 고루틴을 어떻게 여러 코어에 병렬로 돌리며, 그때 어떤 위험(데이터 레이스)이 따라오나?
왜 CPU-집약 작업은 병렬화가 필요한가
CPU 작업은 코어 한 개의 연산 속도가 곧 시간입니다.
- 코어 1개로 N개 작업 → 총 시간 ≈ 작업 시간의 합.
- 코어 K개로 병렬 → 총 시간 ≈ (작업 시간의 합) / K (이론상).
I/O 작업과의 결정적 차이는, CPU 작업에서는 여러 실행 단위를 띄워도 코어 수를 넘어서면 이득이 없어지고 오히려 스위치 비용만 늘어난다는 점입니다. 그래서 병렬화는 “코어 수”가 한계입니다.
Python multiprocessing — GIL 우회의 정답
스레드로는 왜 안 되는가
2단계에서 본 것처럼 파이썬의 GIL은 바이트코드를 한 번에 하나만 실행하게 합니다. 순수 CPU 연산은 GIL을 계속 붙들고 있어서, 스레드를 8개 띄워도 한 번에 한 코어만 사용합니다. 스레드는 CPU 병렬화에 아무 도움이 안 됩니다.
import threading, time
def heavy(n):
total = 0
for _ in range(n):
total += 1 # 순수 CPU 연산
return total
# 스레드 4개로 CPU 작업을 나눠도 거의 무의미 (GIL)
start = time.perf_counter()
ths = [threading.Thread(target=heavy, args=(5_000_000,)) for _ in range(4)]
for t in ths: t.start()
for t in ths: t.join()
print(f"threading: {time.perf_counter()-start:.3f}s") # ≈ 순차와 비슷하거나 느림
프로세스로 분리하면 — GIL이 프로세스마다 따로
멀티프로세싱은 각 프로세스가 자신만의 Python 인터프리터(= 자신만의 GIL) 를 가집니다. 그래서 프로세스 K개를 띄우면 서로 다른 코어에서 진짜 병렬로 실행됩니다.
from multiprocessing import Pool
import time
def heavy(n):
total = 0
for _ in range(n):
total += 1
return total
if __name__ == "__main__": # 프로세스 안전을 위해 필수
start = time.perf_counter()
with Pool(processes=4) as p: # 4개 프로세스 풀
p.map(heavy, [5_000_000] * 4)
print(f"multiprocessing: {time.perf_counter()-start:.3f}s") # ≈ 순차의 1/4
if __name__ == "__main__": 가드는 프로세스가 재귀적으로 생성되는 것을 막는 필수 방어입니다(특히 Windows / spawn 방식).
고수준: ProcessPoolExecutor
concurrent.futures.ProcessPoolExecutor는 스레드 풀과 동일한 인터페이스로 프로세스 풀을 제공합니다. ThreadPoolExecutor의 API와 같기 때문에, I/O 작업을 CPU 작업으로 바꿀 때 클래스명만 바꿔 쓰면 됩니다.
from concurrent.futures import ProcessPoolExecutor
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
nums = list(range(10_000_000, 10_000_050))
with ProcessPoolExecutor(max_workers=8) as ex:
results = list(ex.map(is_prime, nums)) # CPU 병렬
map이 결과를 입력 순서대로 모아줍니다. ThreadPoolExecutor → ProcessPoolExecutor로 바꾸는 것만으로 CPU 작업을 병렬화하는, 파이썬에서 가장 흔한 전환입니다.
주의: 프로세스 간에는 데이터를 직렬화(pickle) 해 전달합니다. 무거운 객체나 람다는 전달이 까다로울 수 있습니다. 간단한 인자/반환값을 주고받는 작업이 적합합니다.
Go 병렬화 — 고루틴 + GOMAXPROCS
고루틴은 기본적으로 병렬이 가능하다
Go에는 파이썬의 GIL 같은 것이 없습니다. 고루틴은 여러 OS 스레드(코어)에 분산될 수 있습니다. GOMAXPROCS 가 동시에 실행 가능한 OS 스레드(= 사용 코어) 수를 결정하며, 기본값은 CPU 코어 수입니다.
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func heavy(start, count int) {
sum := 0
for i := start; i < start+count; i++ {
sum += i * i // 순수 CPU 연산
}
_ = sum
}
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 보통 = 코어 수
// 코어 수만큼 고루틴을 병렬로 실행
var wg sync.WaitGroup
start := time.Now()
n := runtime.GOMAXPROCS(0)
for i := 0; i < n; i++ {
wg.Add(1)
go func(s int) {
defer wg.Done()
heavy(s, 5_000_000/n)
}(i * (5_000_000 / n))
}
wg.Wait()
fmt.Println("parallel:", time.Since(start))
}
데이터 레이스(Race Condition)의 위험
병렬로 실행되면, 여러 고루틴이 같은 변수를 동시에 읽고 쓰는 경쟁이 생길 수 있습니다. 이것이 I/O 작업과 달리 CPU 병렬에서 주의해야 할 핵심입니다.
package main
import "sync"
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 서로 다른 코어에서 동시에 읽기-쓰기 → 데이터 레이스!
}()
}
wg.Wait()
// counter는 1000이 아닐 수 있음 (레이스)
}
counter++는 “읽기 → 더하기 → 쓰기” 3단계인데, 여러 고루틴이 동시에 실행하면 그 사이에 끼어들어 값이 유실됩니다. Go는 go test -race(또는 -race 플래그)로 이런 레이스를 감지하는 내장 도구를 제공합니다.
go run -race main.go # 레이스가 있으면 경고 출력
레이스를 막는 방법은 4단계(동기화) 의 sync.Mutex/atomic, 또는 5단계(통신) 의 채널로 상태를 공유하는 것입니다. 이 시리즈의 다음 두 단계가 바로 그 해결책입니다.
병렬 고루틴 + 동시성 제한
CPU 병렬에서도 동시에 너무 많은 연산을 시작하면 오히려 느려질 수 있습니다. 작업을 코어 수만큼 나눠서 실행하는 것이 일반적입니다. 채널 세마포어를 쓰면 동시 실행 수를 제한할 수 있습니다(2단계에서 본 패턴).
// 동시 고루틴 수를 GOMAXPROCS로 제한하며 작업 실행
sem := make(chan struct{}, runtime.GOMAXPROCS(0))
실습 — 소수 판별 벤치마크 비교
같은 CPU-집약 문제(소수 판별)를 두 언어로, 그리고 각 언어의 순차 vs 병렬을 비교해 봅니다.
| 순차 | 병렬 | 속도 향상 | |
|---|---|---|---|
Python threading |
~1.0x | ~1.0x (GIL로 무의미) | 없음 |
Python ProcessPoolExecutor |
~1.0x | ~0.25x (코어 4개) | ≈ 4배 |
| Go 고루틴 (+GOMAXPROCS) | ~1.0x | ~0.25x (코어 4개) | ≈ 4배 |
핵심 포인트:
- 파이썬에서는
ThreadPoolExecutor가 아니라ProcessPoolExecutor를 써야 CPU 작업이 빨라집니다. - Go에서는 고루틴을 코어 수만큼 병렬로 돌리고,
-race로 공유 상태 레이스를 확인합니다. - 둘 다 이득의 상한은 코어 수이며, 초과하면 스위치 비용만 늘어납니다.
Summary
- CPU-집약 작업의 병렬화 한계는 코어 수이며, 그만큼만 나누는 게 효율적입니다.
- 파이썬은 GIL 때문에 스레드로는 CPU 병렬화가 안 되고,
multiprocessing/ProcessPoolExecutor로 프로세스를 띄워 각자 GIL을 갖게 해야 합니다. - Go는 GOMAXPROCS(기본 = 코어 수) 만큼 고루틴을 병렬로 분산합니다.
- 병렬 CPU 작업의 주된 위험은 데이터 레이스이며, Go는
-race로 탐지합니다. 해결책은 4단계(동기화)·5단계(통신). - 파이썬 프로세스 간 데이터는 pickle 직렬화로 전달되므로 단순한 값을 주고받는 작업이 적합합니다.
다음 학습 (Next Learning)
- Stage 4 · 동기화 — 병렬 실행에서 생기는
counter레이스를Lock/sync.Mutex로 막는 방법을 다룹니다. - Stage 5 · 통신 — CPU 병렬 작업을 나누고 모으는 데 유용한
multiprocessing.Queue와 Go 채널select를 다룹니다. - Python GIL — 멀티프로세싱이 왜 GIL을 우회하는지 그 원리를 다시 확인.