멀티프로세스, 스레드, 코루틴… 흔히 "동시에 여러 작업을 처리하는 방법들"로 뭉뚱그려 배우지만, 이들은 사실 서로 다른 두 가지 문제를 풉니다. 이 자료는 그 차이를 메모리를 공유하는가, 실제로 병렬로 실행되는가라는 두 축으로 정리합니다.
동시성(concurrency)은 여러 작업을 "번갈아 가며 처리해서 마치 동시에 진행되는 것처럼 보이게" 만드는 것이고, 병렬성(parallelism)은 여러 작업을 물리적으로 다른 CPU 코어에서 "실제로 동시에" 실행하는 것입니다. 둘은 다른 질문에 대한 답입니다. 동시성은 "일의 구조를 어떻게 짤 것인가"의 문제이고, 병렬성은 "하드웨어 자원을 어떻게 나눠 쓸 것인가"의 문제입니다.
이 관점에서 보면 이 자료가 다루는 네 가지 기법은 셋과 하나로 갈립니다.
즉 앞의 세 기법(멀티프로세스, Dart isolate, GIL-free 멀티스레드)은 "실제로 동시에 CPU를 쓴다"는 공통점이 있고, 마지막 코루틴/asyncio는 "동시에 도는 것처럼 보이지만 실제로는 한 스레드가 번갈아 처리한다"는 점에서 근본적으로 다릅니다. CPU 연산 자체가 겹쳐 도는 게 아니라, I/O를 기다리는 시간에 다른 작업을 끼워 넣는 것이 핵심입니다.
운영체제가 만들어주는 가장 기본적인 실행 단위입니다. 각 프로세스는 자신만의 독립된 주소공간(코드, 힙, 스택)을 가지고 있어서 한 프로세스가 다른 프로세스의 메모리를 직접 들여다볼 수 없습니다. OS 스케줄러가 여러 프로세스를 서로 다른 코어에 배치하면 진짜 병렬 실행이 일어납니다.
프로세스 A, B는 서로 다른 주소공간을 가진 채 서로 다른 코어에서 동시에 실행됩니다.
| 언어 | 지원 방식 | 비고 |
|---|---|---|
| C | 지원 — fork(), exec() (POSIX) |
OS 시스템 콜을 직접 사용. Windows는 CreateProcess로 별도 API. |
| C++ | 지원 — 표준 라이브러리엔 없고 OS API 직접 사용 | C++ 표준 자체엔 프로세스 생성 API가 없어 POSIX fork나 Windows API에 의존. |
| Python | 지원 — multiprocessing |
GIL 문제를 우회해 진짜 병렬 CPU 연산이 필요할 때 가장 널리 쓰는 표준 해법. |
| Dart | 지원 — dart:io의 Process.start() |
완전히 별도의 OS 프로세스를 실행하는 것으로, 아래의 isolate와는 다른 개념임에 주의. |
| Rust | 지원 — std::process::Command |
표준 라이브러리에 포함. |
Isolate는 이름 그대로 "격리된" 실행 단위입니다. 겉보기엔 스레드처럼 가볍게 여러 개를 띄울 수 있지만, 본질은 스레드가 아니라 경량화된 프로세스에 더 가깝습니다. 스레드의 정의적 특징은 같은 힙을 공유한다는 것인데, isolate는 정반대로 각자 독립된 힙을 가지고 아무 것도 공유하지 않습니다(share-nothing). 통신은 오직 SendPort/ReceivePort를 통한 메시지 패싱으로만 이루어지며, 이 점에서 Erlang/Elixir의 프로세스나 Actor 모델과 철학적으로 가깝습니다.
각 isolate는 독립된 힙과 자체 이벤트 루프를 가진 채 다른 코어에서 동시에 실행됩니다. 서로의 메모리는 볼 수 없고 메시지로만 대화합니다.
공유 메모리 스레드는 빠르지만 race condition과 lock 관리라는 대가를 치릅니다. Dart는 (특히 Flutter의 UI 스레드가 절대 멈추면 안 된다는 요구 때문에) 처음부터 공유 상태 동시성을 언어 차원에서 배제하고, 무거운 계산은 별도 isolate로 보내 메인 isolate(UI)를 절대 막지 않는 쪽을 택했습니다.
| 언어 | 지원 방식 | 비고 |
|---|---|---|
| C | 해당 없음 | share-nothing 격리 실행 단위라는 개념 자체가 언어에 없음. 프로세스로 유사하게 흉내 낼 수는 있음. |
| C++ | 해당 없음 | 동일. 언어 고유 개념 없음. |
| Python | 해당 없음 | 직접 대응 개념은 없고, multiprocessing이 "메모리 격리 + 병렬"이라는 목표는 유사하게 달성. |
| Dart | 고유 지원 — dart:isolate | Dart/Flutter의 핵심 동시성·병렬성 모델. |
| Rust | 해당 없음 | 대신 ownership 모델이 공유 메모리 스레드를 안전하게 만드는 다른 접근을 취함(3번 참고). |
참고: 철학적으로 가장 가까운 것은 Erlang/Elixir의 프로세스, Go의 goroutine + channel 조합입니다. 다만 이들은 이번 비교 대상 언어(C, C++, Python, Dart, Rust)에는 포함되지 않습니다.
스레드는 같은 프로세스 안에서 힙과 전역 상태를 공유하는 실행 단위입니다. 프로세스보다 훨씬 가볍게 생성할 수 있고, 데이터를 주고받을 때도 메모리를 그냥 같이 보면 되니 IPC보다 빠릅니다. 대신 그 대가로 여러 스레드가 같은 데이터를 동시에 건드리면 race condition이 생길 수 있고, 락을 잘못 쓰면 deadlock에 빠집니다.
두 스레드가 같은 힙을 공유하며 서로 다른 코어에서 동시에 실행됩니다. 공유 데이터에 접근할 때는 동기화(lock)가 필요합니다.
CPython은 오랫동안 GIL(Global Interpreter Lock) 때문에 한 순간에 하나의 스레드만 Python 바이트코드를 실행할 수 있었습니다. 이는 버그가 아니라 설계 결정이었습니다 — CPython의 레퍼런스 카운팅 기반 메모리 관리를 스레드 세이프하게 만들기 위한 트레이드오프였습니다. 그 결과 CPU-bound 작업에서는 Python 스레드를 아무리 늘려도 코어를 하나만 쓰는 것과 큰 차이가 없었습니다.
Python 3.13부터 GIL을 제거한 free-threaded 빌드(PEP 703)가 도입되었지만, 이는 아직 실험적인 opt-in 빌드입니다. 기본 배포판은 여전히 GIL이 켜져 있고, 3.14에서 "공식 지원" 단계로 올라섰을 뿐 기본값 전환은 한참 뒤의 일입니다. 즉 "Python 스레드는 이제 항상 병렬이다"라고 단정하면 안 됩니다.
python -VV
# ... "free-threading build" 문구가 있는지 확인
import sys
sys._is_gil_enabled() # False면 이 인터프리터에선 GIL이 꺼져 있음
Rust는 공유 메모리 스레드의 위험을 런타임이 아니라 컴파일 타임에 막습니다. ownership과 borrow checker가 "두 스레드가 같은 데이터를 동시에 변경 가능한 상태로 접근"하는 코드를 아예 컴파일되지 않게 만듭니다. 그래서 Rust는 이 모델을 "fearless concurrency"라고 부릅니다 — 안전성을 프로그래머의 주의력이 아니라 타입 시스템이 보장한다는 점에서 C/C++/Python과 접근 자체가 다릅니다.
| 언어 | 지원 방식 | 비고 |
|---|---|---|
| C | 지원 — pthread (POSIX Threads) | 표준 C 언어 자체엔 없고 POSIX 라이브러리에 의존. C11부터 <threads.h>가 표준에 포함되었으나 실사용은 pthread가 압도적. |
| C++ | 지원 — std::thread (C++11~) | 표준 라이브러리에 포함, mutex/condition_variable 등 동기화 도구도 함께 제공. |
| Python | 부분 지원 — threading | 기본 빌드는 GIL 존재(진짜 병렬 아님). 3.13+ free-threaded 빌드에서만 진짜 병렬, 아직 실험 단계. |
| Dart | 직접 노출 안 함 | OS 스레드를 언어 차원에서 감추고 isolate로 추상화. 공유 메모리 스레딩 모델 자체를 의도적으로 배제. |
| Rust | 지원 — std::thread | ownership/borrow checker가 컴파일 타임에 data race를 방지("fearless concurrency"). |
여기서부터는 앞의 세 기법과 완전히 다른 게임입니다. 코루틴/asyncio는 기본적으로 하나의 스레드 위에서 동작합니다. "동시에 실행"되는 게 아니라, 한 작업이 I/O(네트워크 응답, 파일 읽기, DB 쿼리 등)를 기다리는 동안 그 틈에 다른 작업을 실행시켜 번갈아 진행하는 방식입니다. CPU 연산이 겹쳐 도는 것이 전혀 아니라는 점이 핵심입니다.
Task A가 I/O를 기다리는 동안(빈 구간) 스케줄러가 Task B를 실행시킵니다. 단일 스레드이므로 두 색이 동시에 나타나는 구간이 없습니다.
실행 구조는 대략 이렇습니다: 작업들이 큐에 쌓이고, 이벤트 루프가 큐에서 하나씩 꺼내 실행하다가 await 지점을 만나면 제어권을 루프에 돌려주고, 루프는 다음 작업을 꺼내 실행합니다. async/await는 이 상태 기계(state machine)를 사람이 읽기 쉬운 문법으로 감싼 syntactic sugar입니다.
이 모델이 강력한 이유는 context switching 비용이 거의 0에 가깝기 때문입니다. OS 스레드 전환은 커널 개입이 필요해 비싸지만, 코루틴 전환은 그냥 함수 호출 수준이라 수만 개의 동시 연결(예: 채팅 서버)도 무리 없이 처리할 수 있습니다.
| 언어 | 지원 방식 | 비고 |
|---|---|---|
| C | 표준 없음 | 언어 차원의 코루틴이 없어 libuv, libevent 같은 별도 라이브러리로 이벤트 루프를 직접 구현. |
| C++ | 부분 지원 — C++20 co_await/co_yield/co_return | 언어가 제공하는 건 코루틴을 만들기 위한 저수준 빌딩 블록뿐이고, 실행기(executor/scheduler) 자체는 표준에 없음. 실사용엔 Boost.Asio 등 라이브러리가 필수 — Rust와 같은 함정. |
| Python | 지원 — asyncio | 이벤트 루프, async def/await 문법, 실행기까지 표준 라이브러리에 모두 포함. |
| Dart | 지원 — async/await + Future | 모든 isolate는 내부에 자신만의 이벤트 루프를 가지며 그 위에서 async/await가 동작. |
| Rust | 부분 지원 — async/await 문법은 표준 | C++20과 마찬가지로 실행기가 표준 라이브러리에 없음. tokio, async-std 같은 외부 크레이트가 사실상 필수. |
| 기법 | 메모리 모델 | 병렬 실행 | 생성 비용 | 통신 방식 | 적합한 작업 |
|---|---|---|---|---|---|
| Multi-process 병렬성 |
완전 격리 | O | 높음 | IPC (파이프·소켓·공유메모리) | CPU-bound, 강한 격리·안정성 필요 |
| Dart isolate 병렬성 |
격리 (share-nothing) | O | 중간 | 메시지 패싱 (Port) | UI 스레드를 막지 않는 무거운 연산 |
| Multi-thread 병렬성 |
공유 (같은 힙) | O (GIL 있으면 X) | 낮음 | 공유 메모리 + lock | CPU-bound 병렬 처리, 세밀한 제어 |
| Coroutine / asyncio 동시성 |
공유 (단일 스레드) | X | 매우 낮음 | 이벤트 루프 (콜백/await) | I/O-bound, 수많은 동시 연결 |
| 언어 | Multi-process | Dart isolate | Multi-thread | Coroutine/asyncio |
|---|---|---|---|---|
| C | O fork/exec | — | O pthread | 표준 없음 |
| C++ | O OS API | — | O std::thread | 부분 co_await + 별도 실행기 |
| Python | O multiprocessing | — | 부분 GIL / 3.13+ 실험적 free-threaded | O asyncio |
| Dart | O Process.start | O 고유 지원 | 비노출 isolate로 추상화 | O async/await |
| Rust | O std::process | — | O std::thread + 컴파일타임 안전 | 부분 async/await + 별도 실행기(tokio) |
O = 표준/기본 지원, 부분 = 문법은 있으나 핵심 요소가 실험적이거나 외부 의존, — = 해당 개념 자체가 없음
위 네 가지 기법을 실전에서 고를 때, 그리고 더 깊이 이해할 때 도움이 되는 배경 지식입니다.
실전에서 가장 중요한 판단 기준입니다. 연산 자체가 오래 걸리는 작업(이미지 처리, 암호화, 수치 계산)은 CPU-bound이므로 병렬성(process/thread/isolate)이 필요하고, 대기 시간이 대부분인 작업(네트워크 요청, DB 쿼리, 파일 I/O)은 I/O-bound이므로 동시성(coroutine/asyncio)만으로 충분하거나 오히려 더 효율적입니다.
메모리를 공유하는 스레드는 두 작업이 같은 데이터를 동시에 건드리면 결과가 실행 순서에 따라 달라지는 race condition에 취약합니다. 이를 lock으로 막으려다 서로가 서로의 lock을 기다리면 deadlock이 됩니다. 프로세스와 isolate가 메시지 패싱을 고집하는 이유가 바로 이 문제를 구조적으로 없애기 위해서입니다.
process(커널 개입, 무거움) > OS thread(커널 개입, 중간) > coroutine(사용자 공간, 거의 무료) 순으로 전환 비용이 줄어듭니다. asyncio 서버가 수만 개의 동시 연결을 별문제 없이 처리하는 이유가 여기 있습니다.
프로그램을 아무리 병렬화해도 직렬로 실행해야 하는 구간이 남아있는 한 전체 속도 향상에는 한계가 있습니다. "코어를 늘리면 무조건 그만큼 빨라진다"는 흔한 오해를 짚어주는 법칙입니다.
CPython의 레퍼런스 카운팅 기반 가비지 컬렉션을 스레드 세이프하게 만들기 위한 설계 트레이드오프였습니다. 단순한 "나쁜 제약"이 아니라 당시 상황에서의 합리적 선택이었다는 배경을 알면, free-threading이 왜 이렇게 오래 걸렸는지도 이해가 됩니다.
Rust의 ownership/borrow checker는 "안전성을 프로그래머의 주의력이 아니라 타입 시스템으로 강제한다"는 점에서 C/C++/Python과 근본적으로 다른 언어 설계 철학을 보여줍니다. 동시성 기법을 비교할 때 언어 설계 관점에서 함께 짚어보면 좋은 지점입니다.