본문 바로가기

스레드 생성과 관리

프로세스와 스레드를 구분하고 std::thread의 생성·인자 전달·join·detach로 실행 흐름의 수명을 관리합니다.

지금까지 단일 스레드(Single-threaded) 환경에서 사용하는 C++ 프로그래밍 기법들을 다뤘습니다.

이 장에서는 멀티스레딩(Multithreading)에서 스레드 생성, 동기화, 경쟁 조건, 공유 상태 관리를 다룹니다.

멀티스레딩은 프로그램이 여러 작업을 동시에 병렬로 실행할 수 있도록 하는 기술입니다.

이는 사용자 인터페이스의 반응성 향상, 복잡한 계산의 처리 속도 개선, 네트워크 통신 효율 증대 등 다양한 이점을 제공합니다.

C++11 표준부터는 std::thread를 비롯한 표준 라이브러리를 통해 멀티스레딩을 직접 지원하게 되면서, 플랫폼 독립적으로 멀티스레드 애플리케이션을 개발하는 것이 매우 쉬워졌습니다.

이번 장에서는 C++ 표준 라이브러리의 std::thread를 사용하여 스레드를 생성하고 관리하는 기본적인 방법을 학습하겠습니다.

동시성은 여러 작업의 진행을 겹치게 구성하는 일이고, 병렬성은 그 작업들을 실제로 동시에 실행하는 일입니다. 코어가 하나여도 스레드를 번갈아 실행할 수 있으며, 스레드 수를 늘린다고 계산이 반드시 빨라지지는 않습니다. 생성 비용, 잠금 대기, 실제 CPU·I/O 병목을 함께 봅니다. 각 스레드의 스택이 따로 있다는 사실도 다른 스레드가 전달받은 포인터로 그 스택 객체를 읽을 수 없다는 뜻은 아닙니다. 참조를 넘기면 객체의 수명과 동기화까지 책임져야 합니다.

예제 실행 준비와 출력 순서

아래에서 main을 가진 코드 블록 하나씩 별도 파일에 저장합니다. 이 절의 예제는 C++11 이상, 뒤의 shared_mutex·scoped_lock 예제까지 따라가려면 C++17 이상이 필요합니다. GCC나 Clang의 Linux 환경에서는 g++ -std=c++17 -pthread thread.cpp -o thread_demo로 빌드하고 ./thread_demo로 실행할 수 있습니다. MSVC 등 다른 도구에서는 해당 C++ 표준과 스레드 라이브러리를 지원하는 프로젝트 설정을 사용합니다.

스레드 두 개의 출력 순서나 한 줄 안의 출력 조각 순서는 스케줄링에 따라 달라질 수 있습니다. sleep_for는 완료를 증명하는 수단이 아닙니다. 아래 예제에서 모든 작업이 끝났다는 근거는 마지막 join이 반환했다는 사실입니다.

std::thread의 joinable 상태는 실행이 아니라 소유권 의무를 뜻한다

기본 비joinable→실행 흐름 소유→성공 join으로 의무 해소의 정상 경로가 명확하다. detach/move는 대안 경로로 유지하고 실행 완료와 joinable을 구분한다.

C++ · THREAD OWNERSHIP

joinable은 실행 여부보다 소유권 의무를 뜻한다

std::thread 객체는 실행 흐름의 handle을 소유한다. 실행이 끝나도 joinable일 수 있으며, joinable 객체의 소멸은 terminate를 호출한다.

thread 객체의 소유권 상태 변화기본 비joinable 객체에서 실행 흐름을 소유하면 joinable이다. 성공한 join과 detach, move는 같은 소유 상태에서 선택할 서로 다른 대안이며 연속해서 모두 호출하는 순서가 아니다. join은 완료를 기다리고, detach는 수명 연결을 끊으며, move는 원본 의무를 대상으로 이전한다. 실행이 완료되어도 join 전에는 소유 의무가 남을 수 있다.실행 시작join()detach()move기본 객체비joinablejoinable · 소유 의무실행 중 또는 이미 완료소멸 전에 의무 해소현재: 비joinable실행 완료까지 기다림소유 의무 해소현재: 비joinable실행 수명 연결 분리프로그램 종료는 별도원본: 비joinable대상: joinable대상에 소유 의무 이전
thread 객체의 소유권 상태 변화기본 비joinable 객체에서 실행 흐름을 소유하면 joinable이다. 성공한 join과 detach, move는 같은 소유 상태에서 선택할 서로 다른 대안이며 연속해서 모두 호출하는 순서가 아니다. join은 완료를 기다리고, detach는 수명 연결을 끊으며, move는 원본 의무를 대상으로 이전한다. 실행이 완료되어도 join 전에는 소유 의무가 남을 수 있다.실행 시작join()detach()move기본 객체비joinablejoinable실행 흐름의 handle완료 뒤에도 의무 남음현재: 비joinable실행 완료까지 기다림소유 의무 해소현재: 비joinable실행 수명 연결 분리프로그램 종료는 별도원본: 비joinable대상: joinable대상에 소유 의무 이전

기본 객체 → 실행 시작

기본 객체는 비joinable이다. 새 실행 흐름을 시작한 객체는 joinable이며 실행이 완료되어도 의무는 남는다.

join() · 완료까지 대기

성공한 join은 실행 완료를 기다리고 현재 객체를 비joinable로 만든다.

detach() · 수명 연결 분리

성공한 detach 뒤 객체는 비joinable이다. 실행 흐름은 독립적으로 계속할 수 있지만 프로그램 종료 전 완료를 보장하지 않는다.

move · 의무 이전

원본은 비joinable, 대상은 joinable이 된다. 대상도 join/detach 등의 의무를 해소해야 한다.

move 대입의 대상이 이미 joinable이면 terminate한다. std::ref로 전달한 대상은 스레드가 사용하는 동안 살아 있어야 한다. detach는 표준 daemon을 만드는 기능이 아니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
핸들 준비

기본 thread는 실행 흐름을 소유하지 않는다.

  • joinable: false
  • 소유한 실행 흐름: 없음
  • 기본 생성한 핸들에는 join/detach 의무가 없다.
스레드 소유

실행 흐름을 시작한 thread가 소유권을 가진다.

  • joinable: true
  • 의무: join 또는 detach
  • 실행 함수가 먼저 끝나도 joinable 소유 상태는 자동 해소되지 않는다.
성공 join

종료를 기다려 소유 의무를 해소한다.

  • joinable: false
  • join: 성공 반환
  • join은 실행 완료를 확인하고 핸들의 소유 상태를 해소한다.

std::thread의 joinable 상태는 실행이 아니라 소유권 의무를 뜻한다

기본 비joinable→실행 흐름 소유→성공 join으로 의무 해소의 정상 경로가 명확하다. detach/move는 대안 경로로 유지하고 실행 완료와 joinable을 구분한다.

C++ · THREAD OWNERSHIP

joinable은 실행 여부보다 소유권 의무를 뜻한다

std::thread 객체는 실행 흐름의 handle을 소유한다. 실행이 끝나도 joinable일 수 있으며, joinable 객체의 소멸은 terminate를 호출한다.

thread 객체의 소유권 상태 변화기본 비joinable 객체에서 실행 흐름을 소유하면 joinable이다. 성공한 join과 detach, move는 같은 소유 상태에서 선택할 서로 다른 대안이며 연속해서 모두 호출하는 순서가 아니다. join은 완료를 기다리고, detach는 수명 연결을 끊으며, move는 원본 의무를 대상으로 이전한다. 실행이 완료되어도 join 전에는 소유 의무가 남을 수 있다.실행 시작join()detach()move기본 객체비joinablejoinable · 소유 의무실행 중 또는 이미 완료소멸 전에 의무 해소현재: 비joinable실행 완료까지 기다림소유 의무 해소현재: 비joinable실행 수명 연결 분리프로그램 종료는 별도원본: 비joinable대상: joinable대상에 소유 의무 이전
thread 객체의 소유권 상태 변화기본 비joinable 객체에서 실행 흐름을 소유하면 joinable이다. 성공한 join과 detach, move는 같은 소유 상태에서 선택할 서로 다른 대안이며 연속해서 모두 호출하는 순서가 아니다. join은 완료를 기다리고, detach는 수명 연결을 끊으며, move는 원본 의무를 대상으로 이전한다. 실행이 완료되어도 join 전에는 소유 의무가 남을 수 있다.실행 시작join()detach()move기본 객체비joinablejoinable실행 흐름의 handle완료 뒤에도 의무 남음현재: 비joinable실행 완료까지 기다림소유 의무 해소현재: 비joinable실행 수명 연결 분리프로그램 종료는 별도원본: 비joinable대상: joinable대상에 소유 의무 이전

기본 객체 → 실행 시작

기본 객체는 비joinable이다. 새 실행 흐름을 시작한 객체는 joinable이며 실행이 완료되어도 의무는 남는다.

join() · 완료까지 대기

성공한 join은 실행 완료를 기다리고 현재 객체를 비joinable로 만든다.

detach() · 수명 연결 분리

성공한 detach 뒤 객체는 비joinable이다. 실행 흐름은 독립적으로 계속할 수 있지만 프로그램 종료 전 완료를 보장하지 않는다.

move · 의무 이전

원본은 비joinable, 대상은 joinable이 된다. 대상도 join/detach 등의 의무를 해소해야 한다.

move 대입의 대상이 이미 joinable이면 terminate한다. std::ref로 전달한 대상은 스레드가 사용하는 동안 살아 있어야 한다. detach는 표준 daemon을 만드는 기능이 아니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
핸들 준비

기본 thread는 실행 흐름을 소유하지 않는다.

  • joinable: false
  • 소유한 실행 흐름: 없음
  • 기본 생성한 핸들에는 join/detach 의무가 없다.
스레드 소유

실행 흐름을 시작한 thread가 소유권을 가진다.

  • joinable: true
  • 의무: join 또는 detach
  • 실행 함수가 먼저 끝나도 joinable 소유 상태는 자동 해소되지 않는다.
성공 join

종료를 기다려 소유 의무를 해소한다.

  • joinable: false
  • join: 성공 반환
  • join은 실행 완료를 확인하고 핸들의 소유 상태를 해소한다.

프로세스와 스레드 (Process vs. Thread)

멀티스레딩을 이해하기 전에, 먼저 프로세스(Process)와 스레드(Thread)의 개념을 명확히 알아야 합니다.

  • 프로세스 (Process)
    • 운영체제로부터 자원(메모리 공간, CPU 시간, 파일 핸들 등)을 할당받아 실행되는 프로그램의 인스턴스입니다.
    • 각 프로세스는 독립적인 메모리 공간을 가집니다. 다른 프로세스의 메모리에 직접 접근할 수 없습니다 (IPC: Inter-Process Communication 필요).
    • 보통 하나의 프로세스 내에 하나 이상의 스레드가 존재합니다.
  • 스레드 (Thread)
    • 프로세스 내에서 실제로 코드를 실행하는 가장 작은 단위입니다. 경량 프로세스(lightweight process)라고도 불립니다.
    • 동일한 프로세스 내의 모든 스레드는 프로세스의 메모리 공간(코드, 데이터, 힙 영역)을 공유합니다.
    • 각 스레드는 고유한 실행 흐름(Program Counter), 레지스터 세트, 스택을 가집니다.
    • 메모리 공간을 공유하기 때문에 스레드 간 통신(Inter-Thread Communication)은 프로세스 간 통신보다 훨씬 효율적입니다. 하지만 동시에 공유 자원에 접근할 때 문제가 발생할 수 있어 동기화(Synchronization) 메커니즘이 필요합니다.
왜 멀티스레딩인가?
  • 병렬성(Parallelism): 다중 코어 CPU 환경에서 여러 스레드가 동시에 다른 코어에서 실행되어 실제 병렬 처리를 통해 작업 완료 시간을 단축할 수 있습니다.
  • 응답성(Responsiveness): 긴 작업을 백그라운드 스레드에서 처리하는 동안, 메인 스레드는 사용자 인터페이스를 계속 업데이트하여 프로그램이 멈춘 것처럼 보이지 않게 합니다.
  • 자원 활용 효율성: I/O 작업(네트워크 통신, 파일 읽기/쓰기)처럼 CPU를 사용하지 않고 기다려야 하는 시간에 다른 스레드가 CPU를 사용하여 전체적인 자원 활용도를 높일 수 있습니다.

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

C++11부터 <thread> 헤더에 정의된 std::thread 클래스를 사용하여 새로운 스레드를 생성할 수 있습니다.

std::thread 객체를 생성할 때, 새로운 스레드에서 실행될 함수(또는 함수 객체, 람다)와 해당 함수의 인자들을 생성자의 인자로 전달합니다.

std::thread t(실행_가능_객체, 인자1, 인자2, ...);
  • 실행_가능_객체: 함수 포인터, 함수 객체(Functor), 람다 표현식 등이 될 수 있습니다.
  • 인자1, 인자2, ...: 실행_가능_객체에 전달될 인자들입니다. 이 인자들은 기본적으로 decay한 값으로 복사 또는 이동되어 스레드로 전달됩니다. 만약 참조로 전달하고 싶다면 std::ref()를 사용해야 합니다.
함수 포인터로 스레드 생성 예시
#include <iostream>
#include <thread> // std::thread를 위해
#include <chrono> // std::chrono::seconds를 위해
#include <string> // std::string을 위해

// 새로운 스레드에서 실행될 함수 (인자 없음)
void workerFunctionNoArgs() {
    std::cout << "Worker thread (no args) started.\n";
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 2초 대기
    std::cout << "Worker thread (no args) finished.\n";
}

// 새로운 스레드에서 실행될 함수 (인자 있음)
void workerFunctionWithArgs(int id, const std::string& message) {
    std::cout << "Worker thread " << id << " received message: " << message << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Worker thread " << id << " finished.\n";
}

int main() {
    std::cout << "Main thread started.\n";

    // 1. 인자 없는 함수로 스레드 생성
    std::thread t1(workerFunctionNoArgs);

    // 2. 인자 있는 함수로 스레드 생성
    // 인자는 값으로 복사되어 전달됨.
    std::thread t2(workerFunctionWithArgs, 1, "Hello from main!");

    std::cout << "Main thread doing some work...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));

    // 스레드 t1과 t2가 작업을 마칠 때까지 기다림
    // 중요: join() 또는 detach() 중 하나는 반드시 호출해야 합니다!
    t1.join(); // t1 스레드가 종료될 때까지 메인 스레드 대기
    t2.join(); // t2 스레드가 종료될 때까지 메인 스레드 대기

    std::cout << "Main thread finished. All worker threads joined.\n";
    return 0;
}

스레드 인자 전달: 값 vs. 참조

스레드 함수에 인자를 전달할 때 주의해야 할 점이 있습니다.

std::thread의 생성자에 전달되는 인자들은 기본적으로 decay한 값으로 복사 또는 이동됩니다.

문제점
  • 큰 객체 복사: 객체의 크기가 크면 복사 비용이 많이 들 수 있습니다.
  • 참조 전달 불가: 함수가 참조 매개변수를 받는다면, 단순히 변수 이름만 넘겨서는 참조로 전달되지 않습니다. 임시 객체가 생성되어 복사본이 전달될 수 있습니다.

해결책: std::ref() 사용: 인자를 참조로 전달하고 싶다면 std::ref() 또는 std::cref() (const 참조) 래퍼를 사용해야 합니다.

참조로 인자 전달 예시
#include <chrono>
#include <iostream>
#include <thread>
#include <string>
#include <vector> // std::vector를 위해
#include <functional> // std::ref를 위해

// 참조로 int를 수정하는 함수
void modifyValue(int& value) {
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    value += 100;
    std::cout << "Inside thread: value after modification = " << value << std::endl;
}

// 참조로 벡터에 추가하는 함수
void addElementToVector(std::vector<int>& vec, int element) {
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    vec.push_back(element);
    std::cout << "Inside thread: vector size = " << vec.size() << std::endl;
}

int main() {
    std::cout << "Main thread started.\n";

    int my_int = 10;
    std::cout << "Original my_int: " << my_int << std::endl;

    // my_int를 참조로 스레드에 전달
    std::thread t1(modifyValue, std::ref(my_int)); // std::ref 사용

    std::vector<int> my_vec = {1, 2, 3};
    std::cout << "Original my_vec size: " << my_vec.size() << std::endl;

    // my_vec을 참조로 스레드에 전달
    std::thread t2(addElementToVector, std::ref(my_vec), 4); // std::ref 사용

    t1.join();
    t2.join();

    std::cout << "After t1 joined: my_int = " << my_int << std::endl; // my_int가 변경되었음을 확인
    std::cout << "After t2 joined: my_vec size = " << my_vec.size() << std::endl; // my_vec이 변경되었음을 확인

    std::cout << "Main thread finished.\n";
    return 0;
}

주의: 참조로 인자를 전달할 때는 댕글링 참조(Dangling Reference) 문제에 매우 유의해야 합니다.

스레드가 참조하는 객체가 스레드 실행이 끝나기 전에 소멸되지 않도록 객체의 생명 주기를 잘 관리해야 합니다.

예를 들어, 지역 변수를 참조로 전달하고 스레드가 그 변수가 소멸된 후에도 접근하려고 하면 정의되지 않은 동작이 발생합니다.


값 인자와 참조 인자를 작은 수로 비교하기

긴 작업 없이, 스레드 생성 시 저장한 값과 원본 객체를 구별해 보겠습니다. 아래 코드는 독립된 완전한 프로그램입니다.

복사된 인자와 join 뒤의 원본 값
#include <functional>
#include <iostream>
#include <thread>

int main() {
    int n = 10;
    std::thread copied([](int value) {
        std::cout << "copy=" << value + 100 << '\n';
    }, n);
    n = 20;
    copied.join();
    std::cout << "original=" << n << '\n';

    std::thread referred([](int& value) {
        value += 100;
    }, std::ref(n));
    referred.join();
    std::cout << "after ref=" << n << '\n';
}

첫 스레드는 생성 시점의 10을 자체 인자로 받으므로 copy=110을 출력합니다. 메인의 n = 20은 그 복사본을 바꾸지 않습니다. join 뒤에는 original=20, 참조 인자를 받은 둘째 스레드까지 join한 뒤에는 after ref=120이 나옵니다. 둘째 스레드가 수정하는 동안 메인은 n을 읽지 않으며, 지역 변수는 두 스레드가 끝날 때까지 살아 있습니다.

독립적으로 값과 소유 의무 판단하기

같은 프로그램에서 처음 값을 7, 메인이 대입하는 값을 11, 두 스레드의 증가량을 모두 3으로 바꿨다고 하겠습니다. 세 출력값은 무엇인가요? copied.join()이 끝난 뒤 작업은 완료됐다는 사실과 copied.joinable()의 결과도 구별해 답해 보세요. 별도 상황으로, 실행은 끝났지만 아직 join하지 않은 t를 std::thread u(std::move(t))로 이동했다면 어느 객체에 소멸 전 의무가 남나요?

풀이 확인

복사 인자는 7이므로 copy=10, 원본은 original=11, 참조 수정 뒤에는 after ref=14입니다. 성공한 join 뒤 copied.joinable()은 false입니다. 이미 실행이 끝났더라도 join 전의 객체는 joinable일 수 있으며, 이동 뒤에는 t가 비joinable이고 u가 그 실행 흐름의 소유 의무를 이어받습니다. u도 성공한 join 등으로 의무를 해소해야 합니다.

스레드 종료 대기: join()

std::thread::join() 함수는 해당 스레드가 종료될 때까지 호출한 스레드(보통 메인 스레드)의 실행을 블록(block)합니다.

이는 부모 스레드가 자식 스레드의 완료를 기다려야 할 때 사용됩니다.

join()이 호출된 후에는 스레드 객체가 더 이상 스레드 실행 흐름과 연관되지 않으므로, 다시 join()을 호출할 수 없습니다.

소유권 의무: joinable인 std::thread 객체는 소멸 전에 join·detach하거나 다른 객체로 이동해 의무를 넘겨야 합니다. 기본 생성되거나 이미 이동된 비joinable 객체는 그대로 소멸할 수 있습니다.

그렇지 않으면 프로그램이 std::terminate를 호출하며 비정상 종료될 수 있습니다.


스레드 분리: detach()

std::thread::detach() 함수는 스레드를 생성한 std::thread 객체와 스레드의 실행 흐름을 분리(detach)합니다.

분리된 스레드는 해당 std::thread 객체로 join할 수 없게 됩니다. 표준의 별도 “데몬 스레드” 종류로 바뀌는 것은 아닙니다.

분리된 스레드는 백그라운드에서 실행되며, 프로그램이 종료될 때까지 계속 실행되거나, 완료되면 자동으로 자원이 정리됩니다.

사용 시 주의: detach()된 스레드는 더 이상 제어할 수 없습니다.

예를 들어, join()을 호출하여 스레드의 완료를 기다릴 수 없습니다.

detach()된 스레드가 여전히 부모 스레드의 자원에 접근하려고 할 경우 댕글링 참조 문제가 발생할 가능성이 높습니다.

join()과 detach() 사용 예시
#include <iostream>
#include <thread>
#include <chrono>

void independentWorker() {
    std::cout << "Independent worker thread started.\n";
    std::this_thread::sleep_for(std::chrono::seconds(3));
    std::cout << "Independent worker thread finished.\n";
}

void joinableWorker() {
    std::cout << "Joinable worker thread started.\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Joinable worker thread finished.\n";
}

int main() {
    std::cout << "Main thread started.\n";

    // 1. detach() 스레드
    std::thread detached_thread(independentWorker);
    detached_thread.detach(); // 스레드를 분리

    std::cout << "Main thread created detached thread.\n";

    // 2. join() 스레드
    std::thread joinable_thread(joinableWorker);
    std::cout << "Main thread created joinable thread.\n";

    std::cout << "Main thread doing brief work...\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(500));

    // detach()된 스레드는 더 이상 joinable() 하지 않음
    if (detached_thread.joinable()) {
        std::cout << "Detached thread is joinable (this won't print).\n";
    } else {
        std::cout << "Detached thread is not joinable.\n"; // 출력됨
    }

    // joinable() 확인 후 join()
    if (joinable_thread.joinable()) {
        std::cout << "Main thread waiting for joinable thread to finish...\n";
        joinable_thread.join(); // joinable_thread가 끝날 때까지 대기
        std::cout << "Joinable thread joined.\n";
    } else {
        std::cout << "Joinable thread is not joinable (this won't print).\n";
    }

    // 만약 detached_thread가 main 함수보다 더 오래 실행될 경우
    // main 함수 종료 시 프로그램이 종료되면서 detached_thread도 강제 종료될 수 있음.
    // 이는 detached_thread 내에서 자원 정리 등에 문제가 될 수 있으므로,
    // detach는 매우 신중하게 사용해야 함.
    std::cout << "Main thread finished.\n";
    return 0;
}


기타 스레드 관련 함수

  • std::this_thread::get_id(): 현재 실행 중인 스레드의 고유 ID를 반환합니다.
  • std::this_thread::sleep_for(duration): 현재 스레드를 지정된 시간 동안 일시 중지시킵니다.
  • std::this_thread::yield(): 스케줄러에게 다른 실행 기회를 양보하는 힌트를 줍니다. 특정 스레드 실행이나 동기화를 보장하지 않습니다.

std::thread 관리는 인자 전달, join, detach의 수명 책임을 한 번에 정하는 일입니다.

스레드를 시작했다면 누가 언제 끝을 책임지는지 코드에서 드러나야 합니다.