동시성 조율 모델 — 공유 상태 vs 메시지 전달
여러 실행 흐름이 데이터를 안전하게 나눠 쓰는 두 갈래 — 공유 상태를 락으로 지키기(C++ std::mutex)와 메시지 전달로 아예 공유를 없애기(Go 채널/CSP)를 언어 공통 개념으로 정리한다. C++ std::mutex의 '깜빡 잠금' 함정을 Rust가 소유권(Mutex
난이도 중급 · 선행 스레드/코루틴을 들어봤으면 충분. 이 글은 코루틴의 “조율 반쪽”이다 — 실행 단위를 어떻게 통신·조율하느냐.
한 줄 요약
여러 실행 흐름(스레드·코루틴)이 같은 데이터를 건드리면 깨진다(데이터 레이스). 이걸 막는 길이 두 갈래다 — 공유 상태를 락으로 지키거나(C++ std::mutex), 아예 공유하지 말고 메시지로 주고받거나(Go 채널). Rust는 세 번째로, 소유권으로 레이스를 컴파일 타임에 원천 봉쇄한다.
어떤 문제를 푸는가 — C++ std::mutex에서 출발
스레드 둘이 같은 카운터를 올린다. C++ 개발자는 이 답을 안다.
1
2
3
4
5
6
7
int counter = 0;
std::mutex m;
void increment() {
std::lock_guard<std::mutex> lock(m); // 잠그고 (RAII로 스코프 끝에 자동 unlock)
++counter; // 한 번에 하나의 스레드만 이 줄에 있다
}
std::mutex로 공유 데이터에 한 번에 한 스레드만 들어가게 막는다. 이게 공유 상태(shared-state) 모델이고, 대부분의 언어가 기본으로 제공하는 길이다.
여기서 이미 알던 게 나온다. 뮤텍스로 임계 구역을 감싸는 것 — C++에서
std::mutex+lock_guard로 하던 그것. Go·Rust도 이걸 그대로 갖고 있다(sync.Mutex,std::sync::Mutex). 즉 공유 상태 모델은 세 언어 공통의 바닥이다. 갈리는 건 그 위에 무엇을 더 얹느냐다.
모델 ② 메시지 전달 — 공유를 아예 없앤다 (Go 채널 / CSP)
Go는 다른 길을 전면에 내세운다. 데이터를 공유하지 말고, 채널로 넘겨라.
1
2
3
ch := make(chan int)
go func() { ch <- compute() }() // 한 goroutine이 결과를 채널에 넣고
result := <-ch // 다른 goroutine이 꺼낸다
락이 없다. 왜? 데이터가 한 시점에 한 goroutine에만 있기 때문이다. 채널로 넘기는 순간 넘긴 쪽은 더 이상 안 건드린다 — 그래서 동시 접근 자체가 존재하지 않는다. 이 아이디어가 CSP(Communicating Sequential Processes)이고, Go의 유명한 격언이 이걸 요약한다:
“Don’t communicate by sharing memory; share memory by communicating.” (메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.)
C++엔 이게 없다 — 발판과 그 한계. C++ 표준에 Go 같은 채널은 없다. 굳이 대면
std::future/promise가 “값을 한 번 건네주는 일회성 채널”에 가깝다. 단 여기서 등치가 깨진다 —future는 1회성 값 전달이고, 채널은 스트림(여러 값을 계속, 양방향,select로 다중화)이다. 진짜 채널이 필요하면 C++에선std::queue+mutex+condition_variable로 직접 짜야 한다. 즉 Go는 “손으로 짜던 그 큐”를 언어 기본기로 올린 것.
모델 ③ 액터 — 상태를 가둔다 (Erlang, Akka)
한 걸음 더. 각 액터가 자기 상태를 독점하고, 바깥은 오직 메시지 큐로만 말을 건다. 액터는 자기 메시지를 하나씩 처리하므로 내부에 락이 필요 없다. 메시지 전달을 “객체 단위로” 밀어붙인 형태다. Erlang/Elixir·Akka(JVM)·Actix(Rust)가 이 모델이고, C++엔 표준은 없고 CAF 같은 라이브러리가 있다.
Rust의 반전 — 조율을 타입이 강제한다
Rust도 Mutex와 채널(mpsc)을 다 가졌다. 진짜 차이는 컴파일러가 레이스를 원천에서 막는다는 것이다. 두 장치로.
하나 — Mutex<T>가 데이터를 품는다. C++ std::mutex는 뮤텍스와 데이터가 남남이다. 그래서 잠그는 걸 깜빡하고 counter에 바로 접근해도 컴파일된다(런타임에 터진다). Rust는 데이터를 뮤텍스 안에 넣는다.
1
2
let counter = Mutex::new(0);
*counter.lock().unwrap() += 1; // lock()을 거치지 않으면 안의 0에 접근할 방법이 없다
아, C++ std::mutex의 그 함정을 Rust는 타입으로 막았구나. “뮤텍스 잠그는 걸 깜빡해서 난 데이터 레이스” — C++에서 겪는 그 버그를, Rust는 “잠그지 않으면 데이터에 손댈 수조차 없게” 설계해 컴파일 단계에서 없앤다. (①방향 아하 — C++의 그 문제를 Rust는 이렇게 처리했구나.)
둘 — Send/Sync 표식. “이 타입은 스레드 간 이동해도(Send)/공유해도(Sync) 안전한가”를 타입마다 표시하고, 컴파일러가 안전하지 않은 걸 스레드 경계로 넘기면 컴파일 에러를 낸다. C++엔 이런 강제가 없어 “이 객체를 다른 스레드에 넘겨도 되나?”가 순전히 개발자 책임이다.
언어별 정리
| 공유 상태 + 락 | 메시지 전달(채널) | 레이스 방지 | |
|---|---|---|---|
| C++ | std::mutex + lock_guard (기본) | 표준 없음 (queue+mutex+condvar 수동) | 개발자 책임 |
| Go | sync.Mutex(있음) | 채널 + select (전면) | 런타임 race detector |
| Rust | Mutex<T>(데이터를 품음) | mpsc 채널 | 컴파일 타임(소유권·Send/Sync) |
- C++: 공유 상태 + 뮤텍스가 정석. 채널은 직접 만든다. → 동시성 — thread·mutex·async
- Go: 뮤텍스도 있지만 채널을 권장한다. “accept interfaces”처럼 관용구 차원의 선택. → Go 학습 로드맵 — ⑤ 동시성
- Rust: 둘 다 주되, 컴파일러가 안전을 강제(fearless concurrency)한다. → Rust 학습 로드맵 — 동시성
정리: 공유 상태 + 뮤텍스는 세 언어 공통 바닥(C++에서 알던 그것). 그 위에서 Go는 “공유를 없애는” 채널을, Rust는 “레이스를 컴파일에 막는” 소유권을 얹었다. C++이 런타임·개발자 책임에 맡긴 부분을 각자 다른 방향으로 조인 것이다.
스스로 점검
1. “메모리를 공유해 통신하지 말고 통신해 공유하라”가 락을 없애는 원리는?
답
데이터를 채널로 넘기면 한 시점에 한 실행 흐름에만 그 데이터가 있다. 동시 접근이 아예 생기지 않으니 지킬(락 걸) 공유 상태 자체가 없다. 공유해서 락으로 지키는 대신, 소유를 넘겨 공유를 없애는 접근이다.
2. C++ std::mutex와 Rust Mutex<T>의 결정적 차이는?
답
C++은 뮤텍스와 데이터가 분리돼 있어 잠그지 않고 데이터에 접근해도 컴파일된다(깜빡 잠금 버그). Rust Mutex<T>는 데이터를 뮤텍스 안에 품어 lock()을 거치지 않으면 접근할 방법이 없다 — 그 함정을 타입으로 막는다.
3. 채널이 있는데도 Rust가 Send/Sync를 두는 이유는?
답
채널·뮤텍스를 써도 “안전하지 않은 값을 스레드 경계로 넘기는” 실수는 가능하다. Send/Sync는 그걸 타입 수준에서 표시해, 안전하지 않은 공유·이동을 컴파일 에러로 잡는다. C++엔 없는 강제라, 거기선 순전히 개발자 책임이다.