멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기

· · Windows, 멀티스레드, C++, Visual Studio, 업무 애플리케이션, 불량 조사, 설계

「C#에서는 잘 동작하던 설계를 C++로 가져왔더니 가끔 죽게 되었다」「std::thread를 사용했더니 예외가 발생했을 때 애플리케이션째로 terminate로 즉사했다」「volatile bool 플래그로 멈추고 있었는데 릴리스 빌드에서만 멈추지 않는다」── C++의 멀티스레드에는 매니지드 언어에는 없는 고유한 두려움이 있습니다. 데이터 경쟁이 그대로 미정의 동작(undefined behavior)이 되기 때문입니다. 깨진 값을 읽는 정도로 끝나지 않고, 컴파일러의 최적화 전제가 무너져 ‘무슨 일이 일어나도 이상하지 않은’ 상태가 됩니다.

이 글은 멀티스레드 실무 시리즈의 C++ 편입니다. 업무 애플리케이션·장치 제어·DLL을 모던 C++(C++17/20)로 작성하는 개발자를 대상으로, 멀티스레드 설계의 원칙 ── 스레드를 직접 늘리지 않는다, 공유 가변 상태를 줄인다, 락의 규율, 멈추는 방법을 먼저 설계한다 ── 을 C++와 Windows의 도구로 풀어내고, C++ 고유의 함정과 함께 2026년 8월 시점의 1차 정보를 바탕으로 정리합니다. 본편만으로도 읽을 수 있도록 썼습니다. 같은 원칙을 다른 언어로 전개한 「.NET 편」「C 언어 편」「Java 편」도 있습니다.

1. 먼저 결론

  • C++에서 데이터 경쟁은 ‘깨진 값을 읽는다’가 아니라 ‘미정의 동작’입니다. 동기화되지 않은 공유 가변 접근을 단 한 곳도 남기지 않는 것이, 다른 언어보다 더 절대적인 조건이 됩니다.1
  • std::thread를 맨몸으로 사용하지 마십시오. joinable한 채로 std::thread의 소멸자가 실행되면 std::terminate로 프로세스가 즉사합니다. C++20의 std::jthread는 소멸자에서 자동으로 join하며, 정지 요청(stop_token)도 내장하고 있습니다.23
  • 락은 반드시 RAII로 잡습니다. mtx.lock()을 손으로 쓰는 것은 그만두고 lock_guard / scoped_lock을 사용합니다. 예외가 날아가도 소멸자가 확실하게 해제합니다. 여러 락을 동시에 획득할 때는 scoped_lock이 데드락 회피 알고리즘으로 처리해 줍니다.4
  • volatile은 동기화 도구가 아닙니다. 공유 플래그·카운터는 std::atomic, 여러 변수의 보호는 std::mutex입니다. std::atomic은 불가분성과 memory_order에 의한 순서 부여를 제공합니다.5
  • 대기는 condition_variable의 술어 있는 wait으로 합니다. 조건 변수에는 스퓨리어스 웨이크업(통지 없이 깨어나는 현상)이 있기 때문에, 술어 없는 wait은 오류의 온상입니다.6
  • 정지 방법은 jthread + stop_token(C++20)이 기본형입니다. 그 이전 환경에서는 std::atomic<bool> + 조건 변수로 협조적 정지를 구성합니다. 스레드의 강제 종료는 C++의 세계에는 존재하지 않는다고 생각하십시오.3
  • std::async는 future의 소멸자가 블록될 수 있다는 것을 알고 나서 사용하십시오. 반환값을 버리면 직렬 실행과 같아집니다.7
  • Win32 동기화 오브젝트가 등장할 자리는 ‘Win32 대기 API와의 연계’와 ‘프로세스 간’뿐입니다. 그 외에는 표준 라이브러리로 작성하는 것이 이식성·유지보수성 면에서 유리합니다.8

2. 왜 멀티스레드는 어려운가 ── 경쟁 상태·데드락·미정의 동작

멀티스레드가 가져오는 문제는 언어를 불문하고 파고들면 두 종류입니다.

경쟁 상태(race condition)는 여러 스레드가 어떤 순서로 특정 코드에 도달하느냐에 따라 결과가 달라지는 버그입니다. 대표적인 예가 공유 카운터로, ++count라는 하나의 식은 기계어 수준에서는 ‘읽기 → 덧셈 → 다시 쓰기’라는 3단계로 나뉩니다. 두 스레드가 이 3단계에 동시에 들어가면 한쪽의 덧셈이 다른 쪽의 다시 쓰기에 덮여 사라집니다. 실행할 때마다 결과가 달라지고, 어떤 결과가 될지 예측할 수 없습니다.

스레드B공유 변수 count스레드A스레드B공유 변수 count스레드Acount = 102번 더했는데 count = 11스레드A의 덧셈이 사라졌다읽기-10읽기-10로컬에서 덧셈-11로컬에서 덧셈-11다시 쓰기-11다시 쓰기-11

그림1: 공유 카운터에서 덧셈이 사라지는 전형적인 경쟁 상태. ++count의 3단계 사이에 다른 스레드가 끼어들면 나중에 다시 쓴 쪽이 덮어쓴다

데드락은 두 스레드가 서로 상대가 쥐고 있는 락을 기다리며 어느 쪽도 앞으로 나아갈 수 없게 된 상태입니다. 스레드A가 락1을 쥐고 락2를 기다리고, 스레드B가 락2를 쥐고 락1을 기다린다 ── 이것만으로 양쪽 모두 영원히 멈춥니다.

락2 해제 대기락1 해제 대기스레드A락1 보유 중스레드B락2 보유 중

그림2: 데드락의 순환 대기. 대기의 화살표가 고리를 이루는 순간, 고리 안의 모든 스레드가 영원히 정지한다

까다로운 것은 둘 다 타이밍에 의존한다는 점입니다. 개발 머신에서는 수만 번에 한 번밖에 발생하지 않는 실행 순서의 조합이, 코어 수도 타이밍도 다른 고객 머신에서는 매일 일어납니다. ‘디버거를 붙이면 재현되지 않는다’, ‘로그를 추가했더니 사라졌다’라는 것도 관측 자체가 타이밍을 바꿔 버리기 때문이며, 경쟁 버그의 전형적인 행동입니다. 그렇기 때문에 이 글의 원칙은 모두 ‘올바르게 동기화한다’보다 먼저 ‘동기화가 필요한 곳을 줄인다’라는 하나의 방향을 향하고 있습니다.

2.1. C++에서는 데이터 경쟁이 그대로 미정의 동작이 된다

거기에 더해 C++에는 다른 언어보다 한층 더 깊은 사정이 있습니다. C++ 규격에서는 어떤 메모리 위치에 대해 여러 스레드가 동기화 없이 접근하고 그중 적어도 하나가 쓰기라면, 그것은 데이터 경쟁이며 미정의 동작입니다. C++ Core Guidelines의 병행성 장(CP.2 ‘데이터 경쟁을 피하라’)은 이것을 절대 규칙으로 맨 처음에 내걸고 있습니다.1 미정의 동작이란 ‘오래된 값이든 새로운 값이든 어느 쪽인가는 읽을 수 있다’는 만만한 이야기가 아닙니다. 컴파일러는 ‘데이터 경쟁은 존재하지 않는다’는 전제로 최적화하기 때문에, 루프에서 조건 판정이 사라지거나 쓰기가 재배열·통합되는 등 소스 코드에서는 상상할 수 없는 동작이 정당하게 일어납니다. 「volatile bool 정지 플래그가 릴리스 빌드에서만 듣지 않는다」는 고전적인 사고가 이것의 전형입니다.

2.2. RAII가 기반이 된다

또 하나의 C++ 고유의 전제가 예외와 리소스 관리입니다. C++에는 finally가 없는 대신 RAII(소멸자에 의한 자동 해제)가 있으며, 멀티스레드 도구들도 RAII를 전제로 설계되어 있습니다. ‘락은 오브젝트의 수명으로 관리한다’, ‘스레드의 합류도 오브젝트의 수명으로 보장한다’ ── 이 흐름에 올라타는 것이 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

std::threadjoin도 detach도 하지 않음std::threadjoin 완료std::jthread, C++20스레드를 시작했다스코프를 벗어날 때어떻게 되는가?std::terminate프로세스 즉사안전하게 합류자동으로 request_stop + join예외가 나도 안전

그림3: 스레드 오브젝트의 수명과 끝나는 방식. std::thread는 ‘join을 잊으면 즉사’라는 사양이므로, C++20 이후에는 jthread를 기본값으로 한다

detach()는 원칙적으로 사용하지 않습니다. 합류 수단을 잃은 스레드는 프로세스 종료 시 정적 변수나 힙의 파괴와 경합해 종료 시 크래시의 단골 원인이 됩니다.

3.2. ‘스레드보다 위’의 도구 ── async·future와 병렬 알고리즘

.NET 편의 ‘스레드를 직접 만들지 않는다’는 원칙은 C++에서는 다음 도구에 대응합니다.

  • std::async + std::future: 단발성 비동기 작업과 결과 수신. 다만 중요한 사양으로, std::async로 시작한 작업에 묶인 future(또는 마지막 shared_future)는 작업이 미완료인 채로 소멸자가 실행되면 완료될 때까지 블록됩니다.7 std::launch::async로 실제로 시작된 작업이라면, 반환된 future를 버린 순간 동기 실행과 동등해집니다. 게다가 시작 정책을 지정하지 않은 기본값에서는 구현체가 deferred(지연 실행)를 선택할 수도 있는데, 그 경우 get() / wait()을 아무도 호출하지 않으면 작업 자체가 실행되지 않고 조용히 사라집니다. 확실히 병렬 실행하고 싶다면 std::launch::async를 명시하고, future의 수명을 소유자가 관리하십시오.
  • PPL(Parallel Patterns Library)의 concurrency::parallel_for / parallel_for_each: 컬렉션의 모든 요소에 대한 병렬 적용. 다만 1회 반복의 작업량이 너무 작으면 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::mutexstd::atomicfetch_add든 상관없습니다.

값으로 전달한다. 스레드 시작 시 필요한 데이터를 복사(또는 이동)로 전달해 버리면 그 데이터는 스레드 전유가 되어 동기화가 불필요해집니다. 람다의 캡처를 참조([&])로 해서 수명이 끝난 변수를 건드리는 사고가 많으므로, 스레드에 전달하는 람다는 명시적 캡처로, 원칙적으로 복사 또는 이동으로 합니다. 다만 ‘복사했으니 전유’가 성립하는 것은 값이 포인터나 shared_ptr 같은 별명(에일리어스)을 포함하지 않는, 깊은 값 그래프인 경우뿐입니다. 원시 포인터가 들어 있는 구조체를 복사해도, 가리키는 곳은 여전히 공유된 채입니다.

const로 공유한다. 읽기만 되는 데이터는 몇 개의 스레드에서 동시에 읽어도 안전합니다. 설정값·마스터 데이터·계산의 입력 등은 구축 후 다시 쓰지 않는 const 공유(std::shared_ptr<const Config> 등)로 만들면 동기화 없이 공유할 수 있습니다. 주의할 점으로, shared_ptr<const T>가 금지하는 것은 그 핸들 경유의 변경뿐입니다. 어딘가에 비const인 별명이 남아 있거나 mutable 멤버가 다시 쓰이거나 하면 경쟁은 남으므로, ‘구축이 끝나면 비const 참조를 손에서 놓고 이후 아무도 쓰지 않는다’는 것까지 포함해 설계하십시오. ‘변경이 필요해지면 다시 쓰는 것이 아니라 새 오브젝트를 만들어 교체한다’라고 정하기만 해도, 지켜야 할 가변 상태가 하나 줄어듭니다(교체 자체의 수명 관리는 5.2의 주의 참조).

큐로 전달한다. 스레드 간의 데이터 흐름은 공유 변수가 아니라 생산자/소비자 큐로 몰아넣습니다. 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();   // 통지는 락 바깥에서
        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가 블록되는 것이 자연스러운 배압(백프레셔)으로 작용해, 과부하를 기계적으로 상류로 전달합니다. 둘째, 조건 변수에는 스퓨리어스 웨이크업(통지가 없는데도 깨어나는 현상)이 있으므로, wait은 반드시 술어를 붙여 호출합니다. 술어가 붙은 wait이 내부에서 ‘조건이 참이 될 때까지 루프’를 대신 실행해 줍니다.6

5. 락의 규율 ── RAII와 scoped_lock

공유 가변 상태를 줄여도 완전히 0으로 만들 수 없는 경우가 많습니다. 남은 공유에는 배타 제어를 사용하지만, 규율 없는 락은 경쟁을 감추기만 할 뿐입니다.

먼저, 락의 단위는 ‘코드의 구간’이 아니라 ‘데이터’로 생각합니다. 지키고 싶은 가변 데이터 집합마다 뮤텍스를 하나씩 대응시키고(외부에 공개하지 않는 private 멤버로 합니다), 그 데이터를 건드리는 모든 곳에서 같은 뮤텍스를 잡는다 ── 이 대응표가 무너진 것이 경쟁 버그의 실체입니다. 그리고 락을 쥐고 있는 동안 해도 되는 것은 지키고 있는 데이터의 읽기·쓰기뿐입니다. 락을 쥔 채 하는 파일 I/O·네트워크 호출·콜백(외부 코드 호출)은 보유 시간을 늘릴 뿐만 아니라, 호출한 곳이 다른 락을 잡으려다 데드락에 빠지는 경로를 만듭니다. 락 바깥에서 준비하고, 락 안에서는 교체만 한다가 기본형입니다.

5.1. lock()/unlock()의 직접 작성은 금지

std::mutexlock() / unlock()을 직접 호출하는 코드는 예외나 조기 반환으로 해제 누락을 일으킵니다. 락의 획득과 해제는 반드시 RAII 래퍼에 맡깁니다.

래퍼 용도
std::lock_guard 뮤텍스 하나를 스코프 동안만 쥐는, 가장 기본적인 형태
std::scoped_lock(C++17) 여러 뮤텍스를 동시에 획득한다. 데드락 회피 알고리즘으로 순서 문제를 해결4
std::unique_lock 도중에 해제·재획득하고 싶은 경우나 condition_variable::wait에 전달하는 경우

락이 두 개 이상 있을 때, 획득 순서가 스레드에 따라 뒤바뀌는 것이 데드락의 고전적인 패턴입니다(그림2의 순환 대기는 이렇게 생겨납니다). 대책은 ‘모든 스레드가 같은 순서로 잡는다’를 규칙화하는 것이지만, 동시에 잡는 상황이라면 C++에는 더 나은 답이 있어서, std::scoped_lock에 여러 뮤텍스를 한꺼번에 넘기면 획득 순서의 데드락 회피를 라이브러리가 보증해 줍니다.4 두 오브젝트 간의 송금 처리 같은 ‘양쪽 다 잠그고 싶은’ 상황에서는 개별로 잡지 말고 반드시 한꺼번에 잡습니다.

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;
}

맨 앞의 동일성 체크는 장식이 아닙니다. 같은 Accountfromto에 전달되면, 재귀 불가능한 같은 뮤텍스를 scoped_lock에 두 번 넘기게 되어 행이나 미정의 동작의 원인이 됩니다. ‘양쪽 다 잠그는’ 함수에는 반드시 동일 오브젝트 배제를 함께 넣으십시오.

‘읽기는 많지만 쓰기는 드문’ 데이터에는 std::shared_mutex(C++17)로 읽기-쓰기 락을 사용할 수 있습니다.12 또한 recursive_mutex는 ‘같은 스레드가 다시 획득해도 깨지지 않는’ 타입이지만, 재귀 획득이 필요해지는 설계는 락의 책임 경계가 모호해졌다는 신호인 경우가 많으므로, 구조 재검토를 먼저 고려하십시오.

5.2. atomic의 올바른 위치

std::atomic은 단일 변수에 대한 불가분 연산과, memory_order에 기반한 순서 부여를 제공합니다.5 등장할 자리는 .NET 편의 Interlocked와 같으며, 카운터·플래그 같은 단일 변수의 갱신입니다. 여러 변수를 한꺼번에 정합시킬 수는 없으므로, 그런 경우는 std::mutex로 돌아갑니다.

원시 포인터의 교체(std::atomic<T*>)에는 고유의 함정이 있습니다. 교체 자체는 불가분이어도, 교체한 후의 오래된 오브젝트 수명은 아무도 지켜주지 않습니다. 읽는 쪽이 오래된 포인터를 로드한 직후에 쓰는 쪽이 교체하고 delete하면, 해제된 메모리에 대한 접근입니다. ‘불변 오브젝트를 교체해 공유한다’는 설계를 C++에서 하려면, 락으로 지킨 std::shared_ptr<const T>의 교체(또는 C++20의 std::atomic<std::shared_ptr<T>>)처럼 수명 관리와 세트가 된 수단을 선택하십시오.

그리고 반복하지만, volatile은 스레드 동기화 도구가 아닙니다. memory_order를 직접 지정하는 락프리 프로그래밍은 기본값(seq_cst)에서 완화할 정당한 이유와 검증 수단을 가진 전문가의 영역입니다. 업무 애플리케이션에서는 기본값 그대로 사용하거나, 애초에 mutex로 작성하십시오.

6. 정지 방법의 설계 ── stop_token과 협조적 정지

멀티스레드 설계 리뷰에서 가장 먼저 물어야 할 질문은 ‘이건 어떻게 멈추는 겁니까’입니다. 그리고 C++에는 스레드를 외부에서 안전하게 멈추는 수단이 존재하지 않습니다(Win32의 TerminateThread가 얼마나 위험한지는 C 언어 편에서 자세히 다룹니다). 따라서 정지 방법은 협조적 정지 ── 멈추는 쪽은 요청만 보내고, 언제·어떻게 끝날지는 스레드 스스로 뒷정리가 잘 되는 곳에서 정하며, join의 완료로 ‘멈췄다’고 간주한다 ── 를 C++의 도구로 구성합니다.

C++20에서는 std::jthread가 정지 메커니즘을 내장하고 있습니다. request_stop()을 호출하면 스레드 함수가 받은 std::stop_token에 정지 요청이 서고, 루프는 그것을 폴링합니다. condition_variable_anywaitstop_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);          // 내부에서 블록될 수 있는 처리에도 st를 전달
                        } catch (...) {
                            ReportError(std::current_exception());  // 한 건의 실패는 기록하고 계속
                        }
                    }
                }
            } catch (...) {
                // 스레드 경계의 최종 방어선(Pop이나 이동의 실패도 여기서 받는다).
                // 여기서 예외가 새어 나오면 std::terminate로 프로세스째 죽으므로,
                // ReportError는 예외를 던지지 않는 구현으로 해 둔다
                ReportError(std::current_exception());
            }
        });
    }
    // 명시적인 Stop은 불필요:
    // Worker의 소멸자 → jthread의 소멸자 → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // 용량 상한 있음(4장)
    std::jthread thread_;
};
정지 요청멈추는 쪽jthread의 소멸자 또는 request_stopstop_token계산 루프:stop_requested를 폴링대기 중인 스레드:condition_variable_any::wait lock, st, pred가 즉시 깨어난다뒷정리하고 스스로 returnjoin으로 합류 완료여기서 비로소 멈췄다고 말할 수 있다

그림4: C++20의 협조적 정지. 멈추는 쪽은 요청만 보내고, 끝나는 방식은 스레드 스스로 정하며, join의 완료로 정지로 간주한다

또 하나, 워커 안의 try/catch는 생략할 수 없습니다. jthread가 예외 안전하게 해 주는 것은 join뿐이며, 스레드 함수에서 예외가 새어 나오면 std::thread와 마찬가지로 std::terminate로 프로세스가 죽습니다. 작업 한 건의 실패를 어떻게 다룰지(기록하고 계속할지, 에러 채널로 소유자에게 전달할지)는 스레드 경계에서 명시적으로 정해 둡니다.

같은 이유로, Process에도 stop_token을 전달하고 있는 점에 주목하십시오. 작업 한 건의 처리가 내부에서 블록되는(네트워크 대기·긴 계산 등) 경우, 그곳이 정지 요청을 관측할 수 없으면 소멸자의 암묵적인 join이 그 한 건의 완료를 계속 기다리게 됩니다. 협조적 정지는 ‘기다리는 모든 곳’에 토큰이 전달되어야 비로소 성립합니다. 중단할 수 없는 외부 호출을 포함한다면, 타임아웃을 붙여 한 건의 실행 시간에 상한을 두십시오.

C++17 이전 환경에서는 std::atomic<bool>의 정지 플래그 + condition_variablenotify_all로 같은 구조를 직접 구성합니다. 이때 정지 플래그 확인을 조건 변수의 술어에 포함시키는 것이 요점입니다(플래그만 세우고 통지를 잊으면, 대기 중인 스레드가 영원히 깨어나지 않습니다).

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 등 커널 오브젝트
프로세스를 넘나드는 배타·통지 이름 붙은 Mutex·이벤트·세마포어
Win32 API를 직접 사용하는 프로세스 내 락 SRW 락(재귀가 필요할 때만 CRITICAL_SECTION)8

프로세스 간에 공유 메모리를 배타 제어하는 구체적인 설계는 「공유 메모리를 사용할 때의 함정과 베스트 프랙티스」를 참조하십시오.

7.2. DllMain에서 스레드를 건드리지 않는다

DLL을 작성할 때의 중대한 제약이 로더 락입니다. DllMain은 로더 락을 쥔 상태로 호출되기 때문에, 그 안에서 다른 스레드와 동기화하거나, 스레드 종료를 기다리거나, LoadLibrary를 호출하는 등의 조작은 데드락이나 미확정 동작의 원인이 됩니다. 스레드를 시작·합류시키는 초기화는 DllMain의 바깥(명시적인 초기화 함수)으로 빼내십시오.13

7.3. UI 스레드와 COM 아파트먼트

Windows의 데스크톱 애플리케이션에는 언어와 무관하게 적용되는 강한 제약이 있습니다. 윈도우와 컨트롤은 그것을 만든 스레드(UI 스레드)만 건드릴 수 있다는 규칙입니다. Windows는 윈도우 메시지를 그 윈도우를 만든 스레드의 메시지 큐로 배달하기 때문에, UI의 생성과 조작은 그 스레드에 집중되어 있어야 합니다. 워커 스레드에서 화면을 갱신하고 싶을 때는 직접 건드리지 말고 PostMessage(비동기)로 UI 스레드에 의뢰하고, UI 스레드 쪽의 윈도우 프로시저에서 처리합니다. 동기형인 SendMessage는 UI 스레드가 그 워커의 완료를 기다리고 있는 상황에서 호출하면 서로 기다리는 데드락이 되므로, 워커로부터의 통지는 비동기형을 기본값으로 하십시오. COM이 관련된 경우의 STA/MTA는 「COM STA/MTA 기초 - 스레드 모델과 행(hang)을 피하는 사고방식」에서 해설하고 있습니다. 또한 /clr로 컴파일하는 C++/CLI 코드에서는 <thread> <mutex> 등의 표준 스레드 헤더가 차단된다는 점에도 주의가 필요합니다.14

8. 검증과 디버깅 ── ‘재현되지 않는다’는 전제로 대비하기

경쟁 버그는 테스트로 발견될 것이라고 기대할 수 없습니다. 일반적인 테스트는 ‘어쩌다 경쟁하지 않은’ 실행을 성공으로 세어 버리기 때문입니다. 대비는 3층으로 생각합니다.

첫 번째 방어선은 여기까지의 설계 원칙 그 자체입니다. 리뷰에서는 ‘공유하고 있는 가변 데이터는 무엇인가’, ‘각각 어떤 뮤텍스가 지키는가’, ‘여러 락의 획득 순서는 유일한가(또는 scoped_lock으로 한꺼번에 잡고 있는가)’, ‘정지 경로는 어디인가’를 표로 확인합니다. 이 표를 작성할 수 없는 설계는 동작하고 있더라도 아직 완성되지 않은 것입니다.

둘째, 이상을 숨기지 않고 관측 가능하게 만듭니다. 잡을 수 없어야 할 락에는 timed_mutextry_lock_forcondition_variablewait_for로 타임아웃을 붙이고, 시간 초과를 이상으로 로그에 남기면 영원한 행을 검출 가능한 실패로 바꿀 수 있습니다. 스레드 경계의 try/catch(6장)에서 받은 예외는 반드시 기록합니다. 행이나 크래시 현장에서는 덤프를 채취해 모든 스레드의 스택을 확인하고, 서로의 락 대기가 순환하고 있지 않은지를 봅니다. 덤프와 로그 정비는 「Windows 앱이 프로그램 실수에 의한 예외로 떨어져도 확실히 로그를 남기려면 - in-process에 걸지 않는 설계와 WER / 최종 로그 / 감시 프로세스의 베스트 프랙티스」에서 다루고 있습니다.

셋째, 부하를 걸어 흔듭니다. 코어 수보다 많은 병렬도로 장시간 돌린다, 처리 순서를 무작위화한다, 인위적인 지연을 삽입한다 같은 스트레스 테스트는 개발 머신에서 경쟁의 ‘당첨’을 뽑기 쉽게 만드는 현실적인 수단입니다. 디버그 빌드에서는 사라지는 버그도, 최적화된 릴리스 빌드+고부하라면 재현되는 경우가 자주 있습니다.

9. 정리 ── C++판 체크리스트

언어 공통의 원칙(스레드를 직접 만들지 않는다·공유 가변 상태의 최소화·락과 데이터의 1대1 대응·협조적 정지)에, C++ 고유의 확인을 더합니다.

  1. std::thread를 맨몸으로 사용하고 있지 않은가(jthread로 바꿀 수 없는가, join이 예외 경로에서도 보장되는가)
  2. detach()를 사용하고 있지 않은가
  3. 람다 캡처는 명시적인가, 참조 캡처한 변수의 수명은 스레드보다 긴가
  4. 동기화 없는 공유 가변 접근(=미정의 동작)이 한 곳도 없다고 단언할 수 있는가
  5. lock() / unlock()을 직접 작성한 곳이 없는가, 여러 락은 scoped_lock으로 한꺼번에 잡고 있는가
  6. condition_variable::wait은 모두 술어가 붙어 있는가
  7. 공유 플래그에 volatile을 사용하고 있지 않은가(std::atomic으로 되어 있는가)
  8. 정지 경로는 stop_token(또는 atomic 플래그+통지)으로 설계되어 있고, join으로 합류 완료를 확인하고 있는가
  9. std::async의 future를 버리고 있지 않은가
  10. DllMain에서 스레드의 시작·동기화·join을 하고 있지 않은가

C++의 멀티스레드는 ‘미정의 동작’이라는 절벽 바로 옆을 걷는 작업이지만, 뒤집어 말하면 RAII와 표준 라이브러리의 방식에 순순히 올라타는 것만으로 절벽에서 크게 거리를 둘 수 있습니다. jthread·scoped_lock·술어 있는 wait·atomic ── 도구의 기본값을 올바르게 선택하는 것이, C++에서는 설계 원칙의 실천 그 자체입니다.

관련 글

관련 상담 영역

합동회사 코무라소프트는 C++로 만든 애플리케이션·DLL의 멀티스레드 설계 리뷰, ‘가끔 죽는다·릴리스 빌드에서만 이상하다’ 같은 경쟁에서 비롯된 불량 조사(덤프 분석), 레거시 스레드 코드의 모던 C++로의 이관 상담을 다루고 있습니다.

참고 링크

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. 병행·병렬 장의 첫머리 규칙으로 CP.1(자신의 코드가 멀티스레드에서 동작한다고 가정하라)과 CP.2(데이터 경쟁을 피하라)가 내걸려 있으며, 데이터 경쟁이 있으면 어떤 보증도 성립하지 않는다는 것, 락의 보유 범위나 RAII 이용 등 병행 코드의 설계 규칙이 체계화되어 있다는 것에 대하여.  2

  2. cppreference.com, std::jthread. C++20의 jthread가 std::thread와 달리 소멸자에서 자동으로 request_stop()을 호출한 뒤 join한다는 것, 스레드 함수의 첫 번째 인수로 std::stop_token을 받을 수 있다는 것, 이로 인해 예외 발생 시에도 스레드의 합류와 정지 요청이 보장된다는 것에 대하여.  2

  3. 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

  4. Microsoft Learn, scoped_lock Class. C++17의 scoped_lock이 구축 시 하나 이상의 뮤텍스를 획득하고 소멸자에서 해제한다는 것, 여러 뮤텍스를 전달한 경우 std::lock 상당의 데드락 회피 알고리즘으로 획득된다는 것, 예외가 던져져도 확실하게 해제된다는 것, 단일 뮤텍스라면 lock_guard/unique_lock도 선택지가 된다는 것에 대하여.  2 3

  5. Microsoft Learn, <atomic>. 아토믹 연산이 불가분이기 때문에 다른 스레드에서는 연산의 전후 상태만 관측할 수 있다는 것, memory_order 인수에 기반해 다른 아토믹 연산의 가시성에 대한 순서 요건을 확립하고 이에 반하는 컴파일러 최적화를 억제한다는 것, atomic_flag가 항상 락프리라는 것, /clr:pure에서는 이 헤더가 차단된다는 것에 대하여.  2

  6. Microsoft Learn, <condition_variable>. 조건 변수의 대기에는 뮤텍스가 필요하며 대기 중에는 락이 풀린다는 것, 통지 없이 깨어나는 스퓨리어스 웨이크업이 존재하기 때문에 대기 측은 복귀 시 조건을 명시적으로 확인해야 한다는 것, 술어가 붙은 wait(lock, pred)이 그 루프를 대신해 준다는 것, condition_variable_any가 임의의 뮤텍스 타입과 조합될 수 있다는 것에 대하여.  2

  7. Microsoft Learn, <future>. future와 shared_future의 소멸자는 원칙적으로 블록되지 않지만, 유일한 예외로 std::async로 시작된 작업에 묶인 future(또는 마지막 shared_future)는 작업이 미완료인 채로 소멸자가 실행되면 공유 상태가 ready가 될 때까지 블록된다는 것, 이 동작이 규격의 노트로 명기되어 있다는 것에 대하여.  2

  8. Microsoft Learn, About Synchronization. Win32 동기화 프리미티브 선택 지침으로 이식성을 중시하는 C++ 코드에는 std::mutex / std::shared_mutex와 RAII가 권장된다는 것, Win32의 대기 API나 프로세스 간 동기화가 필요한 경우에 Win32 동기화 오브젝트를 사용한다는 것, 프로세스 내 신규 코드의 기본값은 SRW 락이며 재귀 획득이 필요할 때만 CRITICAL_SECTION이라는 것, 프로세스 내 동기화에 Mutex를 사용하는 것은 항상 커널 전이를 동반하는 ‘흔한 실수’라는 것에 대하여.  2 3

  9. cppreference.com, std::thread::~thread. std::thread의 소멸자가, 스레드가 joinable한 채로(join도 detach도 되지 않은 채로) 호출된 경우 std::terminate를 호출한다는 것, 즉 스레드 오브젝트를 파괴하기 전에 반드시 join 또는 detach의 판단을 마쳐야 한다는 것에 대하여. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. 병렬화는 가능한 한 높은 레벨(바깥쪽 루프)에서 표현해야 한다는 것, 각 반복의 작업이 작거나 불균형한 병렬 루프에서는 fork/join의 스케줄링 오버헤드가 병렬 실행의 이득을 웃돌 수 있다는 것, 그 경향은 프로세서 수가 늘어날수록 강해진다는 것에 대하여. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. C++17의 병렬 알고리즘 라이브러리가 완성되어 있는 한편, ‘완성’이 모든 알고리즘이 모든 경우에 병렬화된다는 것을 의미하지는 않으며, 가장 중요한 알고리즘이 병렬화되고 병렬화되지 않은 것에도 실행 정책의 시그니처가 제공된다는 구현 방침에 대하여. 

  12. 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)가 정리되어 있다는 것에 대하여. 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. DllMain이 로더 락을 쥔 상태로 호출되기 때문에 호출할 수 있는 API에 중대한 제약이 있다는 것, DllMain 안에서 다른 스레드와의 동기화를 수행하면 데드락에 빠질 수 있다는 것, LoadLibrary 호출이나 스레드 종료 대기가 전형적인 금지 사항이라는 것, 초기화는 가능한 한 지연시켜 DllMain 바깥으로 빼내야 한다는 것, 락 계층을 정의해 로더 락을 최상위에 두어야 한다는 것에 대하여. 

  14. Microsoft Learn, <thread>. <thread> 헤더가 thread 클래스와 sleep_for 등의 보조 함수를 정의한다는 것, /clr로 컴파일되는 코드에서는 이 헤더가 차단된다는 것, STDCPP_THREADS 매크로로 스레드 지원 여부를 판정할 수 있다는 것에 대하여. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

std::mutex 와 Win32의 CRITICAL_SECTION·SRW 락은 어떻게 구분해서 사용해야 하나요?
이식성을 중시하는 일반적인 C++ 코드에서는 std::mutex / std::shared_mutex 와 RAII 래퍼(lock_guard / scoped_lock)가 첫 번째 선택지입니다. Win32의 동기화 오브젝트를 선택하는 경우는 WaitForMultipleObjects 같은 Win32의 대기 API와 조합하고 싶을 때, 또는 이름 붙은 오브젝트로 프로세스를 넘나드는 동기화가 필요할 때입니다. 프로세스 내에서 Win32 API를 직접 사용한다면 신규 코드의 기본값은 SRW 락이며, 같은 스레드의 재귀 획득이 필요할 때만 CRITICAL_SECTION을 사용합니다. 프로세스 내 배타 제어에 Win32 Mutex를 사용하는 것은 항상 커널 전이를 동반해 느려지는 전형적인 실수입니다.
std::thread 의 detach()를 사용해도 되나요?
원칙적으로 피해야 합니다. detach된 스레드는 합류(join)할 수단을 잃고, 프로세스 종료 시 그 스레드가 아직 동작 중인지 제어할 수 없게 됩니다. 정적 변수나 힙이 파괴된 후에 detach된 스레드가 계속 동작해 종료 시 크래시를 일으키는 것이 전형적인 사고입니다. '끝을 기다릴 수 있다'는 것은 스레드 설계의 기본 요건이므로, jthread(자동 join)를 사용하거나 thread라면 스코프를 벗어나기 전에 반드시 join하는 구조로 만드십시오. detach가 허용되는 것은 프로세스와 운명을 같이해도 좋고 공유 상태에 일절 접근하지 않는다는 것을 보장할 수 있는 한정적인 상황뿐입니다.
volatile은 C++에서도 스레드 간 동기화에 사용할 수 있나요?
사용할 수 없습니다. C++의 volatile은 메모리 매핑 I/O처럼 '컴파일러가 최적화하지 않았으면 하는 읽기·쓰기'를 위한 지정자로, 스레드 간의 가시성이나 순서를 보장하는 것이 아닙니다. 여러 스레드가 동기화 없이 같은 변수에 접근하면 데이터 경쟁이며 미정의 동작입니다. 스레드 간에 공유하는 플래그나 카운터에는 std::atomic을 사용하고, 여러 변수를 한꺼번에 지키려면 std::mutex를 사용하십시오. std::atomic은 연산의 불가분성과 memory_order에 기반한 순서 부여를 함께 제공합니다.
std::async는 간편해 보이는데, 함정이 있나요?
가장 큰 함정은 future의 소멸자입니다. std::async로 시작한 작업에 묶인 future(또는 마지막 shared_future)는 작업이 미완료인 채로 소멸자가 실행되면 완료될 때까지 블록됩니다. 반환된 future를 받지 않고 버리면 그 자리에서 동기 실행과 같아져 '비동기로 만든 셈인데 직렬'이라는 사고가 일어납니다. 또한 시작 정책을 지정하지 않으면 실제로 별도 스레드에서 실행될지는 구현에 달려 있습니다. 사용한다면 future의 수명을 명시적으로 관리하고, 확실히 병렬 실행하고 싶은 곳에서는 std::launch::async를 지정하십시오.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기