C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법

C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법

이 글의 핵심

std::thread 생성·조인·디태치, mutex·condition_variable·atomic·jthread 기초, 프로세스 vs 스레드 차이, join 누락·디태치 남용 등 자주 하는 실수와 해결법, 프로덕션 패턴을 실전 예제로 정리합니다.

들어가며: 단일 스레드로는 늦었던 작업

”왜 이렇게 오래 걸리지?” - I/O 대기와 CPU 한 코어만 쓰는 문제

이미지 변환 서비스에서 사용자가 업로드한 파일 여러 개를 순차적으로 리사이즈하고 있었습니다. 파일이 10개만 되어도 10초 이상 걸렸습니다.

당시 코드에서는 for 루프로 파일을 한 개씩 처리합니다. loadImagesaveImage에서 디스크 I/O가 일어나는 동안 CPU는 대기하고, resize에서만 CPU를 쓰기 때문에 한 스레드로는 코어 하나만 사용하게 됩니다. 파일이 여러 개일 때는 각 파일을 별도 스레드(thread—프로세스 안에서 독립적으로 실행되는 실행 흐름. 비유하면 한 팀(프로세스) 안에서 여러 사람이 동시에 일하는 것)에 넘기면 I/O 대기와 CPU 작업을 겹쳐서 처리할 수 있어, 전체 시간이 줄어듭니다. 실제로 스레드를 나눈 뒤 10개 파일 기준으로 체감 시간이 절반 이하로 줄었습니다.

void processAll(const std::vector<std::string>& files) {
    for (const auto& path : files) {
        Image img = loadImage(path);   // 디스크 I/O 대기
        img.resize(800, 600);          // CPU 작업
        saveImage(img, outputPath);    // 디스크 I/O 대기
    }
}

위 코드 설명: for 루프가 한 스레드에서만 돌기 때문에 loadImage·saveImage 같은 I/O 대기 동안 CPU는 놀고, resize도 한 코어만 사용합니다. 파일이 많을수록 순차 처리 시간이 길어지므로, 파일 단위로 스레드를 나누면 I/O와 CPU 작업을 겹쳐서 처리할 수 있습니다.

원인:

  • 모든 작업이 메인 스레드 하나에서만 실행됨
  • 디스크 I/O 대기 동안 CPU는 놀고 있음
  • 멀티코어인데 한 코어만 사용

std::thread로 나눈 뒤:

  • 파일별로 스레드를 나누어 I/O와 CPU를 겹쳐서 처리
  • 10개 파일이면 체감 시간이 절반 이하로 줄었습니다

이 경험으로 멀티스레딩 기초의 필요성을 느꼈습니다. 스레드를 쓰면 “동시에 여러 일을 하는” 것처럼 보이지만, 같은 메모리를 여러 스레드가 건드리면 data race로 버그가 납니다. 그래서 먼저 스레드 생성·join(스레드가 끝날 때까지 기다리기)·detach(스레드를 백그라운드로 떼어 놓기)를 익힌 뒤, 다음 글부터 mutex·condition_variable·atomic으로 공유 데이터를 어떻게 보호할지 다룹니다.

같은 “OS 스레드” 관점에서는 Java의 Thread·Executor와 문제의식이 비슷하고, Go 고루틴처럼 경량 스케줄링을 쓰는 모델과는 트레이드오프가 다릅니다. Rust의 std::thread·채널은 소유권 규칙과 함께 읽으면 비교가 잘 됩니다.

이 글을 읽으면:

  • std::thread로 스레드를 생성하고 조인·디태치하는 방법을 알 수 있습니다.
  • 스레드가 무엇인지, 왜 쓰는지 이해할 수 있습니다.
  • 스레드 안전(thread safety)이 무엇인지 감을 잡을 수 있습니다.
  • 자주 하는 실수(조인 누락, 디태치 남용)를 피할 수 있습니다.

추가 문제 시나리오

시나리오 1: UI가 멈추는 현상
데스크톱 앱에서 “저장” 버튼을 누르면 대용량 파일을 압축해 디스크에 쓰는 동안 UI가 몇 초간 멈췄습니다. 사용자는 프로그램이 멈춘 것으로 오해해 여러 번 클릭하는 문제가 발생했습니다. 해결: 파일 압축·저장을 별도 스레드로 분리하고, 메인 스레드는 UI 이벤트만 처리하도록 했습니다.

시나리오 2: 로그 쓰기 지연
여러 워커 스레드가 동시에 로그를 파일에 쓰면, 한 스레드가 fprintf를 호출하는 동안 다른 스레드도 같은 파일 포인터를 건드려 로그가 섞이거나 크래시가 났습니다. 해결: 로그 전용 스레드를 하나 두고, 다른 스레드는 큐에 메시지만 넣고, 로그 스레드만 파일에 쓰도록 분리했습니다.

시나리오 3: 네트워크 요청 대기
REST API를 호출할 때 응답을 기다리는 동안 메인 로직이 블로킹되어, 여러 API를 순차 호출하면 전체 시간이 합산되었습니다. 해결: std::thread로 요청을 병렬로 보내고 join()으로 모두 완료될 때까지 기다리면, 대기 시간이 최대값 수준으로 줄어듭니다.

시나리오 4: 카운터 값이 맞지 않음
이벤트 당일 여러 워커가 주문 수를 하나의 int 변수에 더했는데, 집계 결과가 실제 DB 건수보다 수만 건 적게 나왔습니다. 원인: counter++가 원자적이지 않아 data race가 발생. 해결: mutex로 보호하거나 단일 변수면 atomic 사용.

시나리오 5: 데드락으로 서버 멈춤
두 스레드가 각각 mutex Amutex B를 서로 다른 순서로 잡으면서, 서로가 가진 락을 기다리며 영원히 멈췄습니다. 재시작 전까지 요청이 처리되지 않았습니다. 원인: 뮤텍스 잠금 순서가 스레드마다 달랐음. 해결: 모든 스레드에서 동일한 순서(예: A → B)로 잠그거나 std::scoped_lock(mtxA, mtxB)로 한 번에 잠급니다.

시나리오 6: 작업 큐 폴링으로 CPU 100%
워커 스레드가 큐에 작업이 있는지 1ms마다 확인하는 while 루프를 돌았습니다. 작업이 없을 때도 CPU를 계속 사용해 서버 부하가 올랐습니다. 해결: condition_variable으로 “작업이 들어올 때만 깨우기” 패턴을 적용해 유휴 시 CPU 사용을 0%에 가깝게 줄였습니다.

목차

  1. 스레드란 무엇인가
  2. std::thread로 스레드 생성하기
  3. join과 detach: 스레드 수명 관리
  4. RAII로 스레드 안전하게 관리하기
  5. 스레드 안전(Thread Safety)이란
  6. mutex 기초: 공유 데이터 보호
  7. condition_variable 기초
  8. atomic 기초: 락 없이 카운터 보호
  9. std::jthread와 stop_token
  10. 자주 겪는 실수와 주의점
  11. 성능 비교: 단일 vs 멀티스레드
  12. 모범 사례와 프로덕션 패턴
  13. 구현 체크리스트

1. 스레드란 무엇인가

프로세스와 스레드

프로세스는 실행 중인 프로그램 하나입니다. 메모리 공간, 파일 핸들, 환경이 프로세스마다 따로 있습니다. 비유하면 프로세스는 “실행 중인 프로그램 한 권”이고, 그 안의 “동시에 읽을 수 있는 책갈피(실행 지점) 여러 개”가 스레드입니다.

스레드는 그 프로세스 안에서 돌아가는 실행 흐름 단위입니다. 한 프로세스는 기본적으로 메인 스레드 하나로 시작하고, 필요하면 스레드를 더 만들어 여러 일을 동시에 할 수 있습니다.

프로세스와 스레드, 그리고 std::thread로 스레드를 늘리는 관계를 단순화하면 아래와 같습니다.

flowchart TB
  subgraph proc["프로세스 (메모리 공간 1개)"]
    main[메인 스레드]
    t1[스레드 1]
    t2[스레드 2]
    main --- t1
    main --- t2
  end
  main -->|"std::thread t(fn);"| t1
  main -->|"std::thread t2(fn2);"| t2
graph TB
    PROCESS["프로세스 myapp"]

    MEMORY["메모리 공간br/━━━━━━━━━━━━━━━br/코드, 데이터, 힙br/모든 스레드가 공유 ⚠️"]

    T1["스레드 1 메인br/━━━━━━━━━━━━br/main, UI 처리"]
    T2["스레드 2br/━━━━━━━━━━━━br/네트워크 수신"]
    T3["스레드 3br/━━━━━━━━━━━━br/이미지 처리"]
    T4["스레드 4br/━━━━━━━━━━━━br/로그 쓰기"]

    PROCESS --> MEMORY
    PROCESS --> T1
    PROCESS --> T2
    PROCESS --> T3
    PROCESS --> T4

    T1 -.->|공유 접근| MEMORY
    T2 -.->|공유 접근| MEMORY
    T3 -.->|공유 접근| MEMORY
    T4 -.->|공유 접근| MEMORY

    style PROCESS fill:#e1f5fe,stroke:#0277bd,stroke-width:3px
    style MEMORY fill:#fff3e0,stroke:#f57c00,stroke-width:3px
    style T1 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style T2 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style T3 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style T4 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px

같은 프로세스 안의 스레드들은 힙·전역 변수·정적 변수를 공유합니다. 반면 지역 변수·스택은 스레드마다 따로 있습니다. 그래서 여러 스레드가 같은 전역/힙 변수를 동시에 읽고 쓰면 data race가 발생하고, 결과가 예측 불가능해지거나 크래시가 날 수 있습니다. 공유 데이터가 있으면 mutexatomic으로 보호해야 합니다.

join과 detach를 반드시 정해야 하는 이유: std::thread 객체가 파괴될 때 그 스레드가 아직 실행 중이면, C++ 표준에 따라 std::terminate()가 호출되어 프로그램이 강제 종료됩니다. 그래서 스레드를 만든 뒤에는 반드시 join()(스레드가 끝날 때까지 기다림) 또는 detach()(스레드를 백그라운드로 떼어냄) 중 하나를 호출해야 합니다. “메인은 끝나도 워커는 계속 돌게 하고 싶다”면 detach를 쓰고, “모든 워커가 끝난 뒤에 메인이 정리하고 끝나겠다”면 join을 쓰면 됩니다.

멀티스레딩을 쓰는 이유

  • 성능: I/O 대기 시 다른 스레드가 CPU를 쓰게 해서 전체 처리량을 높임
  • 반응성: UI 스레드는 유지하고 무거운 작업만 별도 스레드에서 실행
  • 멀티코어 활용: 여러 코어에 작업을 나누어 병렬로 실행

2. std::thread로 스레드 생성하기

C++11부터 <thread>std::thread가 포함되었습니다. 함수나 람다, 함수 객체를 넘겨 스레드를 띄울 수 있습니다.

함수를 넘기는 경우

std::thread 생성자에 호출할 함수(또는 함수 객체)를 넘기면, 그 함수가 새 스레드에서 실행됩니다. std::thread t(sayHello);에서 sayHello함수 포인터이므로 괄호를 붙이지 않습니다. 괄호를 붙이면 sayHello()를 즉시 호출한 결과(void)를 넘기는 것이 되어, 컴파일은 되더라도 의도와 다르게 동작합니다. t.join()은 해당 스레드가 끝날 때까지 메인 스레드가 기다리게 하며, join을 하지 않으면 스레드 객체 파괴 시 std::terminate()가 호출되므로 반드시 join() 또는 detach() 중 하나를 호출해야 합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o thread_hello thread_hello.cpp && ./thread_hello
#include <thread>
#include <iostream>

void sayHello() {
    std::cout << "Hello from thread!\n";
}

int main() {
    std::thread t(sayHello);   // 스레드 생성 및 실행
    t.join();                  // 스레드가 끝날 때까지 대기
    return 0;
}

위 코드 설명: std::thread t(sayHello)sayHello를 새 스레드에서 실행하고, t.join()은 그 스레드가 끝날 때까지 메인 스레드를 블로킹합니다. 괄호 없이 sayHello만 넘기는 이유는 함수 포인터를 넘기는 것이지, sayHello() 호출 결과를 넘기는 것이 아니기 때문입니다. join을 하지 않으면 스레드 객체 소멸 시 std::terminate()가 호출됩니다.

실행 결과: Hello from thread! 가 한 줄 출력됩니다.

람다를 넘기는 경우

#include <thread>
#include <iostream>

int main() {
    std::thread t( {
        std::cout << "Hello from lambda thread!\n";
    });
    t.join();
    return 0;
}

위 코드 설명: std::thread 생성자에 람다를 넘기면 그 람다가 새 스레드에서 실행됩니다. 함수 대신 인라인으로 동작을 넣을 수 있어서 간단한 작업에 자주 씁니다. 역시 반드시 join() 또는 detach()를 호출해야 합니다.

실행 결과: Hello from lambda thread! 가 출력됩니다.

인자를 넘기는 경우

#include <thread>
#include <iostream>

void printSum(int a, int b) {
    std::cout << "Sum: " << (a + b) << "\n";
}

int main() {
    std::thread t(printSum, 10, 20);  // 인자 10, 20 전달
    t.join();
    return 0;
}

위 코드 설명: std::thread 생성자에서 호출할 함수 다음에 인자를 나열하면, 그 인자들이 값으로 복사되어 새 스레드에 전달됩니다. 참조로 넘기려면 std::ref(변수)를 써야 하고, 그때는 변수 수명이 스레드보다 길어지도록 주의해야 합니다.

인자는 스레드 객체를 만들 때 함수 다음 인자로 넘깁니다. 값으로 복사되므로, 참조로 넘기려면 std::ref()를 사용해야 합니다.

실행 결과: Sum: 30 이 출력됩니다.

std::ref로 참조 전달하기

참조로 넘겨야 할 때는 std::ref() 또는 std::cref()를 사용합니다. 주의: 참조로 넘긴 변수의 수명이 스레드보다 길어야 합니다. 그렇지 않으면 use-after-free가 발생합니다.

#include <thread>
#include <iostream>
#include <functional>

void increment(int& value) {
    ++value;
}

int main() {
    int counter = 0;
    std::thread t(increment, std::ref(counter));  // 참조 전달
    t.join();
    std::cout << "counter: " << counter << "\n";  // 1
    return 0;
}

위 코드 설명: std::ref(counter)counter를 참조로 넘기면, 스레드 내부에서 counter를 직접 수정할 수 있습니다. std::ref 없이 counter만 넘기면 복사본이 전달되어 원본은 변경되지 않습니다. std::cref()는 const 참조를 넘길 때 사용합니다.

실행 결과: counter: 1 이 출력됩니다.


3. join과 detach: 스레드 수명 관리

스레드 객체가 소멸될 때 그 스레드가 아직 실행 중이면 std::terminate()가 호출되어 프로그램이 종료됩니다. 따라서 스레드를 반드시 join 또는 detach 해야 합니다.

join과 detach의 실행 흐름

sequenceDiagram
    participant M as 메인 스레드
    participant T as 워커 스레드

    Note over M,T: join() 사용 시
    M->>T: std::thread t(doWork);
    M->>M: t.join() 호출
    Note over M: 블로킹 (대기)
    T->>T: doWork() 실행
    T->>M: 종료
    M->>M: join() 반환, 다음 코드 실행

    Note over M,T: detach() 사용 시
    M->>T: std::thread t(doWork);
    M->>M: t.detach() 호출
    M->>M: 즉시 다음 코드 실행
    T->>T: 백그라운드에서 독립 실행

join(): 스레드가 끝날 때까지 대기

std::thread t(doWork);
t.join();  // doWork()가 끝날 때까지 여기서 대기
// 여기서부터는 t가 끝난 뒤

위 코드 설명: join()을 호출한 스레드는 그 시점에서 해당 스레드가 종료할 때까지 기다립니다. join이 반환된 뒤에는 그 스레드는 이미 끝난 상태이므로, 스레드가 사용하던 지역 데이터도 정리된 뒤입니다. 한 번 join한 스레드에는 다시 join할 수 없습니다.

  • join()은 해당 스레드가 끝날 때까지 블로킹합니다.
  • 스레드의 반환값은 직접 받을 수 없습니다. (나중에 std::async·std::promise로 받을 수 있음)
  • 한 번 join한 스레드에는 다시 join할 수 없습니다.

detach(): 스레드를 백그라운드로 놓기

std::thread t(doWork);
t.detach();  // t와의 연결을 끊음. t는 백그라운드에서 계속 실행
// t가 언제 끝날지 모름. t가 접근하는 데이터는 수명을 잘 관리해야 함

위 코드 설명: detach()를 호출하면 std::thread 객체와 실제 스레드의 연결이 끊어져, 그 스레드는 백그라운드에서 독립적으로 돌아갑니다. 메인은 스레드 종료를 기다리지 않으므로, 스레드가 참조하는 변수·객체가 메인(또는 다른 스레드)보다 먼저 파괴되지 않도록 수명을 꼭 맞춰야 합니다.

  • detach() 후에는 그 스레드를 다시 join할 수 없습니다.
  • 스레드가 사용하는 데이터와 객체의 수명이 스레드보다 길어지도록 주의해야 합니다. 그렇지 않으면 use-after-free 등 undefined behavior가 납니다.

joinable()로 상태 확인하기

이미 join 또는 detach한 스레드에 다시 join을 시도하면 undefined behavior입니다. joinable()로 확인할 수 있습니다.

#include <thread>
#include <iostream>

void doWork() {
    std::cout << "Working...\n";
}

int main() {
    std::thread t(doWork);
    if (t.joinable()) {
        t.join();  // 안전하게 join
    }
    // t.join();  // ❌ 이미 join했으면 undefined behavior
    return 0;
}

위 코드 설명: joinable()true이면 아직 join/detach하지 않은 스레드입니다. join 또는 detach 후에는 joinable()false가 됩니다. 예외 안전성을 위해 RAII 패턴과 함께 사용하는 것이 좋습니다.

실전 권장

  • 기본은 join: 스레드가 끝난 뒤 다음 로직을 실행해야 하거나, 스레드가 접근하는 데이터가 현재 스코프에 있을 때는 join을 사용하는 것이 안전합니다.
  • detach는 신중히: 로그 스레드처럼 프로그램 전체 수명과 함께 살아가는 경우에만 쓰고, 그때도 공유 데이터 수명을 꼭 확인합니다.

4. RAII로 스레드 안전하게 관리하기

스코프를 벗어날 때 예외가 발생해도 join을 호출하지 않으면 std::terminate()가 호출됩니다. RAII로 스레드를 감싸서 소멸자에서 자동으로 join하는 패턴을 많이 씁니다.

ThreadGuard 패턴

#include <thread>
#include <iostream>
#include <stdexcept>

class ThreadGuard {
public:
    explicit ThreadGuard(std::thread& t) : t_(t) {}

    ~ThreadGuard() {
        if (t_.joinable()) {
            t_.join();  // 소멸 시 자동 join
        }
    }

    // 복사 방지
    ThreadGuard(const ThreadGuard&) = delete;
    ThreadGuard& operator=(const ThreadGuard&) = delete;

private:
    std::thread& t_;
};

void doWork() {
    std::cout << "Working...\n";
}

void mightThrow() {
    std::thread t(doWork);
    ThreadGuard guard(t);  // guard 소멸 시 t.join() 자동 호출
    // throw std::runtime_error("oops");  // 예외 발생해도 join 보장
}

위 코드 설명: ThreadGuardstd::thread 참조를 받아, 소멸자에서 joinable()이면 join()을 호출합니다. mightThrow()에서 예외가 발생해도 guard가 스코프를 벗어날 때 소멸자가 호출되어 t.join()이 실행되므로 std::terminate()를 피할 수 있습니다. C++20의 std::jthread가 이 패턴을 표준으로 제공합니다.

std::jthread (C++20)

C++20부터는 std::jthread(joining thread)를 사용하면 자동으로 join됩니다.

#include <thread>
#include <iostream>

void doWork() {
    std::cout << "Working...\n";
}

int main() {
    std::jthread t(doWork);  // 소멸 시 자동 join
    // t.join() 호출 불필요
    return 0;
}

위 코드 설명: std::jthread는 소멸 시 자동으로 join()을 호출합니다. std::thread를 쓸 때 join 누락 실수를 줄이려면 std::jthread 사용을 고려하세요. C++20 이상에서 사용 가능합니다.


5. 스레드 안전(Thread Safety)이란

여러 스레드가 같은 데이터를 동시에 읽고 쓸 때, 결과가 항상 올바르게 나오고 메모리 오류가 나지 않으면 그 코드는 스레드 안전하다고 말합니다.

스레드에 안전하지 않은 예

#include <thread>
#include <iostream>

int counter = 0;  // 전역 변수

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 읽기-수정-쓰기. 여러 스레드가 동시에 하면 data race
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << counter << "\n";  // 200000이 아닐 수 있음 (data race)
    return 0;
}

counter++는 실제로는 “읽기 → 더하기 → 쓰기” 세 단계입니다. 두 스레드가 동시에 읽고 쓰면 일부 증가가 빠져서 200000보다 작은 값이 나올 수 있습니다. 이를 data race라고 합니다.

위 코드 설명: 두 스레드가 동시에 counter++를 실행하면, 읽기·증가·쓰기가 원자적이지 않아 한 스레드의 쓰기가 다른 스레드의 읽기 전 결과를 덮어쓸 수 있습니다. 그 결과 200000보다 작은 값이 나오는 data race가 발생합니다. 공유 변수를 이렇게 수정할 때는 mutex나 atomic으로 보호해야 합니다.

이런 공유 데이터 접근은 다음 글에서 다룰 mutexatomic으로 보호해야 합니다.

data race 시각화

sequenceDiagram
    participant T1 as 스레드 1
    participant T2 as 스레드 2
    participant C as counter (메모리)

    Note over C: 초기값: 0
    T1->>C: 읽기 (0)
    T2->>C: 읽기 (0)
    T1->>T1: 0+1=1
    T2->>T2: 0+1=1
    T1->>C: 쓰기 (1)
    T2->>C: 쓰기 (1)
    Note over C: 기대값 2, 실제 1 (한 번 손실)

스레드에 안전한 경우

  • 읽기만 하는 공유 데이터 (변경이 없으면 보통 안전)
  • 스레드마다 완전히 다른 변수만 사용하는 경우
  • 표준에서 스레드 안전이라고 명시한 API만 공유해서 쓰는 경우

6. mutex 기초: 공유 데이터 보호

여러 스레드가 같은 변수를 수정할 때는 한 번에 한 스레드만 접근하도록 mutex(뮤텍스—상호 배제)로 보호해야 합니다. std::mutex는 “락을 잡은 스레드만 크리티컬 섹션에 들어갈 수 있고, 나머지는 대기”하게 합니다. 자세한 내용은 다음 글에서 다루고, 여기서는 기본 사용법만 정리합니다.

std::mutex + lock_guard 완전 예제

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o mutex_basic mutex_basic.cpp && ./mutex_basic
#include <thread>
#include <iostream>
#include <mutex>

int counter = 0;
std::mutex counter_mutex;

void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counter_mutex);  // 락 획득
        ++counter;  // 크리티컬 섹션: 한 번에 한 스레드만 실행
    }  // lock 소멸 시 자동 unlock
}

int main() {
    std::thread t1(safeIncrement);
    std::thread t2(safeIncrement);
    t1.join();
    t2.join();
    std::cout << "counter: " << counter << "\n";  // 항상 200000
    return 0;
}

위 코드 설명:

  • std::mutex counter_mutex: 공유 변수마다 하나의 mutex를 둡니다.
  • std::lock_guard<std::mutex> lock(counter_mutex): 생성 시 lock()을 호출하고, 소멸 시 unlock()을 자동 호출합니다. 예외가 나도 락이 풀리므로 RAII로 안전합니다.
  • ++counter는 이제 한 번에 한 스레드만 실행되므로 data race가 사라집니다.

실행 결과: counter: 200000 이 항상 출력됩니다.

mutex 없이 vs mutex 사용 비교

// ❌ mutex 없음: data race
void unsafeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // 여러 스레드가 동시에 접근 → 결과 불확실
    }
}

// ✅ mutex 사용: 스레드 안전
void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counter_mutex);
        ++counter;
    }
}

lock_guard 사용 시 주의점

  • 락 범위를 최소화: 락을 잡은 채로 I/O나 무거운 연산을 하면 다른 스레드가 오래 대기합니다. 보호가 필요한 구간만 락으로 감싸세요.
  • 데드락: 두 mutex를 서로 다른 순서로 잡으면 데드락이 날 수 있습니다. mutex 글에서 순서 통일·std::lock() 패턴을 다룹니다.

7. condition_variable 기초

condition_variable(조건 변수)은 “특정 조건이 참이 될 때까지 스레드를 재우고, 조건이 바뀌면 깨우는” 동기화 객체입니다. mutex만으로는 “큐에 데이터가 들어올 때까지 잠들었다가, 들어오면 깨운다”를 표현하기 어렵습니다. 폴링 대신 이벤트 기반 대기가 가능해, 작업 큐·Producer-Consumer 패턴의 기초가 됩니다. 자세한 내용은 다음 글에서 다루고, 여기서는 기본 사용법만 정리합니다.

Producer-Consumer 완전 예제

// 복사해 붙여넣은 뒤: g++ -std=c++20 -pthread -o cv_demo cv_demo.cpp && ./cv_demo
#include <thread>
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>

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

void producer() {
    for (int i = 0; i < 5; ++i) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            queue.push(i);
        }
        cv.notify_one();  // 소비자에게 알림
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    {
        std::lock_guard<std::mutex> lock(mtx);
        done = true;
    }
    cv.notify_one();
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [] { return !queue.empty() || done; });  // 조건 만족 시 깨어남
        while (!queue.empty()) {
            int val = queue.front();
            queue.pop();
            lock.unlock();
            std::cout << "Consumed: " << val << "\n";
            lock.lock();
        }
        if (done) break;
    }
}

int main() {
    std::thread p(producer);
    std::thread c(consumer);
    p.join();
    c.join();
    return 0;
}

위 코드 설명: producer는 큐에 데이터를 넣은 뒤 cv.notify_one()으로 대기 중인 워커를 깨웁니다. consumercv.wait(lock, predicate)로 “큐가 비어있지 않거나 done이 true일 때까지” 대기합니다. wait는 predicate가 false일 때만 락을 풀고 잠들고, notify_one을 받으면 깨어나 predicate를 다시 검사합니다. spurious wakeup(가짜 깨움)에 대비해 wait의 두 번째 인자로 조건을 반드시 넣어야 합니다.

실행 결과: Consumed: 0 ~ Consumed: 4 가 순서대로 출력됩니다.


8. atomic 기초: 락 없이 카운터 보호

단일 변수(카운터·플래그)만 보호할 때는 mutex보다 std::atomic이 가볍습니다. atomic은 CPU에서 원자적으로 실행되어, 여러 스레드가 동시에 접근해도 data race가 발생하지 않습니다. 자세한 내용은 다음 글에서 다루고, 여기서는 기본 사용법만 정리합니다.

std::atomic 완전 예제

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o atomic_demo atomic_demo.cpp && ./atomic_demo
#include <thread>
#include <iostream>
#include <atomic>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 원자적 연산, data race 없음
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter: " << counter << "\n";  // 항상 200000
    return 0;
}

위 코드 설명: std::atomic<int>operator++는 CPU에서 원자적으로 실행됩니다. mutex 없이도 여러 스레드가 동시에 증가시켜도 data race가 발생하지 않고, 락 경합이 없어 고빈도 카운터에 더 적합합니다. 주의: 여러 변수를 한 번에 일관되게 바꿔야 할 때는 mutex가 필요합니다. atomic은 단일 변수에 대한 읽기·쓰기·증가·감소에만 사용하세요.

실행 결과: counter: 200000 이 항상 출력됩니다.

atomic vs mutex 선택 기준

상황권장이유
단일 변수(카운터·플래그)std::atomic락 없이 원자적 연산, 경합 적음
여러 변수·복잡한 조건std::mutex한 블록을 통째로 보호하는 편이 안전

9. std::jthread와 stop_token

C++20의 std::jthread는 소멸 시 자동으로 join()을 호출하고, stop_token으로 스레드에 종료 요청을 보낼 수 있습니다. std::thread를 쓸 때 join 누락·종료 신호 전달을 수동으로 처리해야 했던 부분을 표준화합니다.

std::jthread + stop_token 완전 예제

// 복사해 붙여넣은 뒤: g++ -std=c++20 -pthread -o jthread_demo jthread_demo.cpp && ./jthread_demo
#include <thread>
#include <iostream>
#include <chrono>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        std::cout << "Working...\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
    }
    std::cout << "Stopped.\n";
}

int main() {
    std::jthread t(worker);  // stop_token이 자동으로 전달됨
    std::this_thread::sleep_for(std::chrono::seconds(1));
    t.request_stop();  // 종료 요청
    // t 소멸 시 자동 join, request_stop 없어도 join 보장
    return 0;
}

위 코드 설명: std::jthread 생성자에 std::stop_token을 인자로 받는 함수를 넘기면, 그 함수에 stop_token이 자동으로 전달됩니다. st.stop_requested()로 종료 요청 여부를 확인하고, t.request_stop()으로 메인에서 종료를 요청할 수 있습니다. std::jthread가 소멸될 때 request_stop()join()이 자동으로 호출되므로, join 누락과 수동 종료 플래그 관리가 모두 해결됩니다.

실행 결과: Working... 이 여러 번 출력된 뒤 Stopped. 가 출력됩니다.


10. 자주 겪는 실수와 주의점

(1) join/detach 누락

void bad() {
    std::thread t(doWork);
}  // t 소멸 시 아직 실행 중이면 std::terminate() 호출됨

위 코드 설명: t가 스코프를 벗어날 때 소멸자가 호출되는데, 그때 아직 스레드가 실행 중이면 C++ 표준에 따라 std::terminate()가 호출되어 프로그램이 강제 종료됩니다. 스레드를 만든 뒤에는 반드시 join() 또는 detach() 중 하나를 호출해야 합니다.

해결법:

void good() {
    std::thread t(doWork);
    t.join();  // 또는 t.detach();
}

스레드 객체가 소멸되기 전에 반드시 join() 또는 detach()를 호출하세요. RAII로 스레드를 감싸서 소멸자에서 자동으로 join하는 패턴도 많이 씁니다.

(2) join한 뒤 다시 join 시도

std::thread t(doWork);
t.join();
t.join();  // undefined behavior

위 코드 설명: join()을 한 번 호출하면 그 스레드는 “이미 조인된” 상태가 되어, 다시 join()을 호출하면 C++ 표준상 정의되지 않은 동작(undefined behavior)이 됩니다. join 가능 여부는 t.joinable()로 확인할 수 있습니다.

해결법:

std::thread t(doWork);
t.join();
if (t.joinable()) {  // false이므로 실행 안 됨
    t.join();
}

한 스레드에는 한 번만 join(또는 detach) 할 수 있습니다.

(3) 참조로 캡처한 지역 변수

void bad() {
    int value = 42;
    std::thread t([&value]() {
        std::cout << value;  // value는 bad() 반환 후 사라짐. 위험
    });
    t.detach();  // bad()가 끝나도 스레드는 계속 돌 수 있음
}

위 코드 설명: [&value]로 지역 변수 value를 참조로 캡처했고, detach()로 스레드를 떼어냈기 때문에 bad()가 return한 뒤에도 스레드는 계속 돌 수 있습니다. 그때 value는 이미 스택에서 사라진 주소를 가리키는 use-after-free가 되어 정의되지 않은 동작이 됩니다. 값으로 캡처([value])하거나, join으로 스레드가 끝난 뒤에 함수가 끝나게 해야 합니다.

해결법:

void good() {
    int value = 42;
    std::thread t([value]() {  // 값으로 캡처
        std::cout << value;    // 안전
    });
    t.join();  // 또는 detach 전에 value 수명 보장
}

detach한 스레드가 지역 변수 참조를 쓰면, 그 함수가 반환한 뒤에는 use-after-free가 됩니다. 값으로 복사하거나, 스레드 수명과 데이터 수명을 같이 맞추세요.

(4) 스레드가 공유 데이터를 보호 없이 수정

여러 스레드가 같은 변수를 수정하면 data race가 납니다. 다음 글에서 mutex로 한 번에 한 스레드만 수정하도록 보호하는 방법을 다룹니다.

(5) 함수 포인터에 괄호 붙이기

std::thread t(sayHello());  // ❌ sayHello() 반환값(void)을 넘김. 컴파일 에러 또는 잘못된 동작
std::thread t(sayHello);   // ✅ 함수 포인터 전달

위 코드 설명: sayHello()는 함수를 즉시 호출한 결과를 넘기는 것이므로, std::thread가 기대하는 “호출 가능한 객체”가 아닙니다. 괄호 없이 sayHello만 넘겨야 합니다.

자주 발생하는 문제와 해결법

문제 1: “terminate called without an active exception”

원인: std::thread 객체가 소멸될 때 아직 join() 또는 detach()를 호출하지 않은 상태입니다.

해결법:

// ❌ 잘못된 코드
void bad() {
    std::thread t(doWork);
}  // terminate 호출

// ✅ 올바른 코드
void good() {
    std::thread t(doWork);
    t.join();  // 또는 t.detach()
}

문제 2: “AddressSanitizer: heap-use-after-free”

원인: detach()한 스레드가 이미 파괴된 지역 변수를 참조하고 있습니다.

해결법: 값으로 캡처하거나, join()으로 스레드가 끝난 뒤에 스코프를 벗어나도록 합니다.

// ❌ 잘못된 코드
void bad() {
    std::string msg = "hello";
    std::thread t([&msg]() { std::cout << msg; });
    t.detach();
}  // msg 파괴 후 스레드가 접근

// ✅ 올바른 코드
void good() {
    std::string msg = "hello";
    std::thread t([msg]() { std::cout << msg; });  // 값 복사
    t.detach();
}

문제 3: counter 값이 예상보다 작게 나옴

원인: 여러 스레드가 공유 변수를 보호 없이 수정하는 data race입니다.

해결법: mutex 또는 atomic으로 보호합니다.

문제 4: “double free” 또는 “corrupted size vs. prev_size”

원인: 한 스레드가 delete한 메모리를 다른 스레드가 다시 접근하거나 delete하는 경우입니다. 공유 포인터를 락 없이 다루면 발생합니다.

해결법: 공유 객체는 mutex로 보호하거나, std::shared_ptr을 사용할 때도 수정 구간은 락으로 감싸세요. 객체 소유권을 명확히 하고, 한 스레드만 소유하도록 설계하는 것도 방법입니다.

문제 5: 스레드가 너무 많아 메모리 부족

원인: 파일 개수만큼 std::thread를 생성하면, 수천 개 파일에서 수천 개 스레드가 생겨 스택 메모리(스레드당 수 MB)가 급증합니다.

해결법: 스레드 풀을 사용하거나, std::thread::hardware_concurrency()를 상한으로 두고 작업을 큐에 넣어 제한된 워커가 처리하게 합니다.

문제 6: condition_variable wait에서 predicate 누락

원인: cv.wait(lock)만 쓰고 조건을 넣지 않으면, spurious wakeup(가짜 깨움) 시 조건이 아직 만족되지 않았는데도 깨어나 잘못된 동작을 할 수 있습니다.

해결법: cv.wait(lock, [] { return 조건; }) 형태로 predicate를 반드시 넣습니다.

// ❌ 위험: spurious wakeup 시 queue.empty()인데 깨어남
cv.wait(lock);

// ✅ 안전: 조건 만족 시에만 진행
cv.wait(lock, [] { return !queue.empty() || done; });

문제 7: std::mutex 재진입(재귀 잠금)

원인: 같은 스레드가 이미 잡은 mutex를 다시 lock()하면 데드락이 발생합니다. std::mutex는 재진입 가능하지 않습니다.

해결법: 같은 스레드에서 같은 락을 두 번 잡지 않도록 설계하거나, 재귀 뮤텍스가 필요하면 std::recursive_mutex를 사용합니다. 다만 재귀 뮤텍스는 설계 문제를 숨기는 경우가 많으므로, 락 구조를 단순화하는 편이 좋습니다.

문제 8: join 대기 중 블로킹

원인: join()은 해당 스레드가 끝날 때까지 블로킹합니다. 워커가 무한 루프에 빠지거나 외부 I/O에서 멈추면 메인 스레드도 함께 멈춥니다.

해결법: 워커에 종료 조건(stop_requested, running 플래그)을 두고, std::jthread::request_stop() 또는 std::atomic<bool>로 안전하게 종료를 요청합니다. 타임아웃이 필요하면 std::condition_variable::wait_for를 사용합니다.


11. 성능 비교: 단일 vs 멀티스레드

CPU 집약적 작업

순수 CPU 작업(예: 숫자 합산)을 여러 스레드로 나누면 멀티코어를 활용할 수 있습니다. 단, 스레드 생성·조인 비용과 스케줄링 오버헤드가 있어, 작업이 너무 작으면 오히려 느려질 수 있습니다.

#include <thread>
#include <iostream>
#include <chrono>
#include <vector>
#include <numeric>

// 단일 스레드: 0부터 n-1까지 합
long long sumSingle(long long n) {
    long long result = 0;
    for (long long i = 0; i < n; ++i) {
        result += i;
    }
    return result;
}

// 멀티 스레드: 4개 스레드로 구간 나누기
long long sumMulti(long long n, int numThreads = 4) {
    std::vector<std::thread> threads;
    std::vector<long long> partialSums(numThreads, 0);
    long long chunk = n / numThreads;

    for (int i = 0; i < numThreads; ++i) {
        long long start = i * chunk;
        long long end = (i == numThreads - 1) ? n : (i + 1) * chunk;
        threads.emplace_back([&partialSums, i, start, end]() {
            for (long long j = start; j < end; ++j) {
                partialSums[i] += j;
            }
        });
    }
    for (auto& t : threads) {
        t.join();
    }
    return std::accumulate(partialSums.begin(), partialSums.end(), 0LL);
}

int main() {
    const long long n = 100'000'000;
    auto start = std::chrono::high_resolution_clock::now();
    auto r1 = sumSingle(n);
    auto end = std::chrono::high_resolution_clock::now();
    auto singleMs = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    start = std::chrono::high_resolution_clock::now();
    auto r2 = sumMulti(n);
    end = std::chrono::high_resolution_clock::now();
    auto multiMs = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "Single: " << singleMs << " ms, result=" << r1 << "\n";
    std::cout << "Multi:  " << multiMs << " ms, result=" << r2 << "\n";
    return 0;
}

위 코드 설명: sumSingle은 한 스레드에서 0부터 n-1까지 합을 구하고, sumMulti는 4개 스레드로 구간을 나누어 각각 합한 뒤 합산합니다. 4코어 이상에서 sumMulti가 더 빠를 수 있지만, n이 작으면 스레드 생성 비용이 오히려 커질 수 있습니다. 주의: 실제로는 partialSums[i]에 각 스레드가 자기 인덱스만 쓰므로 data race는 없지만, 더 복잡한 공유 데이터 접근 시에는 mutex가 필요합니다.

예상 결과 (예시, 환경에 따라 다름): 4코어에서 sumMultisumSingle보다 2~3배 빠를 수 있습니다.

I/O 대기 작업

이미지 처리처럼 I/O(디스크 읽기·쓰기)와 CPU 작업이 섞여 있으면, 여러 파일을 병렬로 처리하면 전체 시간이 단축됩니다.

구분처리 방식10개 파일 기준 (예시)
단일 스레드순차 처리~10초
멀티 스레드파일별 스레드35초

실전 예시: 이미지 배치 처리 Before/After

도입부에서 언급한 이미지 변환 서비스를 실제 코드로 정리하면 다음과 같습니다.

Before (단일 스레드):

void processAll(const std::vector<std::string>& files,
                const std::string& outputDir) {
    for (size_t i = 0; i < files.size(); ++i) {
        Image img = loadImage(files[i]);      // I/O 대기
        img.resize(800, 600);                 // CPU 작업
        std::string outPath = outputDir + "/" + std::to_string(i) + ".jpg";
        saveImage(img, outPath);              // I/O 대기
    }
}

After (멀티 스레드):

void processAllParallel(const std::vector<std::string>& files,
                        const std::string& outputDir) {
    std::vector<std::thread> threads;
    threads.reserve(files.size());

    for (size_t i = 0; i < files.size(); ++i) {
        threads.emplace_back([&files, &outputDir, i]() {
            Image img = loadImage(files[i]);   // 각 스레드가 독립적으로 I/O
            img.resize(800, 600);             // CPU 작업
            std::string outPath = outputDir + "/" + std::to_string(i) + ".jpg";
            saveImage(img, outPath);           // I/O
        });
    }
    for (auto& t : threads) {
        t.join();
    }
}

위 코드 설명: [&files, &outputDir, i]로 캡처할 때 filesoutputDirprocessAllParallel이 반환할 때까지 유효하므로 안전합니다. i는 값으로 캡처되어 각 스레드가 자기 인덱스만 사용합니다. loadImage·saveImage가 스레드 안전해야 하며, 공유 리소스를 쓰면 mutex가 필요합니다.

주의: loadImage·saveImage가 내부적으로 전역 상태나 캐시를 수정한다면 data race가 발생할 수 있습니다. 그 경우 파일 경로만 넘기고, 각 스레드가 독립된 데이터만 다루도록 설계하거나, mutex로 보호해야 합니다.

스레드 수 선택 가이드

작업 유형권장 스레드 수이유
CPU 집약적std::thread::hardware_concurrency()코어 수에 맞춤
I/O 대기 많음코어 수의 2~4배I/O 대기 시 다른 스레드가 CPU 사용
혼합실험적 튜닝벤치마크로 최적값 찾기

std::thread::hardware_concurrency()는 CPU 코어 수를 반환합니다 (0이면 정보를 알 수 없음).

#include <thread>
#include <iostream>

int main() {
    unsigned int n = std::thread::hardware_concurrency();
    std::cout << "CPU cores: " << n << "\n";
    return 0;
}

12. 모범 사례와 프로덕션 패턴

모범 사례

  1. join을 기본으로: 스레드가 끝난 뒤 다음 로직을 실행해야 하거나, 스레드가 접근하는 데이터가 현재 스코프에 있을 때는 join을 사용하는 것이 안전합니다.
  2. detach는 신중히: 로그 스레드처럼 프로그램 전체 수명과 함께 살아가는 경우에만 쓰고, 그때도 참조하는 데이터 수명을 꼭 확인합니다.
  3. 데이터 수명 보장: detach한 스레드가 지역 변수·참조를 쓰지 않도록 하고, 값으로 복사하거나 스레드 수명보다 긴 객체만 참조합니다.
  4. 공유 데이터는 mutex 또는 atomic: 여러 변수를 한 번에 수정할 때는 mutex, 단일 변수(카운터·플래그)는 atomic을 고려합니다.
  5. 락 범위 최소화: mutex를 잡은 채로 I/O·무거운 연산을 하지 않습니다.
  6. 예외 안전: RAII(ThreadGuard) 또는 std::jthread로 join 누락을 방지합니다.

프로덕션 패턴

패턴 1: 워커 풀 + 작업 큐

hardware_concurrency()만큼 워커를 두고, condition_variable로 “작업이 들어올 때만” 깨워 폴링 없이 처리합니다. condition_variable 글에서 상세 구현을 다룹니다.

패턴 2: 로그 전용 스레드

다른 스레드는 큐에 메시지만 넣고, 로그 스레드만 파일에 쓰도록 분리합니다. 로그 스레드는 detach()하여 프로그램 수명과 함께 살아가는 데몬성 작업으로 둡니다.

패턴 3: 병렬 배치 처리 (상한 제한)

const size_t maxConcurrent = std::thread::hardware_concurrency();
std::vector<std::thread> workers;
workers.reserve(maxConcurrent);
for (size_t i = 0; i < files.size(); i += maxConcurrent) {
    workers.clear();
    for (size_t j = i; j < std::min(i + maxConcurrent, files.size()); ++j)
        workers.emplace_back(processFile, j, std::cref(files));
    for (auto& t : workers) t.join();
}

위 코드 설명: 파일 개수만큼 스레드를 만들지 않고, hardware_concurrency()를 상한으로 배치 단위로 처리해 스레드 수가 폭발하지 않게 합니다.

멀티스레딩 프로덕션 팁

  1. 스레드 수 제한: 무제한으로 스레드를 만들면 컨텍스트 스위칭 비용과 메모리 사용량이 급증합니다. hardware_concurrency()를 상한으로 두고, I/O 바운드 작업은 그 2~4배까지 실험해 보세요.
  2. 에러 전파: 스레드 내부에서 발생한 예외는 메인 스레드로 자동 전파되지 않습니다. std::promise·std::future로 예외를 전달하거나, 스레드 내부에서 try-catch로 처리한 뒤 공유 플래그에 기록하는 방식을 고려하세요.
  3. 디버깅: data race는 재현이 어렵고 타이밍에 따라 결과가 달라집니다. ThreadSanitizer(-fsanitize=thread)로 컴파일하면 data race를 감지할 수 있습니다.
g++ -std=c++17 -pthread -fsanitize=thread -g -o myapp myapp.cpp
  1. 네이티브 핸들: std::thread::native_handle()로 OS별 스레드 핸들을 얻을 수 있습니다. CPU 선호도 설정 등 플랫폼 특화 작업에 필요할 때 사용합니다.

13. 구현 체크리스트

std::thread를 사용할 때 다음 사항을 확인하세요.

  • 스레드 생성 후 반드시 join() 또는 detach() 호출
  • join()/detach()한 번만 호출 (재호출 시 undefined behavior)
  • detach() 사용 시 스레드가 참조하는 데이터 수명 보장
  • 람다에서 참조 캡처([&]) 시 use-after-free 주의
  • 공유 변수 수정 시 mutex + lock_guard 또는 atomic 사용
  • mutex 사용 시 락 범위 최소화 (I/O·무거운 연산은 락 밖에서)
  • 예외 안전 시 RAII(ThreadGuard) 또는 std::jthread 고려
  • 함수 포인터 전달 시 괄호 없이 (sayHello not sayHello())

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
  • C++ 실전 가이드 시리즈 전체 목차 | #0~#49 기초·메모리·네트워크·면접

이 글에서 다루는 키워드 (관련 검색어)

C++ 스레드, std::thread, 멀티스레딩 기초, join detach, 스레드 생성, 동시성 프로그래밍 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • 스레드는 프로세스 안의 실행 단위이며, 같은 프로세스의 스레드들은 메모리(힙·전역)를 공유합니다.
  • std::thread로 함수·람다·함수 객체를 넘겨 스레드를 만들 수 있고, 인자는 값으로(또는 std::ref로) 넘깁니다.
  • 스레드는 반드시 join 또는 detach 해야 하며, 기본적으로는 join으로 끝날 때까지 기다리는 편이 안전합니다.
  • 여러 스레드가 같은 데이터를 수정하면 data race가 되므로, std::mutexlock_guard로 보호하거나 단일 변수는 std::atomic을 사용합니다.
  • condition_variable로 “조건이 만족될 때만 깨우기” 패턴을 적용하면 폴링 없이 작업 큐·Producer-Consumer를 구현할 수 있습니다.
  • RAII(ThreadGuard) 또는 std::jthread로 join 누락을 방지하고, stop_token으로 종료 요청을 안전하게 전달할 수 있습니다.
  • 프로덕션에서는 스레드 수 상한, 워커 풀, ThreadSanitizer 검사 등 모범 사례를 적용합니다.

한 줄 요약: std::thread로 스레드를 만들고 반드시 join 또는 detach 하며, 공유 데이터는 mutex·atomic으로 보호하고, condition_variable로 이벤트 기반 대기를 구현합니다. 다음으로 mutex를 읽어보면 좋습니다.

다음 글

스레드를 띄우는 방법을 알았다면, 이제 여러 스레드가 같은 데이터를 안전하게 다루는 방법이 필요합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++11 멀티스레딩 완벽 입문 가이드. std::thread 생성·조인·디태치, 프로세스 vs 스레드 차이, 스레드 안전성(thread safety) 개념, join 누락·디태치 남용 등 자주 하는 실수와 해결법을… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

Q. std::thread vs std::async, 언제 뭘 쓰나요?

A. std::thread는 스레드를 직접 생성·관리할 때 쓰고, std::async는 반환값을 받아야 하거나 “나중에 결과만 필요할 때” 쓰기 좋습니다. 단순히 백그라운드에서 실행만 하면 될 때는 std::thread가 적합합니다.

Q. 스레드 풀은 언제 쓰나요?

A. 스레드를 자주 생성·파괴하면 오버헤드가 큽니다. 같은 유형의 작업을 반복할 때는 미리 스레드 풀을 만들어 재사용하는 패턴이 효율적입니다. 이 글의 기초를 익힌 뒤 mutex·condition_variable으로 스레드 풀을 구현할 수 있습니다.

Q. 컴파일 시 “undefined reference to pthread” 에러가 나요

A. Linux에서 std::thread를 쓰려면 -pthread 링크 옵션이 필요합니다.

g++ -std=c++17 -pthread -o myapp myapp.cpp

Q. join과 detach, 어떤 걸 기본으로 써야 하나요?

A. join을 기본으로 사용하세요. 스레드가 끝난 뒤 다음 로직을 실행해야 하거나, 스레드가 접근하는 데이터가 현재 스코프에 있을 때는 join이 안전합니다. detach는 로그 스레드처럼 프로그램 전체 수명과 함께 살아가는 데몬성 작업에만 쓰고, 그때도 참조하는 데이터 수명을 꼭 확인하세요.

다음 글: C++ 실전 가이드 #7-2: mutex와 동기화 - race condition, mutex, lock_guard로 공유 데이터를 보호하는 방법을 설명합니다.

참고 자료


관련 글

  • C++ condition_variable 실무 패턴 |
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례