icon
15장 : 멀티스레딩 기초

스레드 생성과 관리

지금까지 C++의 강력한 기능들을 통해 단일 스레드(Single-threaded) 환경에서의 프로그래밍 기법들을 깊이 있게 학습했습니다.

이제는 현대 컴퓨팅 환경에서 필수적인 개념인 멀티스레딩(Multithreading) 의 세계로 발을 들여놓을 차례입니다.

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

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

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

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


프로세스와 스레드 (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, ...: 실행_가능_객체에 전달될 인자들입니다. 이 인자들은 기본적으로 값으로 복사되어 스레드로 전달됩니다. 만약 참조로 전달하고 싶다면 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의 생성자에 전달되는 인자들은 기본적으로 값으로 복사됩니다.

문제점

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

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

참조로 인자 전달 예시
#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()

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

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

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

필수 사용: std::thread 객체가 소멸되기 전에 반드시 join() 또는 detach() 둘 중 하나는 호출해야 합니다. 그렇지 않으면 프로그램이 std::terminate를 호출하며 비정상 종료될 수 있습니다.


스레드 분리: detach()

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

분리된 스레드는 데몬 스레드(Daemon Thread) 가 되며, 더 이상 생성자 스레드에 의해 관리되지 않고 독립적으로 실행됩니다.

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

사용 시 주의: 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(): 현재 스레드의 실행을 일시 중지하고, 다른 스레드가 실행될 기회를 제공합니다.