Concurrency vs Parallelism
concurrency랑parallelism은 여러 개의 작업을 동시에 돌리는 것이라고 이해하기 쉽지만 다르다.- concurrency(동시성)은 여러 개의 작업의 진행 시간이 겹치도록(overlap) 하는 것이고, parallelism(병렬성)은 여러 개의 작업을 실제로 물리적으로 실행하는 것이다.
음식점을 예시로 들어보면
- 직원 1명이 파스타에 물 올려놓고, 물 끓는 동안 소스를 만들고, 소스 졸이는 동안 샐러드를 준비하는 것은 ‘동시성’ 이다. 직원이 실제로는 물리적으로 여러 개의 일을 동시에 실행하진 않지만, 결과적으로 보면 파스타에 물 올리기, 소스 만들기, 샐러드 준비하기 3가지가 진행되는 시간이 겹친다.
- 직원 3명이 1명씩 각각 파스타, 소스, 샐러드를 동시에 만드는 것. 3개의 작업이 진짜로 동시에 실행된다.
그렇다면,
- 작업들이 실제 일을 하는 시간보다, 기다리는 시간이 대부분이라면? -> concurrency
- 작업들이 실제 일을 하는 시간이 대부분이라면? -> parallelism
컴퓨터에서 보면,
- 코어 = 직원, 작업 = thread
- 실제로 일을 하는 것 = cpu에서 명령어가 처리되는 것
- 네트워크 요청 등은 cpu에서 명령어가 처리되는 시간보다 i/o를 기다리는 시간이 훨씬 길기 때문에 cpu가 놀게 된다 -> concurrency가 유리
- 이미지 리사이징 같은 작업들은 cpu가 계속 일을 해야 한다 -> parallelism이 유리
Python에서의 동시성과 병렬성
파이썬은 동시성과 병렬성이 분리되어 있다!
- 동시성은 asyncio/threading을 이용해서 구현되고, 병렬성은 multiprocessing으로 구현된다
- threading은 실제 os thread를 쓰므로 서로 다른 코어에서 실행(병렬성)될 것 같지만, 사실은 파이썬의 GIL로 인해 동시에 하나의 코어에서만 실행된다. 그러므로 사실상 동시성을 구현하는 메커니즘으로 보아야 한다.
multiprocessing
제일 단순하다! 여러 개의 프로세스를 띄워서 실행하는 것이다. python 프로그램을 여러 개 돌리는 것과 사실상 똑같다. 멀티코어 프로세서라면, 각 프로세스가 여러 코어에 분배되어서 실행된다.
import multiprocessing as mp
def compute_intensive_func(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
with mp.Pool(4) as p:
results = p.map(compute_intensive_func, [10**7] * 4)
장점
- 별도의 OS 프로세스로 실행이 되고, 각각 독립된 파이썬 인터프리터와 메모리 공간을 가지니까 GIL 제약이 없다.
- 실제로 cpu를 여러개를 사용해서 이득을 볼 수 있는 것 (이미지 변환, 계산 등)을 할 때 효과적이다. (compute-bound task)
단점은
- 프로세스가 생성되니까 일단 무겁다. 프로세스별로 파이썬 인터프리터가 하나씩 뜨니까, worker 하나당 최소 수십MB 는 먹는다.
- worker가 4개면 메모리도 4배로 늘어난다. 물론 이건 SharedMemory 사용하면 줄일 수 있지만, 여전히 동기화는 직접 해야한다.
- 서로 완전히 다른 주소공간을 가지니까, 데이터를 주고 받으려면 serialization이 필요하다.
- pickle 등으로 파이썬 객체들을 serialize하면 내부의 pointer reference들은 다 날아간다.
threading
- OS의 thread을 이용하는 방식이다.
- 원래는 thread도 여러 코어에 분배되어서 실행되지만, GIL 때문에 실제로는 물리적으로 동시에 실행되진 못한다.
예시:
import threading
import requests
urls = ["https://example.com"] * 10
def fetch(url):
return requests.get(url)
# 순차 실행: ~10초
# 스레드 실행: ~1초
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
장점
- 프로세스보다 훨씬 가볍다. 많이 만들어도 os에서 부담이 적다
- 같은 주소 공간을 쓴다
- serialization 없이 파이썬 객체도 바로 공유된다!
- 디버깅도 쉽다. pdb로 한 프로세스에서 디버깅 가능
- i/o bound에 적합하다
- cpu를 heavy하게 쓰는 작업이 아니면ss
단점
- GIL 때문에 진짜 cpu를 병렬로 쓸 수 없다.
- cpu를 많이 쓰는 작업은 안 쓰니만 못할 수 있다 (context switching 때문에)
- 한 쓰레드에서 segfault로 죽으면 프로세스 전체가 같이 죽는다. (isolation level이 낮다)
- 같은 메모리 공간을 쓰니 동기화 버그의 위험이 있다. 여러 스레드가 같은 객체를 동시에 수정하면 race condition 발생.
- 프로세스보단 훨씬 낫지만, 쓰레드를 많이(수천개 이상) 만들면 os scheduler와 메모리에 부담이 간다.
- 보통 쓰레드당 수 MB 정도 쓴다.
Race Condition 예시:
import threading
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # read-modify-write, 원자적이지 않음
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 2,000,000이 아닌 경우가 생김
asyncio
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, "https://example.com") for _ in range(10)]
results = await asyncio.gather(*tasks)
asyncio.run(main())
장점
- 매우 가볍다. coroutine은 거의 수 KB 수준.
- 수만개 이상의 동시 작업도 단일 스레드에서 처리 가능하다.
- context switch 비용이 거의 없다
단점
- 기존 코드베이스에 async 함수가 아닌 코드베이스와의 통합이 어렵다.
- 같은 라이브러리도 async 버전으로 파편화됨. (
psycopg2vsasyncpg)
- 같은 라이브러리도 async 버전으로 파편화됨. (
Go에서의 동시성과 병렬성
- Go에서는 파이썬과 다르게 동시성과 병렬성이 통합되어 있다.
- 동시성과 병렬성은 실제 물리적 하드웨어에서 동시에 돌아가느냐의 차이다. Go는 설계부터 개발자가 실제로 코어에서 동시에 도는지에 대해 신경쓰지 않아도 되게 되어 있다.
goroutine이란?
- go에선 thread, process, asyncio 없이 모든 게 다 goroutine이다.
왜 goroutine을 쓰는가?
- goroutine은 매우 가볍다 (수 KB 수준으로 python의 coroutine과 큰 차이가 없다)
- goroutine (수 KB) ~= asyncio coroutine (수 KB) « python threading (수 MB) « python multiprocessing (수십 MB)
func fetch(url string, ch chan<- string) {
resp, _ := http.Get(url)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ch <- string(body)
}
func main() {
ch := make(chan string, 10)
for i := 0; i < 10; i++ {
go fetch("https://example.com", ch)
}
for i := 0; i < 10; i++ {
fmt.Println(len(<-ch))
}
}
정리
결국 동시성과 병렬성은 “어떤 문제를 풀고 있느냐"에 따라 필요한 것이 다르다.
I/O를 기다리는 시간이 대부분인가? → 동시성으로 충분하다. CPU는 어차피 놀고 있으니, 그 시간에 다른 작업을 끼워넣으면 된다. CPU가 계속 일해야 하는가? → 병렬성이 필요하다. 코어를 실제로 여러 개 써야 빨라진다.
Python은 이 둘을 서로 다른 도구로 해결한다. I/O 바운드에는 asyncio나 threading, CPU 바운드에는 multiprocessing. 상황에 맞는 도구를 골라야 하고, 각 도구의 트레이드오프를 이해해야 한다. Go는 goroutine이라는 단일 추상화로 동시성과 병렬성을 통합했다. 개발자가 “이건 I/O니까 async, 이건 CPU니까 multiprocessing"이라고 구분할 필요 없이, go 키워드 하나면 런타임이 알아서 스케줄링한다. 어떤 언어를 쓰든, 핵심은 동시성과 병렬성을 혼동하지 않는 것이다. 동시성은 프로그램의 구조(structure)이고, 병렬성은 실행(execution)의 문제다. 좋은 concurrent 구조를 먼저 설계하면, 하드웨어가 허락할 때 parallelism으로 자연스럽게 확장할 수 있다.