수정 이력(1건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175867)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/multithreading-best-practices-cpp/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22175867
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22175868
「C#에서는 잘 돌아가던 설계를 C++로 가져왔더니 가끔 죽게 됐다」「std::thread를 썼더니 예외가 났을 때 앱 전체가 terminate로 바로 죽었다」「volatile bool flag로 멈추고 있었는데 릴리스 빌드에서만 멈추지 않는다」── C++ 멀티스레드에는 매니지드 언어에는 없는 고유한 위험이 있습니다. data race가 곧 undefined behavior가 되기 때문입니다. 깨진 값이 읽히는 선에서 끝나지 않고, 컴파일러의 최적화 전제가 무너져 「무슨 일이 일어나도 이상하지 않은」 상태가 됩니다.
이 글은 멀티스레드 실무 시리즈의 C++ 편입니다. 업무 앱·장치 제어·DLL을 모던 C++(C++17/20)로 작성하는 개발자를 대상으로, 멀티스레드 설계의 원칙 ── 스레드를 직접 늘리지 않는다, 공유 가변 상태를 줄인다, lock의 규율, 멈추는 방법을 먼저 설계한다 ── 를 C++와 Windows 도구에 옮겨 놓고, C++ 고유의 함정과 함께 2026년 8월 시점의 1차 자료를 바탕으로 정리합니다. 이 편만 읽어도 되도록 썼습니다. 같은 원칙을 다른 언어로 펼친 「.NET 편」「C 언어 편」「Java 편」도 있습니다.
1. 먼저 결론
- C++에서 data race는 「깨진 값이 읽힌다」가 아니라 「undefined behavior」입니다. 동기화되지 않은 공유 가변 접근을 한 곳도 남기지 않는 것이, 다른 언어보다 더 절대 조건이 됩니다.1
std::thread를 그대로 쓰지 마십시오. joinable한 채로std::thread의 소멸자가 실행되면std::terminate로 프로세스가 바로 종료됩니다. C++20의std::jthread는 소멸자에서 자동 join하고, 정지 요청(stop_token)도 내장합니다.23- lock은 반드시 RAII로 잡습니다.
mtx.lock()을 손으로 쓰지 말고lock_guard/scoped_lock을 씁니다. 예외가 나와도 소멸자가 반드시 풀어 줍니다. 여러 lock을 동시에 잡을 때는scoped_lock이 deadlock 회피 알고리즘으로 처리합니다.4 volatile은 동기화 도구가 아닙니다. 공유 flag·counter는std::atomic, 여러 변수의 보호는std::mutex입니다.std::atomic은 원자성과memory_order에 따른 순서 부여를 제공합니다.5- 대기는
condition_variable의 predicate가 있는wait으로 합니다. condition variable에는 spurious wakeup(알림 없이 깨어나는 현상)이 있으므로, predicate 없는wait은 실수의 온상입니다.6 - 멈추는 방법은
jthread+stop_token(C++20)이 기본형입니다. 그 이전 환경에서는std::atomic<bool>+ condition variable로 협조 정지를 만듭니다. 스레드 강제 종료는 C++ 세계에는 없다고 생각하십시오.3 std::async는 future의 소멸자가 block한다는 것을 알고 나서 쓰십시오. 반환값을 버리면 직렬 실행과 같아집니다.7- Win32 동기화 오브젝트가 나올 자리는 「Win32 대기 API와의 연계」와 「프로세스 간」뿐입니다. 그 외에는 표준 라이브러리로 쓰는 편이 이식성·유지보수에서 유리합니다.8
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 27건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 멀티스레드는 어려운가 ── race condition·deadlock·undefined behavior
멀티스레드가 가져오는 문제는 언어를 가리지 않고 파고들면 두 종류입니다.
race condition(경쟁 상태)은 여러 스레드가 어떤 순서로 특정 코드에 도달하느냐에 따라 결과가 달라지는 버그입니다. 단골 예가 공유 counter로, ++count라는 하나의 식은 기계어 수준에서는 「읽기 → 더하기 → 다시 쓰기」의 3단계로 갈라집니다. 두 스레드가 이 3단계에 동시에 들어가면, 한쪽의 더하기가 다른 쪽의 다시 쓰기에 덮여 사라집니다. 실행할 때마다 결과가 바뀌고, 어떤 결과가 될지는 예측할 수 없습니다.
sequenceDiagram
participant A as 스레드 A
participant M as 공유 변수 count
participant B as 스레드 B
Note over M: count = 10
A->>M: 읽기(10)
B->>M: 읽기(10)
A->>A: 로컬에서 더하기(11)
B->>B: 로컬에서 더하기(11)
A->>M: 다시 쓰기(11)
B->>M: 다시 쓰기(11)
Note over M: 두 번 더했는데 count = 11<br/>스레드 A의 더하기가 사라졌다
그림 1: 공유 counter에서 더하기가 사라지는 전형적인 race condition. ++count의 3단계 사이에 다른 스레드가 끼어들면, 나중에 다시 쓴 쪽이 덮어씁니다
deadlock은 두 스레드가 서로 상대가 쥐고 있는 lock을 기다려, 어느 쪽도 앞으로 가지 못하는 상태입니다. 스레드 A가 lock 1을 쥐고 lock 2를 기다리고, 스레드 B가 lock 2를 쥐고 lock 1을 기다린다 ── 이것만으로 둘 다 영원히 멈춥니다.
flowchart LR
A["스레드 A<br/>lock 1 보유 중"] -->|"lock 2 해제 대기"| B["스레드 B<br/>lock 2 보유 중"]
B -->|"lock 1 해제 대기"| A
그림 2: deadlock의 순환 대기. 대기 화살표가 고리를 만든 순간, 고리 안의 모든 스레드가 영원히 정지합니다
까다로운 점은 둘 다 타이밍에 의존한다는 것입니다. 개발 머신에서는 수만 번에 한 번만 맞는 실행 순서 조합이, 코어 수도 타이밍도 다른 고객 머신에서는 매일 일어납니다. 「디버거를 붙이면 재현되지 않는다」「로그를 넣었더니 사라졌다」는 것도, 관측이 타이밍을 바꿔 버리기 때문이며, 경쟁 버그의 전형적인 행동입니다. 그래서 이 글의 원칙은 모두, 「올바르게 동기화한다」보다 먼저 「동기화가 필요한 곳을 줄인다」는 한 방향을 향합니다.
2.1. C++에서는 data race가 곧 undefined behavior가 됩니다
그 위에 C++에는 다른 언어보다 한 단계 더 깊은 사정이 있습니다. C++ 규격에서는, 어떤 메모리 위치에 여러 스레드가 동기화 없이 접근하고 그중 적어도 한쪽이 쓰기이면, 그것은 data race이며 undefined behavior입니다. C++ Core Guidelines의 병행성 장(CP.2 「data race를 피하라」)은 이것을 절대 규칙으로 맨 앞에 내겁니다.1 undefined behavior란 「옛 값 또는 새 값 중 하나가 읽힌다」는 만만한 이야기가 아닙니다. 컴파일러는 「data race는 존재하지 않는다」는 전제로 최적화하므로, 루프에서 조건 판정이 사라지거나 쓰기가 재배열·합쳐지는 등, 소스 코드에서 상상할 수 없는 동작이 정당하게 일어납니다. 「volatile bool 정지 flag가 릴리스 빌드에서만 듣지 않는다」는 고전적인 사고가 이 전형입니다.
2.2. RAII가 토대가 됩니다
또 하나의 C++ 고유 전제가 예외와 리소스 관리입니다. C++에는 finally가 없는 대신 RAII(소멸자에 의한 자동 해제)가 있고, 멀티스레드 도구도 RAII를 전제로 설계되어 있습니다. 「lock은 오브젝트 수명으로 관리한다」「스레드의 join도 오브젝트 수명으로 보장한다」── 이 방식에 올라타는 것이, C++에서 멀티스레드를 안전하게 쓰기 위한 토대입니다.
3. 스레드를 띄우는 방법 ── thread의 함정과 jthread
3.1. std::thread의 소멸자는 「사고를 부르는 명세」입니다
std::thread에는 유명한 함정이 있습니다. joinable한 채로(join도 detach도 하지 않은 채로) 소멸자가 실행되면, std::terminate가 호출되어 프로세스가 바로 종료됩니다.9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← 여기서 예외가 나면…
worker.join(); // ← join에 도달하지 못하고, worker의 소멸자에서 terminate
}
예외 안전하게 만들려면 try/catch로 join을 보장해야 했고, RAII 언어인데 스레드만 수동 관리라는 뒤틀린 상태였습니다. C++20의 std::jthread가 이것을 해결합니다. 소멸자에서 자동으로 정지 요청을 내고 join하므로, 위 코드는 std::jthread로 바꾸기만 하면 예외 안전해집니다.2 MSVC에서는 Visual Studio 2019 16.9 이후 <stop_token>과 jthread를 쓸 수 있습니다.3
flowchart TB
T["스레드를 띄웠다"] --> Q{"스코프를 빠져나갈 때<br/>어떻게 되는가?"}
Q -->|"std::thread<br/>join도 detach도 하지 않음"| X["std::terminate<br/>프로세스 즉시 종료"]
Q -->|"std::thread<br/>join 완료"| OK1["안전하게 join"]
Q -->|"std::jthread (C++20)"| OK2["자동으로 request_stop + join<br/>예외가 나도 안전"]
그림 3: 스레드 오브젝트의 수명과 끝나는 방식. std::thread는 「join을 잊으면 즉시 종료」라는 명세이므로, C++20 이후에는 jthread를 기본으로 합니다
detach()는 원칙적으로 쓰지 않습니다. join 수단을 잃은 스레드는, 프로세스 종료 시 정적 변수나 heap 파괴와 경합해 종료 시 crash의 단골 원인이 됩니다.
3.2. 「스레드보다 위」의 도구 ── async·future와 병렬 알고리즘
.NET 편의 「스레드를 직접 만들지 않는다」는 원칙은, C++에서는 다음 도구에 해당합니다.
std::async+std::future: 한 번의 비동기 작업과 결과 받기. 다만 중요한 명세로,std::async로 띄운 작업에 묶인future(또는 마지막shared_future)는 작업이 끝나기 전에 소멸자가 실행되면 완료될 때까지 block합니다.7std::launch::async로 실제로 띄워진 일이라면, 반환 future를 버린 순간 동기 실행과 같아집니다. 더 나쁜 점은, launch policy를 지정하지 않은 기본에서는 구현이deferred(지연 실행)를 고를 수 있고, 그 경우get()/wait()을 아무도 호출하지 않으면 일 자체가 실행되지 않고 조용히 사라집니다. 확실히 병렬로 돌리고 싶다면std::launch::async를 명시하고, future의 수명을 소유자가 관리하십시오.- PPL(Parallel Patterns Library)의
concurrency::parallel_for/parallel_for_each: 컬렉션 전 요소에 대한 병렬 적용. 다만 한 반복의 일이 너무 작으면 fork/join 오버헤드가 이득을 잡아먹으므로, 병렬화는 바깥 루프에서 하는 것이 원칙입니다.10 - C++17의 병렬 알고리즘(
std::execution::par): MSVC에서는 주요 알고리즘이 병렬화되어 있습니다(전부가 아닙니다).11 주의점으로, 실행 정책 아래 요소 처리에서 예외가 새어 나오면std::terminate가 호출됩니다. 콜백 안에 자체 예외 경계(try/catch)를 두는 것은, 6장의 스레드 경계와 같은 생각입니다.
「I/O 대기는 스레드를 늘릴 대상이 아니다」라는 구분도 그대로 유효합니다. Windows 네이티브 코드라면 OVERLAPPED I/O나 IOCP가 그 받침대가 됩니다(구조는 「Windows I/O의 심층 제2회」 참조).
4. 공유 가변 상태를 줄인다 ── 분할·값 전달·const·큐
경쟁은 「여러 스레드」와 「공유된 가변 데이터」가 갖춰졌을 때만 일어납니다. 스레드 수는 요건으로 정해지므로, 설계에서 깎을 수 있는 것은 공유 쪽입니다. 수단은 「분할」「불변으로 만들기」「건네주기」의 3계통이며, C++에서는 이렇게 씁니다.
분할한다. 병렬 집계 같은 처리에서는, 공유 합계 변수에 각 스레드가 쓰는 것이 아니라, 스레드마다 로컬 소계를 갖게 하고 마지막에 한 번만 합칩니다. 공유에 대한 쓰기가 「반복마다」에서 「스레드마다 한 번」까지 줄어, 동기화 비용도 경쟁의 창도 자릿수가 다르게 작아집니다. 합치는 한 번은 std::mutex여도 std::atomic의 fetch_add여도 됩니다.
값으로 넘긴다. 스레드를 띄울 때 필요한 데이터를 복사(또는 move)로 넘겨 버리면, 그 데이터는 그 스레드만 가지게 되어 동기화가 필요 없습니다. 람다 캡처를 참조([&])로 해 수명이 끝난 변수를 건드리는 사고가 많으므로, 스레드에 넘기는 람다는 명시 캡처로, 원칙은 복사 또는 move입니다. 다만 「복사했으니 그 스레드만의 것」이 성립하는 것은, 값이 포인터나 shared_ptr 같은 별칭(alias)을 포함하지 않는, 깊은 값 그래프인 경우뿐입니다. raw pointer가 든 구조체를 복사해도, 가리키는 곳은 공유된 채입니다.
const로 공유한다. 읽기만 하는 데이터는 몇 스레드가 동시에 읽어도 안전합니다. 설정값·마스터 데이터·계산 입력 등은, 만든 뒤 다시 쓰지 않는 const 공유(std::shared_ptr<const Config> 등)로 두면 동기화 없이 공유할 수 있습니다. 주의점으로, shared_ptr<const T>가 막는 것은 그 핸들을 통한 변경뿐입니다. 어딘가에 non-const 별칭이 남아 있거나 mutable 멤버가 다시 쓰이면 경쟁은 남으므로, 「생성이 끝나면 non-const 참조를 놓고, 이후 아무도 쓰지 않는다」까지 포함해 설계하십시오. 「변경이 필요해지면 고치는 것이 아니라 새 오브젝트를 만들어 교체한다」고 정하는 것만으로, 지켜야 할 가변 상태가 하나 줄어듭니다(교체 자체의 수명 관리는 5.2의 주의를 보십시오).
큐로 넘긴다. 스레드 사이 데이터 흐름은 공유 변수가 아니라 producer/consumer 큐로 모읍니다. C++ 표준에는 채널이 없으므로, std::mutex + std::condition_variable로 작은 큐를 쓰는 것이 정석입니다.
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // 용량 0이면 모든 Push가 영원히 기다리는 함정이 된다
throw std::invalid_argument("capacity must be positive");
}
// 가득 차면 자리가 날 때까지(또는 정지 요청까지) 기다린다. false는 정지 요청.
bool Push(T item, std::stop_token st)
{
{
std::unique_lock lock(mtx_);
if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
return false; // 정지 요청으로 깨어남
if (st.stop_requested()) // 빈자리와 정지가 동시에면 정지를 우선하고,
return false; // 정지 시작 후의 투입을 받지 않는다
queue_.push(std::move(item));
}
not_empty_.notify_one(); // 알림은 lock 밖에서
return true;
}
// 정지 요청(stop_token) 또는 요소 도착까지 기다린다. 정지 시에는 nullopt.
std::optional<T> Pop(std::stop_token st)
{
std::optional<T> item;
{
std::unique_lock lock(mtx_);
if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
return std::nullopt; // 정지 요청으로 깨어남
if (st.stop_requested()) // 요소와 정지가 동시에면 정지를 우선하고,
return std::nullopt; // 정지 시작 후에는 새 일에 착수하지 않는다
item = std::move(queue_.front());
queue_.pop();
}
not_full_.notify_one();
return item;
}
private:
const std::size_t capacity_;
std::mutex mtx_;
std::condition_variable_any not_empty_; // stop_token 대응 wait을 쓰기 위해 any를 고른다
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
설계상 요점은 두 가지입니다. 첫째, 용량에 상한을 두고, 가득 차면 생산 쪽을 기다리게 하는 것입니다. 상한 없는 큐는 생산이 소비보다 빠른 구성에서 「돌아가기는 하는데 메모리가 계속 늘어나는」 시한폭탄이 됩니다. 가득 찼을 때 Push가 block되는 것이 자연스러운 backpressure(배압)로 작동해, 과부하를 기계적으로 상류에 전합니다. 둘째, condition variable에는 spurious wakeup(알림이 없는데 깨어나는 현상)이 있으므로, wait은 반드시 predicate를 붙여 호출합니다. predicate가 있는 wait이 내부에서 「조건이 참이 될 때까지 루프」를 실행해 줍니다.6
5. lock의 규율 ── RAII와 scoped_lock
공유 가변 상태를 줄여도 0으로 만들지 못하는 경우가 많습니다. 남은 공유에는 상호 배제를 쓰지만, 규율 없는 lock은 경쟁을 가릴 뿐입니다.
먼저, lock의 단위는 「코드 구간」이 아니라 「데이터」로 생각합니다. 지키고 싶은 가변 데이터 집합마다 mutex를 하나 대응시키고(밖에 공개하지 않는 private 멤버로 둡니다), 그 데이터에 손대는 모든 곳에서 같은 mutex를 잡는다 ── 이 대응표가 무너진 것이 경쟁 버그의 실체입니다. 그리고 lock을 쥐고 있는 동안 해도 되는 것은 지키는 데이터의 읽기·쓰기뿐입니다. lock을 쥔 채 하는 파일 I/O·네트워크 호출·콜백(외부 코드 호출)은 보유 시간을 늘릴 뿐 아니라, 호출한 쪽이 다른 lock을 잡으려다 deadlock하는 경로를 만듭니다. lock 밖에서 준비하고, lock 안에서는 교체만이 기본형입니다.
5.1. lock()/unlock()을 손으로 쓰는 것은 금지입니다
std::mutex의 lock() / unlock()을 직접 호출하는 코드는, 예외나 이른 return으로 해제 누락을 일으킵니다. lock의 획득과 해제는 반드시 RAII 래퍼에 맡깁니다.
| 래퍼 | 용도 |
|---|---|
std::lock_guard |
mutex 하나를 스코프 동안만 쥐는, 가장 기본 형태 |
std::scoped_lock(C++17) |
여러 mutex를 동시에 잡습니다. deadlock 회피 알고리즘으로 순서 문제를 해결합니다4 |
std::unique_lock |
도중에 해제·재획득하고 싶을 때, 또는 condition_variable::wait에 넘길 때 |
lock이 둘 이상일 때, 잡는 순서가 스레드마다 뒤바뀌는 것이 deadlock의 고전 패턴입니다(그림 2의 순환 대기는 이렇게 생깁니다). 대책은 「모든 스레드가 같은 순서로 잡는다」를 규칙화하는 것이지만, 동시에 잡는 장면이라면 C++에는 더 나은 답이 있어, std::scoped_lock에 여러 mutex를 한꺼번에 넘기면 잡는 순서의 deadlock 회피를 라이브러리가 보장합니다.4 두 오브젝트 사이 송금처럼 「둘 다 lock하고 싶은」 장면에서는, 따로 잡지 말고 반드시 한꺼번에 잡습니다.
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // 같은 계좌면 아무것도 하지 않음(아래 주)
std::scoped_lock lock(from.mtx, to.mtx); // 두 개를 한꺼번에, 순서는 라이브러리가 해결
from.balance -= amount;
to.balance += amount;
}
맨 앞의 동일성 검사는 장식이 아닙니다. 같은 Account가 from과 to에 넘어오면, 재귀가 안 되는 같은 mutex를 scoped_lock에 두 번 넘기게 되어 hang이나 undefined behavior의 원인이 됩니다. 「둘 다 lock하는」 함수에는 같은 오브젝트 제외를 반드시 붙이십시오.
「읽기는 많고 쓰기는 드문」 데이터에는 std::shared_mutex(C++17)로 읽기/쓰기 lock을 쓸 수 있습니다.12 또한 recursive_mutex는 「같은 스레드가 다시 잡아도 깨지지 않기」 위한 타입이지만, 재귀 획득이 필요해지는 설계는 lock의 책임 경계가 흐릿해졌다는 신호인 경우가 많아, 구조 재검토를 먼저 검토하십시오.
5.2. atomic의 올바른 자리
std::atomic은 단일 변수에 대한 원자적 연산과, memory_order에 따른 순서 부여를 제공합니다.5 나올 자리는 .NET 편의 Interlocked와 같고, counter·flag 같은 단일 변수 갱신입니다. 여러 변수를 한꺼번에 일관되게 맞출 수는 없으므로, 그 경우는 std::mutex로 돌아갑니다.
raw pointer 교체(std::atomic<T*>)에는 고유한 함정이 있습니다. 교체 자체는 원자적이어도, 교체한 뒤 옛 오브젝트의 수명은 아무도 지켜 주지 않습니다. 읽는 쪽이 옛 포인터를 로드한 직후 쓰는 쪽이 교체하고 delete하면, 이미 해제한 메모리에 대한 접근입니다. 「불변 오브젝트를 교체해 공유한다」는 설계를 C++에서 하려면, lock으로 지킨 std::shared_ptr<const T>의 교체(또는 C++20의 std::atomic<std::shared_ptr<T>>)처럼, 수명 관리와 세트가 된 수단을 고르십시오.
그리고 반복하지만, volatile은 스레드 동기화 도구가 아닙니다. memory_order를 직접 지정하는 lock-free 프로그래밍은, 기본(seq_cst)에서 느슨하게 만들 정당한 이유와 검증 수단을 가진 전문가의 영역입니다. 업무 앱에서는 기본 그대로 쓰거나, 애초에 mutex로 쓰십시오.
6. 멈추는 방법의 설계 ── stop_token과 협조 정지
멀티스레드 설계 리뷰에서 맨 먼저 물어볼 질문은 「이것은 어떻게 멈춥니까」입니다. 그리고 C++에는 스레드를 밖에서 안전하게 멈추는 수단이 존재하지 않습니다(Win32의 TerminateThread가 얼마나 위험한지는 C 언어 편에서 자세히 다룹니다). 따라서 멈추는 방법은 협조 정지 ── 멈추는 쪽은 요청만 내고, 언제·어떻게 끝날지는 스레드 자신이 뒷정리가 되는 지점에서 정하며, join 완료를 「멈췄다」로 본다 ── 를 C++ 도구로 만듭니다.
C++20에서는 std::jthread가 정지 기구를 내장합니다. request_stop()을 호출하면, 스레드 함수가 받은 std::stop_token에 정지 요청이 서고, 루프는 그것을 polling합니다. condition_variable_any의 wait은 stop_token을 직접 받을 수 있어, 「일이 올 때까지 기다리는 스레드」도 정지 요청으로 바로 깨울 수 있습니다(4장의 BlockingQueue::Pop이 그 형태입니다).
class Worker {
public:
void Start()
{
if (thread_.joinable()) // 도는 중의 이중 Start를 거절한다.
throw std::logic_error("already running"); // 거절하지 않고 대입하면, 새 스레드가 돌기
// 시작한 뒤 옛 스레드 정지를 기다리는 동안
// 워커 두 개가 나란히 돌아 버린다
thread_ = std::jthread([this](std::stop_token st) {
try {
while (!st.stop_requested()) {
if (auto item = queue_.Pop(st)) { // 정지 요청으로도 깨어난다
try {
Process(*item, st); // 안에서 block될 수 있는 처리에도 st를 넘긴다
} catch (...) {
ReportError(std::current_exception()); // 한 건의 실패는 기록하고 계속
}
}
}
} catch (...) {
// 스레드 경계의 마지막 방어선(Pop이나 move 실패도 여기서 받는다).
// 여기서 예외가 새면 std::terminate로 프로세스 전체가 죽으므로,
// ReportError는 예외를 던지지 않는 구현으로 둔다
ReportError(std::current_exception());
}
});
}
// 명시적 Stop은 불필요:
// Worker의 소멸자 → jthread의 소멸자 → request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // 용량 상한 있음(4장)
std::jthread thread_;
};
flowchart TB
OWNER["멈추는 쪽<br/>(jthread의 소멸자 또는 request_stop)"] -->|"정지 요청"| ST["stop_token"]
ST --> P["계산 루프:<br/>stop_requested()를 polling"]
ST --> W["대기 중인 스레드:<br/>condition_variable_any::wait(lock, st, pred)<br/>가 바로 깨어난다"]
P --> E["뒷정리하고 스스로 return"]
W --> E
E --> J["join으로 합류 완료<br/>여기서 비로소 「멈췄다」고 말할 수 있다"]
그림 4: C++20의 협조 정지. 멈추는 쪽은 요청만 내고, 끝나는 방식은 스레드 자신이 정하며, join 완료를 정지로 봅니다
한 가지 더, 워커 안의 try/catch는 생략할 수 없습니다. jthread가 예외 안전하게 해 주는 것은 join뿐이고, 스레드 함수에서 예외가 새면 std::thread와 마찬가지로 std::terminate로 프로세스가 죽습니다. 일 한 건의 실패를 어떻게 다룰지(기록하고 계속할지, 오류 채널로 소유자에게 전할지)는 스레드 경계에서 명시적으로 정해 둡니다.
같은 이유로, Process에도 stop_token을 넘기고 있는 점에 주목하십시오. 일 한 건의 처리가 안에서 block되면(네트워크 대기·긴 계산 등), 그곳이 정지 요청을 관측하지 못하면 소멸자의 암묵 join이 그 한 건의 완료를 끝없이 기다립니다. 협조 정지는 「기다리는 모든 곳」에 토큰이 닿아야 비로소 성립합니다. 중단할 수 없는 외부 호출을 포함한다면, timeout을 붙여 한 건의 실행 시간에 상한을 두십시오.
C++17 이전 환경에서는, std::atomic<bool> 정지 flag + condition_variable의 notify_all로 같은 구도를 손수 만듭니다. 이때 정지 flag 확인은 condition variable의 predicate에 넣는 것이 요점입니다(flag만 세우고 알림을 잊으면, 대기 중인 스레드가 영원히 깨어나지 않습니다).
7. Windows 고유의 주의 ── Win32 API와의 경계
7.1. 표준 라이브러리와 Win32 동기화 오브젝트의 역할 분담
Microsoft 문서는, 이식성을 중시하는 C++ 코드에는 std::mutex / std::shared_mutex를 권하고, Win32 동기화 오브젝트가 나올 자리를 「Win32 대기 API가 필요할 때」와 「프로세스 간 동기화」로 정리합니다.8
| 상황 | 선택 |
|---|---|
| 일반적인 프로세스 안 상호 배제 | std::mutex + RAII(기본) |
| 읽기 많고 쓰기 드묾 | std::shared_mutex |
WaitForMultipleObjects로 여러 오브젝트를 동시에 기다리고 싶다 |
Win32 이벤트·Mutex 등 kernel object |
| 프로세스를 넘는 상호 배제·알림 | named Mutex·이벤트·세마포어 |
| Win32 API를 직접 쓰는 프로세스 안 lock | SRW lock(재귀가 필요할 때만 CRITICAL_SECTION)8 |
프로세스 간에 공유 메모리를 상호 배제하는 구체 설계는 「공유 메모리의 함정과 실무 베스트 프랙티스」를 보십시오.
7.2. DllMain에서 스레드에 손대지 마십시오
DLL을 쓸 때의 중대한 제약이 loader lock입니다. DllMain은 loader lock을 쥔 채로 호출되므로, 그 안에서 다른 스레드와 동기화하거나, 스레드 종료를 기다리거나, LoadLibrary를 호출하는 조작은 deadlock이나 불확정 동작의 원인이 됩니다. 스레드를 띄우거나 join하는 초기화는 DllMain 밖(명시적 초기화 함수)으로 빼십시오.13
7.3. UI 스레드와 COM 아파트먼트
Windows 데스크톱 앱에는 언어와 무관하게 적용되는 강한 제약이 있습니다. 윈도우와 컨트롤은 그것을 만든 스레드(UI 스레드)만 손댈 수 있다는 규칙입니다. Windows는 윈도우 메시지를 그 윈도우를 만든 스레드의 메시지 큐로 배달하므로, UI의 생성과 조작은 그 스레드에 모여 있어야 합니다. 워커 스레드에서 화면을 갱신하고 싶을 때는 직접 건드리지 말고 PostMessage(비동기)로 UI 스레드에 맡기고, UI 스레드 쪽 윈도우 프로시저에서 처리합니다. 동기형 SendMessage는, UI 스레드가 그 워커의 완료를 기다리는 상황에서 호출하면 서로를 기다리는 deadlock이 되므로, 워커에서의 알림은 비동기형을 기본으로 하십시오. COM이 얽히는 경우의 STA/MTA는 「COM STA/MTA의 기초 지식」에서 설명합니다. 또한 /clr로 컴파일하는 C++/CLI 코드에서는 <thread> <mutex> 같은 표준 스레드 헤더가 막힌다는 점에도 주의가 필요합니다.14
8. 검증과 디버깅 ── 「재현되지 않는다」는 전제로 대비합니다
경쟁 버그는 테스트에서 찾아질 것을 기대할 수 없습니다. 보통의 테스트는 「우연히 경쟁하지 않은」 실행을 성공으로 세기 때문입니다. 대비는 3층으로 생각합니다.
첫 번째 방어선은, 여기까지의 설계 원칙 그 자체입니다. 리뷰에서는 「공유하고 있는 가변 데이터는 무엇인가」「각각 어떤 mutex가 지키는가」「여러 lock의 획득 순서는 유일한가(또는 scoped_lock으로 한꺼번에 잡고 있는가)」「정지 경로는 어디인가」를 표로 확인합니다. 이 표를 쓸 수 없는 설계는, 돌아가고 있어도 아직 완성되지 않았습니다.
둘째, 이상을 숨기지 않고 관측 가능하게 만듭니다. 잡히지 않아야 할 lock에는 timed_mutex의 try_lock_for나 condition_variable의 wait_for로 timeout을 붙이고, 시간 초과를 이상으로 로그에 남기면, 영원한 hang을 검출 가능한 실패로 바꿀 수 있습니다. 스레드 경계의 try/catch(6장)에서 받은 예외는 반드시 기록합니다. hang이나 crash 현장에서는 dump를 채취해 모든 스레드의 스택을 확인하고, 서로의 lock 대기가 순환하지 않는지 봅니다. dump와 로그 정비는 「Windows 앱의 crash 때 로그와 dump를 남기는 설계」에서 다룹니다.
셋째, 부하를 걸어 흔듭니다. 코어 수보다 많은 병렬도로 오래 돌리고, 처리 순서를 무작위화하고, 인공 지연을 넣는 스트레스 테스트는, 개발 머신에서 경쟁의 「당첨」을 뽑기 쉽게 하는 현실적인 수단입니다. 디버그 빌드에서 사라지는 버그도, 최적화된 릴리스 빌드+고부하라면 재현되는 일이 많습니다.
9. 정리 ── C++ 편 체크리스트
언어 공통 원칙(스레드를 직접 만들지 않는다·공유 가변 상태의 최소화·lock과 데이터의 1대1 대응·협조 정지)에, C++ 고유 확인을 겹칩니다.
std::thread를 그대로 쓰지 않았는가(jthread로 바꿀 수 없는가, join은 예외 경로에서도 보장되는가)detach()를 쓰지 않았는가- 람다 캡처는 명시적인가, 참조 캡처한 변수의 수명은 스레드보다 긴가
- 동기화 없는 공유 가변 접근(=undefined behavior)이 한 곳도 없다고 단언할 수 있는가
lock()/unlock()을 손으로 쓴 곳이 없는가, 여러 lock은scoped_lock으로 한꺼번에 잡고 있는가condition_variable::wait은 모두 predicate가 붙어 있는가- 공유 flag에
volatile을 쓰지 않았는가(std::atomic인가) - 정지 경로는
stop_token(또는 atomic flag+알림)으로 설계되어, join으로 합류 완료를 확인하는가 std::async의 future를 버리지 않았는가DllMain에서 스레드의 기동·동기화·join을 하지 않았는가
C++ 멀티스레드는 「undefined behavior」라는 절벽 바로 옆을 걷는 일이지만, 뒤집으면 RAII와 표준 라이브러리의 방식에 솔직히 올라타는 것만으로 절벽에서 크게 멀어질 수 있습니다. jthread·scoped_lock·predicate가 있는 wait·atomic ── 도구의 기본을 올바르게 고르는 것이, C++에서는 설계 원칙의 실천 그 자체입니다.
관련 글
- 멀티스레드 실무 베스트 프랙티스 .NET 편
- 멀티스레드 실무 베스트 프랙티스 C 언어 편
- 멀티스레드 실무 베스트 프랙티스 Java 편
- 네이티브 DLL을 C++/CLI로 감싸는 실무
- 공유 메모리의 함정과 실무 베스트 프랙티스
- COM STA/MTA의 기초 지식 - 스레드 모델과 hang을 피하는 생각
- Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
관련 상담 영역
합동회사 코무라소프트에서는, C++ 앱·DLL의 멀티스레드 설계 리뷰, 「가끔 죽는다·릴리스 빌드에서만 이상하다」 같은 경쟁에서 비롯된 장애 조사(dump 분석), 레거시 스레드 코드를 모던 C++로 옮기는 상담을 다룹니다.
참고 링크
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. 병행·병렬 장의 첫머리 규칙으로 CP.1(자기 코드가 멀티스레드에서 돈다고 가정하라)과 CP.2(data race를 피하라)가 내걸려 있으며, data race가 있으면 어떤 보장도 성립하지 않는다는 점, lock 보유 범위나 RAII 이용 등 병행 코드의 설계 규칙이 체계화되어 있다는 점을 다룹니다. ↩ ↩2
-
cppreference.com, std::jthread. C++20의 jthread가 std::thread와 달리 소멸자에서 자동으로 request_stop()을 호출한 뒤 join한다는 점, 스레드 함수의 첫 인자로 std::stop_token을 받을 수 있다는 점, 이로써 예외가 나도 스레드의 join과 정지 요청이 보장된다는 점을 다룹니다. ↩ ↩2
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. P0660R10(<stop_token>과 jthread) 및 P1135R6(C++20 동기화 라이브러리)이 Visual Studio 2019 16.9에서 지원되었다는 점, C++ 표준 라이브러리 기능의 버전별 대응 현황을 다룹니다. ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class. C++17의 scoped_lock이 생성 시 하나 이상의 mutex를 잡고 소멸자에서 푼다는 점, 여러 mutex를 넘기면 std::lock에 해당하는 deadlock 회피 알고리즘으로 잡는다는 점, 예외가 던져져도 반드시 해제된다는 점, 단일 mutex라면 lock_guard/unique_lock도 선택지가 된다는 점을 다룹니다. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. atomic 연산이 원자적이므로 다른 스레드에서는 연산 전 또는 후 상태만 관측할 수 있다는 점, memory_order 인자에 따라 다른 atomic 연산의 가시성에 대한 순서 요건을 세우고 이에 어긋나는 컴파일러 최적화를 막는다는 점, atomic_flag가 항상 lock-free라는 점, /clr:pure에서는 이 헤더가 막힌다는 점을 다룹니다. ↩ ↩2
-
Microsoft Learn, <condition_variable>. condition variable 대기에는 mutex가 필요하고 대기 중에는 lock이 풀린다는 점, 알림 없이 깨어나는 spurious wakeup이 있으므로 대기 쪽은 복귀 시 조건을 명시적으로 확인해야 하며 predicate가 있는 wait(lock, pred)이 그 루프를 대신한다는 점, condition_variable_any가 임의 mutex 타입과 조합될 수 있다는 점을 다룹니다. ↩ ↩2
-
Microsoft Learn, <future>. future와 shared_future의 소멸자는 원칙적으로 block하지 않지만, 유일한 예외로 std::async로 띄운 작업에 묶인 future(또는 마지막 shared_future)는 작업이 끝나기 전에 소멸자가 실행되면 공유 상태가 ready가 될 때까지 block한다는 점, 이 동작이 규격의 노트로 명시되어 있다는 점을 다룹니다. ↩ ↩2
-
Microsoft Learn, About Synchronization. Win32 동기화 프리미티브 선택 지침으로, 이식성 중시 C++ 코드에는 std::mutex / std::shared_mutex와 RAII가 권장된다는 점, Win32 대기 API나 프로세스 간 동기화가 필요할 때 Win32 동기화 오브젝트를 쓴다는 점, 프로세스 안 신규 코드의 기본은 SRW lock이고 재귀 획득이 필요할 때만 CRITICAL_SECTION이라는 점, 프로세스 안 동기화에 Mutex를 쓰는 것은 항상 kernel transition이 따라붙는 「흔한 실수」라는 점을 다룹니다. ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread. std::thread의 소멸자가, 스레드가 joinable한 채로(join도 detach도 되지 않은 채로) 호출되면 std::terminate를 호출한다는 점, 즉 스레드 오브젝트를 파괴하기 전에 반드시 join 또는 detach 판단을 마쳐야 한다는 점을 다룹니다. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. 병렬화는 가능한 한 높은 수준(바깥 루프)에서 표현해야 한다는 점, 각 반복의 일이 작거나 불균형한 병렬 루프에서는 fork/join 스케줄링 오버헤드가 병렬 실행의 이득을 웃돌 수 있다는 점, 그 경향은 프로세서 수가 늘수록 강해진다는 점을 다룹니다. ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. C++17 병렬 알고리즘 라이브러리가 완성되어 있는 한편, 「완성」이 모든 알고리즘이 모든 경우에 병렬화된다는 뜻이 아니며, 가장 중요한 알고리즘이 병렬화되고 병렬화되지 않는 것에도 실행 정책 시그니처가 제공된다는 구현 방침을 다룹니다. ↩
-
Microsoft Learn, C++ standard library header files. 멀티스레드 관련 표준 헤더로 <atomic>(C++11), <mutex>(C++11), <shared_mutex>(C++14), <condition_variable>(C++11), <future>(C++11), <stop_token>·<semaphore>·<latch>·<barrier>(C++20), <thread>(C++11)가 정리되어 있다는 점을 다룹니다. ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMain이 loader lock을 쥔 채로 호출되므로 호출할 수 있는 API에 중대한 제약이 있다는 점, DllMain 안에서 다른 스레드와 동기화하면 deadlock할 수 있다는 점, LoadLibrary 호출이나 스레드 종료 대기가 전형적인 금지 사항이라는 점, 초기화는 가능한 한 늦춰 DllMain 밖으로 빼야 한다는 점, lock 계층을 정의하고 loader lock을 최상위에 두어야 한다는 점을 다룹니다. ↩
-
Microsoft Learn, <thread>. <thread> 헤더가 thread 클래스와 sleep_for 등 보조 함수를 정의한다는 점, /clr로 컴파일되는 코드에서는 이 헤더가 막힌다는 점, STDCPP_THREADS 매크로로 스레드 지원 여부를 판정할 수 있다는 점을 다룹니다. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex로 스레드를 만들고, SRW 락과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects로 정지를 설계하는 것이 정석입니다. TerminateThre...
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않습니까. Vista에서 새로 설계된 Win32 스레드 풀 API의 work, timer, wait, io 네 가지 객체와 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 자료를 ...
절전에서 재개하면 깨지는 앱 ── 전원 이벤트의 구조와 재개에 강한 업무 앱을 만드는 방법
노트북을 열었더니 업무 앱의 통신이 끊어져 있었다――원인은 절전을 전제하지 않은 설계입니다. WM_POWERBROADCAST에 의한 알림의 흐름, Modern Standby의 동작, 끊김·재연결 설계, 절전 억제와 조사 명령까지를 1차 정보로 설...
DllMain과 로더 락 ── 「DLL 초기화에서는 아무것도 하지 말라」는 말의 진짜 이유
DllMain에서 LoadLibrary나 스레드 동기화를 해서는 안 되는 이유는 무엇인가. 모든 DLL 알림을 직렬화하는 로더 락의 구조부터, 데드락이 성립하는 전형적인 시나리오, 지연 초기화 같은 올바른 설계, hang 조사 절차까지를 1차 정...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
장애 조사 & 장기 가동 장애
간헐적 장애, 통신 진단, 장기 가동 크래시, 실패 경로 테스트 기반을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
장애 조사 & 원인 분석
간헐적 장애, 장기 가동 중 크래시, 누수, 통신 중단 등 까다로운 프로덕션 이슈를 조사합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- std::mutex와 Win32의 CRITICAL_SECTION·SRW lock은 어떻게 나눠 쓰면 되나요?
- 이식성을 중시하는 일반적인 C++ 코드에서는 std::mutex / std::shared_mutex와 RAII 래퍼(lock_guard / scoped_lock)가 첫 후보입니다. Win32 동기화 오브젝트를 고르는 경우는 WaitForMultipleObjects 같은 Win32 대기 API와 맞추고 싶을 때, 또는 named object로 프로세스 간 동기화가 필요할 때입니다. 프로세스 안에서 Win32 API를 직접 쓴다면 신규 코드의 기본은 SRW lock이고, 같은 스레드에서 재귀로 잡을 필요가 있을 때만 CRITICAL_SECTION을 씁니다. 프로세스 안 상호 배제에 Win32 Mutex를 쓰는 것은 항상 kernel transition이 따라붙어 느려지는 전형적인 실수입니다.
- std::thread의 detach()를 써도 되나요?
- 원칙적으로 피하십시오. detach한 스레드는 join할 수단을 잃고, 프로세스 종료 시점에 그 스레드가 아직 도는지 통제할 수 없습니다. 정적 변수나 heap이 파괴된 뒤에 detach된 스레드가 계속 돌다 종료 시 crash가 나는 것이 전형적인 사고입니다. '끝을 기다릴 수 있다'는 스레드 설계의 기본 요건이므로, jthread(자동 join)를 쓰거나, thread라면 스코프가 끝나기 전에 반드시 join하는 구조로 만드십시오. detach가 허용되는 것은 프로세스와 운명을 같이해도 되고, 공유 상태에 전혀 손대지 않는다고 보장할 수 있는 한정된 장면뿐입니다.
- volatile은 C++에서도 스레드 간 동기화에 쓸 수 있나요?
- 쓸 수 없습니다. C++의 volatile은 memory-mapped I/O처럼 '컴파일러가 최적화하지 않았으면 하는 읽기·쓰기'를 위한 한정자이며, 스레드 간 가시성이나 순서를 보장하지 않습니다. 여러 스레드가 같은 변수에 동기화 없이 접근하면 data race이고 undefined behavior입니다. 스레드 간에 공유하는 flag나 counter에는 std::atomic을 쓰고, 여러 변수를 한꺼번에 지키려면 std::mutex를 쓰십시오. std::atomic은 연산의 원자성과 memory_order에 따른 순서 부여를 함께 제공합니다.
- std::async는 간편해 보이는데, 함정이 있나요?
- 가장 큰 함정은 future의 소멸자입니다. std::async로 띄운 작업에 묶인 future(또는 마지막 shared_future)는 작업이 끝나기 전에 소멸자가 실행되면 완료될 때까지 block합니다. 반환 future를 받지 않고 버리면 그 자리에서 동기 실행과 같아져 '비동기로 만든 줄 알았는데 직렬'이 됩니다. 또한 launch policy를 지정하지 않으면 실제로 다른 스레드에서 돌지는 구현에 달렸습니다. 쓴다면 future의 수명을 명시적으로 관리하고, 확실히 병렬로 돌리고 싶은 곳에서는 std::launch::async를 지정하십시오.