Stage 3 · 병렬화 — Multiprocessing · 고루틴 병렬

병렬화 — CPU-집약 작업을 여러 코어에 분산해 진짜 동시 실행으로 처리량을 높임 코어에 병렬 분산 코어 0 — P/goroutine A연산 묶음 1 코어 1 — P/goroutine B연산 묶음 2 코어 2 — P/goroutine C연산 묶음 3 코어 3 — P/goroutine D연산 묶음 4 Python: 스레드는 GIL 때문에 한 코어만 사용 → 프로세스로 분리해야 병렬 Go: 고루틴을 GOMAXPROCS 만큼 코어에 분산 단, 공유 상태 접근 시 race 주의 순차 시간 (길수록 느림) 병렬 ≈ 1/4로 단축
CPU-집약 작업은 **멀티 코어를 실제로 병렬 사용**할 때만 이득입니다. Python은 GIL 때문에 스레드로는 안 되고 **프로세스(multiprocessing)** 로 분리해야 하며, Go는 고루틴을 코어 수만큼 분산하되 **데이터 레이스**에 주의해야 합니다. 병렬로 돌리면 순차 대비 대략 코어 수만큼 빨라집니다.

한눈에 보기

2단계에서 다룬 I/O-집약 작업과 달리, CPU-집약(Compute-bound) 작업은 대기 시간이 아니라 연산 자체가 병목입니다. 소수 판별, 해시 계산, 이미지 필터, 행렬 연산 같은 작업입니다.

이런 작업에서의 핵심 질문:

  1. 파이썬은 왜 스레드로 CPU 작업을 병렬화하지 못하고, 프로세스(multiprocessing) 로 우회해야 하나?
  2. 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이 결과를 입력 순서대로 모아줍니다. ThreadPoolExecutorProcessPoolExecutor로 바꾸는 것만으로 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을 우회하는지 그 원리를 다시 확인.