C++ std::thread와 jthread 기초: 생성, join·detach, 공유 데이터 보호

std::thread를 처음 쓸 때는 다음 순서로 익히는 것이 안전합니다. 스레드를 만들고, join()으로 끝날 때까지 기다리는 것을 기본으로 삼고, 여러 스레드가 함께 쓰는 변수는 mutex나 atomic으로 보호하며, detach()는 데이터 수명을 확실히 보장할 수 있을 때만 씁니다. GCC/Clang으로 Linux에서 빌드할 때는 -pthread 옵션을 붙이는 것이 관례입니다(glibc 2.34 이상에서는 없어도 링크되는 경우가 많지만, 붙여 두는 편이 이식성이 좋습니다).

들어가며

파일 여러 개를 읽어 변환하고 다시 저장하는 작업을 for 루프로 하나씩 처리하면, 한 파일의 디스크 I/O를 기다리는 동안 CPU는 놀고 계산도 코어 하나에서만 돌아갑니다.

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 대기
    }
}

파일마다 별도 스레드(thread, 프로세스 안에서 독립적으로 실행되는 실행 흐름)에 넘기면 한 파일이 I/O를 기다리는 동안 다른 파일을 계산할 수 있고, 여러 코어를 동시에 쓸 수 있습니다. 얼마나 빨라지는지는 디스크 대역폭, 코어 수, 파일 크기에 따라 달라지므로 직접 측정해 봐야 합니다.

대신 같은 메모리를 여러 스레드가 동시에 건드리면 data race가 생깁니다. 그래서 이 글에서는 스레드 생성, join(스레드가 끝날 때까지 기다리기), detach(스레드를 백그라운드로 떼어 놓기)를 먼저 다루고, 공유 데이터 보호는 mutex, condition_variable, atomic 글에서 자세히 이어 갑니다. 같은 OS 스레드 모델인 Java의 Thread·Executor와는 문제의식이 비슷하고, Go 고루틴처럼 런타임이 경량 스케줄링을 하는 모델과는 트레이드오프가 다릅니다. Rust의 std::thread·채널은 소유권 규칙과 함께 읽으면 비교가 잘 됩니다.

스레드를 분리하는 전형적인 상황은 몇 가지로 정리됩니다. 데스크톱 앱에서 무거운 저장·압축 작업을 UI 스레드에서 하면 그동안 화면이 멈추므로, 작업을 별도 스레드로 옮기고 UI 스레드는 이벤트 처리만 하게 합니다. 여러 원격 API를 순차 호출하면 응답 대기 시간이 모두 더해지지만, 병렬로 보내고 모두 join하면 전체 시간이 가장 느린 응답 하나에 가까워집니다. 반대로 스레드를 잘못 쓰면 생기는 문제도 정해져 있습니다. 여러 스레드가 하나의 int에 counter++를 하면 증가가 유실되고, 두 mutex를 스레드마다 다른 순서로 잡으면 데드락이 나며, 작업 큐를 sleep 없이 계속 확인하는 busy-wait 루프는 할 일이 없을 때도 코어 하나를 다 씁니다. 각각 atomic·mutex, 잠금 순서 통일이나 std::scoped_lock, condition_variable로 해결하며 뒤 글들에서 다룹니다.

프로세스와 스레드

프로세스는 실행 중인 프로그램 하나입니다. 메모리 공간, 파일 핸들, 환경이 프로세스마다 따로 있습니다. 스레드는 그 프로세스 안에서 돌아가는 실행 흐름 단위입니다. 한 프로세스는 메인 스레드 하나로 시작하며, 필요하면 스레드를 더 만들어 여러 일을 동시에 할 수 있습니다.

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

같은 프로세스 안의 스레드들은 힙, 전역 변수, 정적 변수를 공유하고, 지역 변수가 놓이는 스택은 스레드마다 따로 가집니다. 그래서 여러 스레드가 같은 전역·힙 변수를 동시에 쓰거나, 한쪽이 쓰는 동안 다른 쪽이 읽으면 data race가 됩니다. C++ 표준에서 data race는 정의되지 않은 동작(undefined behavior)이라 결과가 틀리는 것에서 그치지 않고 크래시가 날 수도 있습니다.

스레드를 쓰는 이유는 크게 세 가지입니다. I/O를 기다리는 동안 다른 스레드가 CPU를 쓰게 해 처리량을 높이고, UI 스레드를 막지 않아 반응성을 유지하고, 계산을 여러 코어에 나눠 병렬로 실행하기 위해서입니다.


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

C++11부터 <thread>에 std::thread가 들어 있습니다. 함수, 람다, 함수 객체를 넘기면 그것이 새 스레드에서 실행됩니다.

함수를 넘기는 경우

// 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()를 호출한 결과를 넘기는 것이 아니기 때문입니다. 실행하면 Hello from thread!가 한 줄 출력됩니다.

람다를 넘기는 경우

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

람다를 넘기면 별도 함수를 만들지 않고 동작을 그 자리에 쓸 수 있어 간단한 작업에 자주 씁니다. 이 경우에도 join() 또는 detach()를 반드시 호출해야 합니다.

인자를 넘기는 경우

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

호출할 함수 다음에 인자를 나열하면 그 인자들이 새 스레드가 쓸 저장 공간에 값으로 복사(또는 이동)된 뒤 함수에 전달됩니다. 출력은 Sum: 30입니다.

std::ref로 참조 전달하기

인자가 복사되기 때문에, 스레드 함수가 int&를 받는다면 그냥 변수를 넘겨서는 컴파일되지 않습니다. 복사된 임시 값에 비const 참조를 바인딩할 수 없기 때문입니다. 원본을 넘기려면 std::ref()(const 참조라면 std::cref())로 감쌉니다.

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

이때 복사되는 것은 참조 래퍼뿐이라 스레드 안에서 원본 counter를 수정합니다. 참조로 넘긴 변수는 스레드가 끝날 때까지 살아 있어야 하며, 그렇지 않으면 이미 사라진 객체를 가리키게 됩니다. 출력은 counter: 1입니다.


join과 detach: 스레드 수명 관리

std::thread 객체가 소멸할 때 아직 joinable 상태(join도 detach도 하지 않은 상태)이면, 스레드가 이미 끝났더라도 std::terminate()가 호출되어 프로그램이 종료됩니다. 그래서 스레드를 만든 뒤에는 객체가 소멸하기 전에 반드시 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()으로 받을 수 없으며, 결과가 필요하면 std::async나 std::promise/std::future를 씁니다.

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

std::thread t(doWork);
t.detach();  // t와의 연결을 끊음. 스레드는 백그라운드에서 계속 실행

detach()를 호출하면 std::thread 객체와 실제 스레드의 연결이 끊어져, 그 스레드는 혼자 돌다가 끝납니다. 아무도 종료를 기다리지 않으므로 스레드가 참조하는 변수·객체가 스레드보다 먼저 파괴되지 않도록 수명을 직접 맞춰야 하고, 그렇지 않으면 use-after-free가 됩니다. main이 반환하면 프로세스가 종료되면서 detach된 스레드도 정리 없이 함께 사라진다는 점도 기억해야 합니다.

joinable()로 상태 확인하기

join이나 detach는 joinable 상태에서 한 번만 할 수 있습니다. 이미 join·detach한 스레드에 다시 join()을 호출하면 std::system_error(오류 코드 invalid_argument) 예외가 던져집니다. 호출 전에 joinable()로 확인할 수 있습니다.

#include <thread>
#include <iostream>
void doWork() {
    std::cout << "Working...\n";
}
int main() {
    std::thread t(doWork);
    if (t.joinable()) {
        t.join();
    }
    // t.join();  // 다시 호출하면 std::system_error 예외
    return 0;
}

기본은 join입니다. 스레드가 끝난 뒤 다음 로직을 실행해야 하거나, 스레드가 접근하는 데이터가 현재 스코프에 있다면 join이 안전합니다. detach는 데이터 수명과 프로그램 종료 시점까지 따져 봐야 하므로 꼭 필요한 경우로 제한합니다.


ThreadGuard와 std::jthread로 join 누락 막기

t.join()을 호출하기 전에 예외가 나서 스코프를 벗어나면, joinable한 std::thread가 소멸하면서 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 보장
}

mightThrow()에서 예외가 나도 스택 해제 과정에서 guard의 소멸자가 먼저 실행되어 t.join()을 호출하므로 std::terminate()를 피할 수 있습니다. 지역 변수는 선언의 역순으로 소멸하므로 guard가 t보다 먼저 정리됩니다.

std::jthread (C++20)

C++20의 std::jthread(joining thread)는 이 패턴을 표준으로 제공합니다.

#include <thread>
#include <iostream>
void doWork() {
    std::cout << "Working...\n";
}
int main() {
    std::jthread t(doWork);  // 소멸 시 자동 join
    return 0;
}

std::jthread는 소멸할 때 joinable이면 먼저 request_stop()을 호출한 뒤 join()합니다. 종료 요청을 보내는 방법은 뒤의 stop_token 절에서 다룹니다.


공유 변수에서 data race가 생기는 과정

여러 스레드가 같은 데이터를 동시에 다뤄도 결과가 항상 올바르고 정의되지 않은 동작이 없으면 그 코드를 스레드 안전하다고 말합니다.

#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이 아닐 수 있음
    return 0;
}

counter++는 실제로 읽기, 더하기, 쓰기의 세 단계입니다. 두 스레드가 같은 값을 읽은 뒤 각자 1을 더해 쓰면 한쪽의 증가가 덮어써져 사라지므로, 200000보다 작은 값이 나올 수 있습니다.

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만 쓰는 경우는 별도 보호 없이도 안전합니다.


std::mutex와 lock_guard로 공유 데이터 보호하기

여러 스레드가 같은 변수를 수정할 때는 mutex(상호 배제)로 한 번에 한 스레드만 접근하게 합니다. 자세한 내용은 mutex 글에서 다루고, 여기서는 기본 사용법만 봅니다.

// 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::lock_guard는 생성할 때 lock()을, 소멸할 때 unlock()을 호출하므로 예외가 나도 락이 풀립니다. 락을 잡은 채로 I/O나 무거운 연산을 하면 다른 스레드가 그만큼 오래 기다리므로, 보호가 꼭 필요한 구간만 감싸는 것이 좋습니다. 두 mutex를 스레드마다 다른 순서로 잡으면 데드락이 날 수 있으며, 순서 통일과 std::scoped_lock은 mutex 글에서 다룹니다.


condition_variable로 만든 Producer-Consumer

condition_variable(조건 변수)은 특정 조건이 참이 될 때까지 스레드를 재우고, 조건이 바뀌면 깨우는 동기화 객체입니다. mutex만으로는 “큐에 데이터가 들어올 때까지 잠들어 있다가 들어오면 깨어난다”를 표현할 수 없어 폴링을 하게 되는데, 조건 변수를 쓰면 이벤트가 있을 때만 깨어납니다. 자세한 내용은 condition_variable 글에서 다룹니다.

// g++ -std=c++17 -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;
}

cv.wait(lock, predicate)는 predicate가 false이면 락을 풀고 잠들었다가, 깨어나면 락을 다시 잡고 predicate를 검사합니다. 알림 없이 깨어나는 spurious wakeup이 허용되기 때문에 조건을 반드시 함께 넘겨야 합니다. 출력은 Consumed: 0부터 Consumed: 4까지 순서대로 나옵니다.


std::atomic으로 락 없이 카운터 보호하기

카운터나 플래그처럼 단일 변수만 보호하면 될 때는 std::atomic이 mutex보다 가볍습니다. 자세한 내용은 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++;  // 원자적 읽기-수정-쓰기
    }
}
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++는 하나의 원자적 읽기-수정-쓰기 연산이라 증가가 유실되지 않습니다. 다만 여러 스레드가 같은 변수를 자주 갱신하면 해당 캐시 라인이 코어 사이를 오가므로 비용이 없는 것은 아닙니다. 여러 변수를 한꺼번에 일관되게 바꿔야 하거나 “확인 후 변경” 같은 복합 조건이 있다면 mutex로 블록 전체를 보호하는 편이 안전합니다.


stop_token으로 jthread에 종료 요청 보내기

std::jthread는 자동 join과 함께 협력적 취소 수단인 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();  // 종료 요청
    return 0;          // t 소멸 시 join
}

스레드 함수의 첫 매개변수가 std::stop_token이면 jthread가 자신의 토큰을 자동으로 넘깁니다. 워커는 루프마다 stop_requested()를 확인하고, 메인은 request_stop()으로 종료를 요청합니다. 위 예제에서 request_stop()을 빼도 소멸자가 요청을 보낸 뒤 join하므로 결과는 같습니다. 강제로 스레드를 죽이는 것이 아니라 워커가 확인해야 끝나는 방식이므로, 확인 없이 오래 블로킹하는 코드가 있으면 종료가 그만큼 늦어집니다. 출력은 Working...이 몇 번 나온 뒤 Stopped.입니다.


자주 하는 실수

join/detach 누락

void bad() {
    std::thread t(doWork);
}  // t가 joinable인 채로 소멸 → std::terminate()

GCC/libstdc++에서는 terminate called without an active exception 메시지와 함께 프로그램이 종료됩니다. 소멸 전에 join() 또는 detach()를 호출하거나, ThreadGuard·std::jthread를 씁니다.

같은 스레드를 두 번 join

std::thread t(doWork);
t.join();
t.join();  // std::system_error 예외

join한 스레드는 더 이상 joinable이 아니므로 두 번째 호출은 std::system_error를 던집니다. 여러 경로에서 join할 수 있는 코드라면 if (t.joinable())로 감쌉니다.

detach한 스레드가 지역 변수를 참조로 캡처

void bad() {
    std::string msg = "hello";
    std::thread t([&msg]() { std::cout << msg; });
    t.detach();
}  // msg가 파괴된 뒤에도 스레드가 msg에 접근할 수 있음

void good() {
    std::string msg = "hello";
    std::thread t([msg]() { std::cout << msg; });  // 값으로 복사
    t.detach();
}

bad()가 반환하면 msg는 사라지지만 detach된 스레드는 계속 돌 수 있어, 그 뒤의 접근은 정의되지 않은 동작입니다. AddressSanitizer로 빌드하면 stack-use-after-return 같은 형태로 잡히기도 하지만 타이밍에 따라 재현되지 않을 수 있습니다. 값으로 캡처하거나, join으로 스레드가 끝난 뒤에 함수가 반환되게 합니다.

함수 이름 뒤에 괄호 붙이기

std::thread t(sayHello());  // 컴파일 에러: void 결과를 넘기려 함
std::thread t(sayHello);    // 함수를 넘김

sayHello()는 그 자리에서 함수를 호출한 결과(void)를 넘기는 것이라 컴파일되지 않습니다. 함수가 호출 가능한 객체를 반환한다면 컴파일은 되지만, 원래 함수는 현재 스레드에서 이미 실행된 뒤라 의도와 다르게 동작합니다.

스레드를 너무 많이 만들기

파일 개수만큼 std::thread를 만들면 파일이 수천 개일 때 스레드도 수천 개가 됩니다. 스레드마다 스택이 예약되고(Linux glibc 기본값은 보통 8MB의 가상 메모리, Windows 기본값은 1MB), 컨텍스트 스위칭도 늘어나며, OS의 스레드 수 제한에 걸리면 std::system_error(Resource temporarily unavailable)가 납니다. 워커 수를 제한한 스레드 풀이나 배치 처리를 씁니다.

predicate 없이 condition_variable 대기

cv.wait(lock);                                          // spurious wakeup에 취약
cv.wait(lock, [] { return !queue.empty() || done; });   // 조건이 참일 때만 진행

predicate 없이 기다리면 알림이 없었는데 깨어나거나, 알림이 대기 시작 전에 와서 놓치는 경우 모두에 취약합니다.

같은 std::mutex를 한 스레드에서 두 번 잠그기

이미 잡은 std::mutex를 같은 스레드가 다시 lock()하는 것은 정의되지 않은 동작이며, 실제로는 대개 데드락으로 나타납니다. 락 구조를 단순하게 바꾸는 것이 우선이고, 정말 재진입이 필요하면 std::recursive_mutex를 씁니다.

링크 에러: undefined reference to pthread_create

오래된 glibc(2.34 미만) 환경에서 -pthread 없이 빌드하면 이 링크 에러가 납니다. g++ -std=c++17 -pthread -o myapp myapp.cpp처럼 옵션을 붙입니다.


CPU 작업과 I/O 작업에서 스레드를 늘렸을 때

CPU 집약적 작업

순수 계산을 여러 스레드로 나누면 여러 코어를 쓸 수 있지만, 스레드 생성·join 비용이 있어 작업이 작으면 오히려 느려질 수 있습니다.

#include <thread>
#include <iostream>
#include <chrono>
#include <vector>
#include <numeric>
long long sumSingle(long long n) {
    long long result = 0;
    for (long long i = 0; i < n; ++i) {
        result += i;
    }
    return result;
}
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]() {
            long long local = 0;              // 지역 변수에 모은 뒤
            for (long long j = start; j < end; ++j) {
                local += j;
            }
            partialSums[i] = local;           // 마지막에 한 번만 기록
        });
    }
    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::steady_clock::now();
    auto r1 = sumSingle(n);
    auto end = std::chrono::steady_clock::now();
    auto singleMs = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    start = std::chrono::steady_clock::now();
    auto r2 = sumMulti(n);
    end = std::chrono::steady_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;
}

각 스레드는 partialSums의 자기 인덱스에만 쓰므로 data race가 없습니다. 반복마다 partialSums[i]에 직접 더하지 않고 지역 변수에 모으는 이유는 false sharing 때문입니다. 인접한 long long 원소들은 같은 캐시 라인에 있어, 여러 코어가 그 라인에 계속 쓰면 라인이 코어 사이를 오가며 느려집니다. 결과는 환경에 따라 다르며, 최적화 빌드에서는 컴파일러가 sumSingle의 루프를 벡터화하거나 닫힌 식으로 바꾸기도 해서 나눌 이득이 거의 없게 보일 수 있습니다. 경과 시간 측정에는 시스템 시각 조정의 영향을 받지 않는 steady_clock을 씁니다.

I/O가 섞인 작업

디스크 읽기·쓰기와 계산이 섞인 작업은 여러 파일을 병렬로 처리하면 한 파일이 I/O를 기다리는 동안 다른 파일을 계산할 수 있어 대기 시간이 겹칩니다. 다만 디스크 대역폭이나 코어 수에 도달하면 스레드를 더 늘려도 빨라지지 않습니다.

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]);
            img.resize(800, 600);
            std::string outPath = outputDir + "/" + std::to_string(i) + ".jpg";
            saveImage(img, outPath);
        });
    }
    for (auto& t : threads) {
        t.join();
    }
}

files와 outputDir을 참조로 캡처해도 함수가 모든 스레드를 join한 뒤에 반환하므로 안전하고, i는 값으로 캡처되어 스레드마다 자기 인덱스를 씁니다. 대신 loadImage·saveImage가 내부에서 전역 상태나 캐시를 수정한다면 data race가 생기므로, 그 함수들이 스레드 안전한지 먼저 확인해야 합니다. 이 코드는 파일 수만큼 스레드를 만들기 때문에 파일이 많다면 아래처럼 동시 실행 수를 제한해야 합니다.

스레드 수 정하기

std::thread::hardware_concurrency()는 하드웨어가 동시에 실행할 수 있는 스레드 수(하이퍼스레딩을 포함한 논리 프로세서 수)의 힌트를 반환하며, 알 수 없으면 0을 반환합니다. CPU 집약적 작업은 이 값 근처가 출발점이고, I/O 대기가 많은 작업은 그보다 많은 스레드가 유리할 수 있지만 적정값은 작업마다 다르므로 측정해서 정합니다.

#include <algorithm>
#include <thread>

const size_t maxConcurrent = std::max(1u, std::thread::hardware_concurrency());  // 0 방지
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()가 0을 반환하면 i += 0이 되어 무한 루프에 빠지므로 최솟값을 1로 둡니다. 이 방식은 배치 안에서 가장 느린 파일이 끝날 때까지 다음 배치를 시작하지 못하는 단점이 있어, 파일 처리 시간이 들쭉날쭉하다면 작업 큐에서 워커가 하나씩 꺼내 가는 스레드 풀이 더 효율적입니다. 스레드 풀 구현은 condition_variable 글과 고급 멀티스레딩 글에서 다룹니다.


실무에서 함께 알아 둘 것

스레드 함수 안에서 던진 예외는 다른 스레드로 전파되지 않습니다. 잡히지 않은 예외가 스레드 함수를 빠져나가면 그 자리에서 std::terminate()가 호출되므로, 결과와 예외를 호출자에게 넘기려면 std::promise/std::future, std::packaged_task, std::async를 쓰거나 스레드 안에서 try-catch로 처리합니다.

로그 전용 스레드처럼 프로그램 내내 사는 스레드도 detach보다는 종료 시점에 정지 요청을 보내고 join하는 편이 안전합니다. detach한 스레드는 main이 끝난 뒤 정적 객체가 파괴되는 동안에도 돌 수 있어, 큐에 남은 로그가 유실되거나 이미 파괴된 객체에 접근할 수 있기 때문입니다.

data race는 타이밍에 따라 나타났다 사라지므로 테스트만으로는 잡기 어렵습니다. GCC와 Clang에서 ThreadSanitizer를 켜고 빌드해 테스트를 돌리면 실제로 실행된 경로의 data race를 보고해 줍니다.

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

CPU 친화도 설정처럼 표준에 없는 기능이 필요하면 std::thread::native_handle()로 OS 스레드 핸들(Linux에서는 pthread_t)을 얻어 플랫폼 API를 호출합니다.


같이 보면 좋은 글

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

참고 자료