C++ 멀티스레딩 기초: std::thread·mutex·생산자-소비자와 자주 겪는 문제

이 글의 핵심

스레드를 여러 개 띄우는 것 자체는 쉽지만, 공유 데이터를 보호하지 않으면 결과가 실행할 때마다 달라지고 락 순서가 엇갈리면 프로그램이 멈춥니다. 병렬 계산 예제로 기본을 익힌 뒤, 적절한 스레드 수, join과 detach 선택, async와 thread의 차이 같은 결정 기준을 FAQ로 정리합니다.

왜 멀티스레딩이 필요한가

C++11 이전에는 스레드를 만들려면 POSIX의 pthread나 Windows의 CreateThread 같은 플랫폼 종속 API를 직접 다뤄야 했습니다. std::thread, std::mutex, std::condition_variable이 표준 라이브러리에 들어오면서 플랫폼에 상관없이 동일한 코드로 스레드를 다룰 수 있게 됐고, 이것이 오늘날 C++로 동시성 프로그램을 작성할 때의 출발점입니다.

멀티스레딩을 쓰는 이유는 크게 두 갈래로 나뉩니다. 하나는 CPU 바운드 작업(대량 계산, 이미지 처리, 압축 등)을 여러 코어에 나눠 실제로 벽시계 시간을 줄이는 경우이고, 다른 하나는 I/O 바운드 작업(네트워크 요청, 디스크 읽기)에서 한 스레드가 대기하는 동안 다른 스레드가 작업을 이어가게 해 반응성을 높이는 경우입니다. 이 둘은 적정 스레드 개수를 결정하는 기준부터 다르므로, 뒤에서 CPU 바운드와 I/O 바운드를 구분해서 설명합니다.

다만 멀티스레딩은 공짜가 아닙니다. 스레드 생성·컨텍스트 스위칭 비용, 공유 자원 접근 시의 동기화 오버헤드, 그리고 무엇보다 디버깅 난이도가 급격히 올라간다는 대가가 따릅니다. 특히 레이스 컨디션(race condition)과 데드락(deadlock)은 로컬 환경에서는 재현되지 않다가 운영 환경의 부하 상황에서만 터지는 경우가 많아, 처음부터 왜 위험한지 원리를 이해하고 접근하는 것이 중요합니다.

기본 스레드 생성

std::thread 객체를 만드는 순간 새 스레드가 즉시 실행을 시작합니다. 생성자에 전달한 함수는 별도의 실행 흐름에서 독립적으로 돌아가며, main 스레드와 동시에 진행됩니다.

#include <iostream>
#include <thread>
using namespace std;

void printHello() {
    cout << "Hello from thread!" << endl;
}

int main() {
    thread t(printHello);  // 스레드 생성
    t.join();  // 스레드 종료 대기
    
    cout << "Main thread" << endl;
    
    return 0;
}

여기서 반드시 짚고 넘어가야 할 부분이 t.join()입니다. std::thread 객체가 “joinable” 상태(즉 join()이나 detach()를 아직 호출하지 않은 상태)로 소멸자에 도달하면, C++ 표준은 std::terminate()를 호출해 프로세스 전체를 즉시 종료시키도록 규정합니다. 이는 언뜻 과격해 보이지만 설계상 의도된 트레이드오프입니다. 소멸자가 join을 대신 호출해 버리면 프로그램이 예상치 못한 지점에서 조용히 블로킹될 수 있고, 반대로 자동으로 detach해 버리면 스레드가 참조하던 지역 변수가 스코프를 벗어난 뒤에도 계속 접근되는 댕글링 참조 문제가 생깁니다. 표준위원회는 이 두 가지 암묵적 동작 모두를 위험하다고 판단해, 대신 “결정하지 않았다면 즉시 크래시로 알린다”는 명시적 실패를 택했습니다.

저는 예외 처리 경로에서 이 문제를 실제로 겪은 적이 있습니다. 함수 초반에 std::thread를 생성하고 마지막 줄에서 join()을 호출하는 구조였는데, 중간의 어떤 검증 로직이 예외를 던지면 join() 줄에 도달하지 못한 채 스택 언와인딩이 시작됐고, 그 과정에서 스레드 객체의 소멸자가 joinable 상태로 호출되면서 std::terminate로 프로세스 전체가 죽었습니다. 예외 로그만 봐서는 원인을 짐작하기 어려웠고, 결국 코어 덤프를 분석하고서야 “예외 → 언와인딩 → 미조인 스레드 소멸”이라는 경로를 확인할 수 있었습니다. 이후로는 스레드를 멤버로 감싸 RAII로 소멸자에서 항상 join하거나, C++20의 std::jthread(소멸 시 자동으로 join)를 우선 검토하는 쪽으로 습관을 바꿨습니다.

람다와 매개변수

실무에서는 함수 포인터보다 람다를 캡처와 함께 넘기는 방식을 훨씬 자주 씁니다. std::thread의 생성자는 가변 인자 템플릿으로 구현되어 있어서, 실행할 콜러블 다음에 인자를 나열하면 내부적으로 값이 복사(또는 이동)되어 새 스레드의 스택으로 전달됩니다.

#include <iostream>
#include <thread>
using namespace std;

int main() {
    // 람다 사용
    thread t1([]() {
        cout << "Lambda thread" << endl;
    });
    
    // 매개변수 전달
    thread t2([](int x, string s) {
        cout << x << ": " << s << endl;
    }, 10, "Hello");
    
    t1.join();
    t2.join();
    
    return 0;
}

주의할 점은 thread t2(..., 10, "Hello")처럼 넘긴 인자들이 기본적으로 값 복사된다는 것입니다. 참조로 넘기고 싶다면 반드시 std::ref나 std::cref로 감싸야 하는데, 이는 뒤에 나오는 sumRange 예제에서 ref(results[i])로 결과를 받아오는 이유이기도 합니다. 이 규칙을 모르고 참조 매개변수를 받는 함수에 지역 변수를 그냥 넘기면, 컴파일러가 자동으로 값 복사 버전으로 바인딩해 버려서 원본 변수는 전혀 갱신되지 않는 조용한 버그가 생깁니다. 링크 에러나 런타임 크래시가 나면 오히려 발견하기 쉬운데, 이 경우는 컴파일도 실행도 되면서 결과만 틀리기 때문에 처음 겪으면 원인 찾기가 까다롭습니다.

mutex로 동기화

여러 스레드가 같은 메모리(여기서는 counter)를 동시에 읽고 쓰면 레이스 컨디션이 발생합니다. counter++는 하나의 연산처럼 보이지만 실제로는 “읽기 → 더하기 → 쓰기”의 세 단계로 컴파일되는데, 두 스레드가 이 단계들을 인터리빙하면서 실행하면 한쪽의 증가분이 유실될 수 있습니다. std::mutex는 임계 구역(critical section)에 한 번에 하나의 스레드만 들어가도록 강제해 이 문제를 막습니다.

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

mutex mtx;
int counter = 0;

void increment() {
    for (int i = 0; i < 1000; i++) {
        mtx.lock();
        counter++;
        mtx.unlock();
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    cout << "Counter: " << counter << endl;  // 2000
    
    return 0;
}

이 코드의 mtx.lock() / mtx.unlock() 짝은 정상적으로 실행되면 문제가 없지만, 예외 안전성 관점에서는 취약합니다. counter++와 mtx.unlock() 사이에서 예외가 던져지면 unlock()이 절대 호출되지 않고, 그 뮤텍스는 영원히 잠긴 채로 남습니다. 이후 같은 뮤텍스를 잠그려는 다른 모든 스레드는 무한정 대기하게 되므로, 실무 코드에서 lock()/unlock()을 수동으로 짝지어 쓰는 것은 지양하는 게 좋습니다. 바로 다음 절의 lock_guard가 이 문제를 해결하는 표준적인 방법입니다.

lock_guard (RAII)

C++의 RAII(Resource Acquisition Is Initialization) 관용구는 뮤텍스 잠금에도 그대로 적용됩니다. lock_guard는 생성자에서 lock()을 호출하고 소멸자에서 unlock()을 호출하는 얇은 래퍼로, 스코프를 벗어나는 모든 경로(정상 종료, return, 예외로 인한 스택 언와인딩)에서 자동으로 잠금이 해제됨을 보장합니다.

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

mutex mtx;

void safeIncrement(int& counter) {
    for (int i = 0; i < 1000; i++) {
        lock_guard<mutex> lock(mtx);  // 자동 unlock
        counter++;
    }  // 자동으로 unlock됨
}

int main() {
    int counter = 0;
    
    thread t1(safeIncrement, ref(counter));
    thread t2(safeIncrement, ref(counter));
    
    t1.join();
    t2.join();
    
    cout << "Counter: " << counter << endl;  // 2000
    
    return 0;
}

lock_guard는 재잠금이나 조건부 해제가 필요 없는 단순한 경우에 적합합니다. 잠금을 임시로 풀었다가 다시 걸어야 하거나(생산자-소비자 예제의 unique_lock::unlock()/lock()), 조건 변수와 함께 대기해야 한다면 더 유연한 unique_lock을 써야 합니다. 이 둘의 선택 기준은 단순합니다. 잠금 구간이 스코프와 정확히 일치하면 lock_guard, 그렇지 않고 잠금 상태를 직접 제어해야 하면 unique_lock입니다.

실전 예시

이제 실제로 여러 스레드를 동시에 다루는 세 가지 패턴을 살펴보겠습니다. CPU 바운드 계산을 나누는 경우, 스레드 간 신호를 조건 변수로 주고받는 경우, 그리고 스레드 생성 비용을 상각하는 스레드 풀입니다. 각각 적정 스레드 개수를 정하는 기준이 다르다는 점에 주목하면서 보시기 바랍니다.

예시 1: 병렬 계산

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

void sumRange(int start, int end, long long& result) {
    long long sum = 0;
    for (int i = start; i < end; i++) {
        sum += i;
    }
    result = sum;
}

int main() {
    const int N = 100000000;
    const int NUM_THREADS = 4;
    
    vector<thread> threads;
    vector<long long> results(NUM_THREADS);
    
    int range = N / NUM_THREADS;
    
    // 스레드 생성
    for (int i = 0; i < NUM_THREADS; i++) {
        int start = i * range;
        int end = (i == NUM_THREADS - 1) ? N : (i + 1) * range;
        threads.emplace_back(sumRange, start, end, ref(results[i]));
    }
    
    // 모든 스레드 대기
    for (auto& t : threads) {
        t.join();
    }
    
    // 결과 합산
    long long total = 0;
    for (long long r : results) {
        total += r;
    }
    
    cout << "합계: " << total << endl;
    
    return 0;
}

설명: 큰 계산을 여러 스레드로 나누어 병렬 처리합니다.

이 예제는 전형적인 CPU 바운드 작업입니다. NUM_THREADS를 무작정 늘린다고 계속 빨라지지는 않습니다. 물리 코어 수를 넘어서는 순간부터는 운영체제가 스레드들을 번갈아 스케줄링해야 하므로 컨텍스트 스위칭 비용만 늘고 실질적인 처리량은 오히려 떨어지는 경우가 많습니다. 이런 CPU 바운드 워크로드에서는 std::thread::hardware_concurrency()가 반환하는 논리 코어 수(하이퍼스레딩 포함) 근처를 기본값으로 잡고, 실제 벤치마크로 미세 조정하는 것이 정석입니다. 반대로 네트워크 요청이나 디스크 I/O처럼 스레드가 대부분의 시간을 “대기”하며 보내는 I/O 바운드 작업이라면, 코어 수보다 훨씬 많은 스레드를 띄워도 됩니다. 어차피 각 스레드가 CPU를 점유하는 시간은 짧고 대부분 커널이 I/O 완료를 기다리기 때문입니다.

예시 2: 생산자-소비자 패턴

#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
using namespace std;

queue<int> dataQueue;
mutex mtx;
condition_variable cv;
bool done = false;

void producer() {
    for (int i = 1; i <= 10; i++) {
        this_thread::sleep_for(chrono::milliseconds(100));
        
        {
            lock_guard<mutex> lock(mtx);
            dataQueue.push(i);
            cout << "생산: " << i << endl;
        }
        
        cv.notify_one();  // 소비자에게 알림
    }
    
    {
        lock_guard<mutex> lock(mtx);
        done = true;
    }
    cv.notify_all();
}

void consumer() {
    while (true) {
        unique_lock<mutex> lock(mtx);
        
        cv.wait(lock, [] {
            return !dataQueue.empty() || done;
        });
        
        while (!dataQueue.empty()) {
            int value = dataQueue.front();
            dataQueue.pop();
            lock.unlock();
            
            cout << "소비: " << value << endl;
            this_thread::sleep_for(chrono::milliseconds(150));
            
            lock.lock();
        }
        
        if (done && dataQueue.empty()) {
            break;
        }
    }
}

int main() {
    thread prod(producer);
    thread cons(consumer);
    
    prod.join();
    cons.join();
    
    return 0;
}

설명: 생산자와 소비자가 큐를 통해 데이터를 주고받는 패턴입니다.

이 코드에서 핵심은 cv.wait(lock, [] { return !dataQueue.empty() || done; })처럼 조건을 함께 넘기는 형태로 wait를 호출한다는 점입니다. 조건 변수는 “허위 기상(spurious wakeup)“이라고 해서, notify가 없었는데도 대기 중인 스레드가 깨어날 수 있습니다. 조건 없이 cv.wait(lock)만 호출하면 이런 허위 기상 때마다 큐가 비어 있는데도 그냥 진행해 버리는 버그가 생기므로, 반드시 술어(predicate)를 넘겨 깨어난 뒤에도 조건을 다시 검사하도록 해야 합니다. 아래 다이어그램은 생산자와 소비자, 뮤텍스, 조건 변수 사이의 신호 흐름을 정리한 것입니다.

sequenceDiagram
    participant P as 생산자 스레드
    participant M as mutex + queue
    participant C as 소비자 스레드
    P->>M: lock 획득 후 데이터 push
    P->>C: cv.notify_one() 호출
    Note over C: wait 중이던 스레드가\n조건을 재검사 후 깨어남
    C->>M: lock 재획득, front/pop
    C->>M: unlock 후 처리 시작
    P->>M: 마지막 반복 후 done = true
    P->>C: cv.notify_all() 호출
    Note over C: done && 큐 비어있음 확인 시\n루프 종료

예시 3: 스레드 풀

#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <functional>
#include <mutex>
#include <condition_variable>
using namespace std;

class ThreadPool {
private:
    vector<thread> workers;
    queue<function<void()>> tasks;
    mutex mtx;
    condition_variable cv;
    bool stop;
    
public:
    ThreadPool(size_t numThreads) : stop(false) {
        for (size_t i = 0; i < numThreads; i++) {
            workers.emplace_back([this]() {
                while (true) {
                    function<void()> task;
                    
                    {
                        unique_lock<mutex> lock(mtx);
                        cv.wait(lock, [this]() {
                            return stop || !tasks.empty();
                        });
                        
                        if (stop && tasks.empty()) {
                            return;
                        }
                        
                        task = move(tasks.front());
                        tasks.pop();
                    }
                    
                    task();
                }
            });
        }
    }
    
    ~ThreadPool() {
        {
            unique_lock<mutex> lock(mtx);
            stop = true;
        }
        
        cv.notify_all();
        
        for (auto& worker : workers) {
            worker.join();
        }
    }
    
    void enqueue(function<void()> task) {
        {
            unique_lock<mutex> lock(mtx);
            tasks.push(task);
        }
        cv.notify_one();
    }
};

int main() {
    ThreadPool pool(4);
    
    for (int i = 1; i <= 10; i++) {
        pool.enqueue([i]() {
            cout << "작업 " << i << " 시작 (스레드 " 
                 << this_thread::get_id() << ")" << endl;
            this_thread::sleep_for(chrono::seconds(1));
            cout << "작업 " << i << " 완료" << endl;
        });
    }
    
    this_thread::sleep_for(chrono::seconds(5));
    
    return 0;
}

설명: 스레드 풀로 작업을 효율적으로 분배합니다.

스레드 풀이 필요한 이유는 단순합니다. std::thread를 새로 만드는 것 자체가 커널 자원 할당이 필요한 상대적으로 비싼 연산이기 때문에, 짧은 작업을 수백~수천 번 반복해야 한다면 매번 스레드를 새로 만들고 join하는 대신 미리 만들어 둔 워커 스레드들에게 작업만 넘기는 편이 훨씬 효율적입니다. 위 구현에서 enqueue는 작업(클로저)을 큐에 넣고 notify_one()으로 대기 중인 워커 하나를 깨우기만 하며, 실제 실행은 생성자에서 미리 띄워 둔 numThreads개의 워커 람다가 담당합니다. 소멸자에서 stop = true로 표시한 뒤 notify_all()로 모든 워커를 깨우고 join()하는 순서도 눈여겨볼 부분입니다. stop 플래그를 뮤텍스 없이 설정하면 워커 스레드가 그 값을 영원히 못 볼 수도 있기 때문에, 반드시 잠금을 잡은 상태에서 값을 바꾸고 해제한 뒤 notify해야 합니다.

자주 발생하는 문제

문제 1: 경쟁 조건 (Race Condition)

증상: 결과가 매번 다름

원인: 동기화 없이 공유 자원 접근

해결법:

// ❌ 경쟁 조건
int counter = 0;

void increment() {
    for (int i = 0; i < 1000; i++) {
        counter++;  // 위험!
    }
}

// ✅ mutex로 보호
mutex mtx;
int counter = 0;

void increment() {
    for (int i = 0; i < 1000; i++) {
        lock_guard<mutex> lock(mtx);
        counter++;
    }
}

문제 2: 데드락 (Deadlock)

증상: 프로그램이 멈춤

원인: 서로 다른 mutex를 기다림

데드락이 실제로 발생하는 전형적인 패턴은 아래 코드처럼 두 스레드가 같은 두 개의 뮤텍스를 서로 다른 순서로 잠그는 경우입니다. 스레드 A가 mtx1을 먼저 잠근 뒤 mtx2를 기다리고, 동시에 스레드 B가 mtx2를 먼저 잠근 뒤 mtx1을 기다리면, 두 스레드 모두 상대방이 쥐고 있는 자원을 영원히 기다리는 순환 대기(circular wait) 상태에 빠집니다. 어느 쪽도 먼저 양보하지 않으므로 프로그램은 크래시 없이 그냥 멈춥니다.

이 패턴이 무서운 이유는 타이밍에 의존한다는 점입니다. func1과 func2가 정확히 겹쳐서 실행되는 순간에만 데드락이 재현되기 때문에, 코드 리뷰에서는 발견되지 않고 로컬 테스트에서도 대부분 통과합니다. 실제로 저는 두 개의 뮤텍스를 서로 다른 순서로 잠그는 코드가 코드 리뷰를 무사히 통과한 뒤, 운영 환경에서 트래픽이 몰리는 시간대에만 간헐적으로 서비스가 응답을 멈추는 문제를 겪은 적이 있습니다. 로컬에서는 두 함수가 거의 동시에 실행될 확률이 낮아 재현되지 않다가, 부하가 커져 두 스레드의 타이밍이 좁은 창(window)으로 겹치는 빈도가 늘어나면서 운영 환경에서만 증상이 드러났습니다. 스레드 덤프를 떠서 두 스레드가 각각 어떤 뮤텍스를 쥔 채 어떤 뮤텍스를 기다리고 있는지 확인하고 나서야 원인을 특정할 수 있었습니다.

해결법은 세 가지입니다. 첫째, 프로젝트 전체에서 여러 뮤텍스를 잠그는 순서를 하나로 고정하는 것(예: 항상 포인터 주소가 작은 뮤텍스부터 잠근다)입니다. 둘째, 잠금 순서를 신경 쓸 필요가 없도록 처음부터 여러 뮤텍스를 하나로 합치는 것입니다. 셋째, 그리고 대부분의 경우 가장 실용적인 방법은 C++17의 std::scoped_lock을 쓰는 것입니다. scoped_lock(mtx1, mtx2)는 데드락 회피 알고리즘(내부적으로 std::lock과 동일)을 사용해 여러 뮤텍스를 원자적으로 한 번에 잠그므로, 호출하는 쪽에서 순서를 신경 쓸 필요 자체가 없어집니다.

// ❌ 데드락 가능
mutex mtx1, mtx2;

void func1() {
    lock_guard<mutex> lock1(mtx1);
    lock_guard<mutex> lock2(mtx2);
}

void func2() {
    lock_guard<mutex> lock2(mtx2);  // 순서 다름!
    lock_guard<mutex> lock1(mtx1);
}

// ✅ 항상 같은 순서로 lock
void func1() {
    lock_guard<mutex> lock1(mtx1);
    lock_guard<mutex> lock2(mtx2);
}

void func2() {
    lock_guard<mutex> lock1(mtx1);  // 같은 순서
    lock_guard<mutex> lock2(mtx2);
}

// ✅ scoped_lock 사용 (C++17)
void func() {
    scoped_lock lock(mtx1, mtx2);  // 자동으로 데드락 방지
}

문제 3: detach 후 댕글링 참조

증상: 크래시 또는 이상한 값

원인: 스레드가 참조하는 변수가 소멸됨

해결법:

// ❌ 위험한 코드
void badExample() {
    int data = 10;
    thread t([&data]() {
        this_thread::sleep_for(chrono::seconds(1));
        cout << data << endl;  // 이미 소멸!
    });
    t.detach();
}  // data 소멸

// ✅ 값으로 캡처
void goodExample() {
    int data = 10;
    thread t([data]() {
        this_thread::sleep_for(chrono::seconds(1));
        cout << data << endl;  // 안전
    });
    t.detach();
}

// ✅ join으로 대기
void betterExample() {
    int data = 10;
    thread t([&data]() {
        cout << data << endl;
    });
    t.join();  // 대기
}

문제 4: false sharing으로 인한 성능 저하

증상: 뮤텍스나 명시적인 락 경합이 전혀 없는데도, 스레드 수를 늘려도 성능이 거의 오르지 않거나 오히려 떨어짐

원인: 서로 다른 스레드가 각자 다른 변수를 쓰고 있지만, 그 변수들이 우연히 같은 캐시 라인(보통 64바이트) 안에 배치되어 있는 경우입니다. 예를 들어 스레드별 카운터를 long long counters[NUM_THREADS]; 같은 배열로 선언하면, 인접한 원소들이 같은 캐시 라인에 들어갈 가능성이 높습니다. 한 스레드가 자신의 원소에 쓰기를 하면 CPU 캐시 일관성 프로토콜(MESI 등)이 그 캐시 라인 전체를 다른 코어의 캐시에서 무효화시키는데, 문제는 이 무효화가 논리적으로는 전혀 관련 없는 다른 스레드의 원소까지 함께 무효화한다는 점입니다. 결과적으로 각 스레드가 자기 카운터를 증가시킬 때마다 다른 코어의 캐시 라인을 다시 읽어와야 하는 상황이 반복되면서, 실제 데이터 경합은 없는데도 마치 락 경합이 있는 것처럼 느려집니다.

// ❌ false sharing 위험 — 인접 원소가 같은 캐시 라인에 위치
struct Counters {
    long long values[8];  // 스레드 8개가 각자 values[i]만 증가
};

// ✅ 패딩으로 각 카운터를 별도 캐시 라인에 배치
struct alignas(64) PaddedCounter {
    long long value;
    char padding[64 - sizeof(long long)];
};
PaddedCounter counters[8];

저는 예전에 스레드별로 값을 따로 누적한 뒤 마지막에 합산하는 방식으로 카운터 집계 로직을 병렬화한 적이 있는데, 코어 수를 4개에서 8개로 늘렸는데도 처리량이 거의 그대로였습니다. 뮤텍스도 없고 원자적 연산도 아니었기 때문에 처음에는 원인을 짐작하기 어려웠는데, perf로 프로파일링해 보니 캐시 미스율이 비정상적으로 높게 나왔고, 그제야 스레드별 카운터 배열이 같은 캐시 라인 안에 몰려 있다는 것을 확인했습니다. 각 카운터에 alignas(64) 패딩을 넣어 캐시 라인을 분리하자 코어 수에 거의 비례해서 처리량이 늘어났습니다. 이 경험 이후로는 스레드마다 자주 갱신되는 데이터를 배열로 묶을 때는 습관적으로 캐시 라인 정렬을 의심해 보게 되었습니다.

FAQ

Q1: join vs detach?

A:

  • join: 스레드 종료 대기 (권장)
  • detach: 백그라운드 실행 (주의 필요)

Q2: 몇 개의 스레드를 만들어야 하나요?

A: 작업 성격에 따라 다릅니다. CPU 바운드 작업(순수 계산)은 hardware_concurrency()가 반환하는 논리 코어 수 근처가 적절하고, 그 이상으로 늘리면 컨텍스트 스위칭 비용만 늘어납니다. I/O 바운드 작업(네트워크·디스크 대기)은 스레드 대부분이 실행 중이 아니라 대기 중이므로 코어 수보다 훨씬 많은 스레드를 띄워도 무방합니다.

unsigned int numThreads = thread::hardware_concurrency();

Q3: mutex는 느린가요?

A: 약간의 오버헤드가 있지만 필요한 경우 반드시 사용해야 합니다. atomic을 고려할 수도 있습니다.

Q4: 스레드 안전한 컨테이너는?

A: C++ 표준 컨테이너는 기본적으로 스레드 안전하지 않습니다. mutex로 보호하거나 concurrent 라이브러리를 사용하세요.

Q5: async vs thread?

A:

  • thread: 저수준 제어
  • async: 고수준, 간편 (future 반환)
auto future = async(launch::async, []() {
    return 42;
});
cout << future.get() << endl;

Q6: 멀티스레딩은 언제 사용하나요?

A:

  • CPU 집약적 작업 병렬화
  • I/O 대기 시간 활용
  • 반응성 향상 (UI)

마무리

std::thread와 std::mutex는 C++에서 동시성을 다루는 가장 기본적인 도구이지만, 이번 글에서 살펴본 것처럼 미조인 스레드 소멸 시의 std::terminate, 잠금 순서에 따른 데드락, false sharing처럼 컴파일러도 정적 분석 도구도 잡아주지 못하는 함정이 곳곳에 숨어 있습니다. 이런 함정들은 대부분 “왜 표준이 이렇게 설계됐는가”를 이해하면 예방할 수 있는 성격의 문제이므로, 단순히 API 사용법을 외우기보다는 각 도구가 해결하려는 문제와 트레이드오프를 함께 기억해 두는 것이 실전에서 훨씬 도움이 됩니다. 실무에서 더 안전한 코드를 원한다면 원시 std::thread 대신 소멸 시 자동으로 join하는 std::jthread, 수동 lock()/unlock() 대신 lock_guard/scoped_lock, 그리고 가능하다면 뮤텍스보다 가벼운 std::atomic을 우선 검토해 보시기 바랍니다.

같이 보면 좋은 글