프로그래밍/C++

C++ 멀티스레딩: 스레드 생성, 뮤텍스, 조건 변수를 활용한 동시성 프로그래밍

shimdh 2025. 2. 3. 10:36
728x90

1. 스레드 생성 및 관리

1.1 스레드란 무엇인가?

스레드는 프로세스 내에서 실행되는 경량 프로세스로, 독립적으로 실행될 수 있는 코드의 흐름입니다. 스레드는 같은 메모리 공간을 공유하므로 데이터 교환이 용이하지만, 각 스레드는 별도의 호출 스택과 레지스터를 가집니다. 이는 스레드가 독립적으로 실행될 수 있도록 해줍니다.

1.2 스레드의 장점과 단점

  • 장점:
    • 병렬 처리: 여러 작업을 동시에 처리하여 성능을 향상시킬 수 있습니다.
    • 자원 공유: 같은 프로세스 내의 스레드는 메모리와 파일 디스크립터 등을 공유하므로 데이터 교환이 쉽습니다.
  • 단점:
    • 동기화 문제: 여러 스레드가 동일한 자원에 접근할 경우 데이터 경합이 발생할 수 있습니다.
    • 디버깅 어려움: 스레드 간의 상호작용이 복잡해질수록 디버깅이 어려워집니다.

1.3 C++에서 스레드 생성하기

C++11부터 <thread> 헤더를 사용하여 스레드를 생성할 수 있습니다. 아래는 간단한 스레드 생성 예제입니다.

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    // 새 스레드 생성
    std::thread t(hello);

    // 메인 스레드가 종료되기 전에 t가 완료되도록 대기
    t.join();

    return 0;
}
  • std::thread t(hello): hello 함수를 새로운 스레드에서 실행합니다.
  • t.join(): 메인 스레드가 t 스레드가 종료될 때까지 기다립니다. 이를 통해 스레드의 실행이 완료될 때까지 프로그램이 종료되지 않도록 합니다.

1.4 여러 개의 스레드 생성하기

여러 작업을 동시에 수행하려면 여러 개의 스레드를 생성할 수 있습니다.

#include <iostream>
#include <thread>
#include <vector>

void print_id(int id) {
    std::cout << "Thread ID: " << id << std::endl;
}

int main() {
    const int num_threads = 5; // 사용할 스레드 개수
    std::vector<std::thread> threads;

    for (int i = 0; i < num_threads; ++i) {
        threads.emplace_back(print_id, i); // 각 스레드에 고유한 ID 전달
    }

    for (auto& th : threads) {
        th.join(); // 모든 스레드가 끝날 때까지 대기
    }

    return 0;
}
  • threads.emplace_back(print_id, i): print_id 함수를 각 스레드에서 실행하며, 고유한 ID를 전달합니다.
  • th.join(): 모든 스레드가 종료될 때까지 메인 스레드가 대기합니다.

1.5 스레드의 생명주기 관리

스레드는 생성, 실행, 종료의 세 단계를 거칩니다. 스레드가 종료되면 해당 스레드의 리소스는 해제됩니다. 그러나 스레드가 종료되지 않은 상태에서 프로그램이 종료되면 리소스 누수가 발생할 수 있으므로, join() 또는 detach()를 통해 스레드의 생명주기를 관리해야 합니다.

  • join(): 스레드가 종료될 때까지 기다립니다.
  • detach(): 스레드를 독립적으로 실행시킵니다. 이 경우 스레드는 메인 스레드와 별개로 실행됩니다.

예제: detach() 사용하기

#include <iostream>
#include <thread>
#include <chrono>

void background_task() {
    for (int i = 0; i < 5; ++i) {
        std::cout << "Background task is running... " << i << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main() {
    std::thread t(background_task);
    t.detach(); // 스레드를 독립적으로 실행

    std::cout << "Main thread continues to run..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(3)); // 메인 스레드 대기

    return 0;
}
  • t.detach(): 스레드를 독립적으로 실행시킵니다. 메인 스레드는 t 스레드의 종료를 기다리지 않습니다.

2. 뮤텍스와 잠금

2.1 뮤텍스란 무엇인가?

뮤텍스(Mutex)는 "Mutual Exclusion"의 약자로, 한 번에 하나의 스레드만 특정 코드 블록이나 자원에 접근하도록 제한하는 도구입니다. 이를 통해 데이터 경합(data race)을 방지할 수 있습니다.

2.2 뮤텍스 사용 예제

아래는 뮤텍스를 사용하여 공유 자원을 보호하는 예제입니다.

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx; // 뮤텍스 객체 선언
int shared_counter = 0;

void increment_counter(int id) {
   mtx.lock(); // 뮤텍스를 잠금으로써 보호 시작

   ++shared_counter;
   std::cout << "Thread " << id << ": Counter = " << shared_counter << std::endl;

   mtx.unlock(); // 뮤텍스를 해제하여 다른 스레드가 접근 가능하도록 함
}

int main() {
   const int num_threads = 10;
   std::vector<std::thread> threads;

   for (int i = 0; i < num_threads; ++i) {
       threads.emplace_back(increment_counter, i);
   }

   for (auto& th : threads) {
       th.join();
   }

   return 0;
}
  • mtx.lock(): 뮤텍스를 잠급니다. 다른 스레드는 이 코드 블록에 접근할 수 없습니다.
  • mtx.unlock(): 뮤텍스를 해제합니다. 다른 스레드가 접근할 수 있도록 합니다.

2.3 RAII 패턴과 std::lock_guard

C++에서는 RAII(Resource Acquisition Is Initialization) 패턴을 사용하여 뮤텍스를 더 안전하게 관리할 수 있습니다. std::lock_guard는 생성자에서 뮤텍스를 잠그고 소멸자에서 자동으로 해제합니다.

void increment_counter(int id) {
    std::lock_guard<std::mutex> lock(mtx); // 자동으로 락이 걸리고 해제됨
    ++shared_counter;
    std::cout << "Thread " << id << ": Counter = " << shared_counter << std::endl;
}
  • std::lock_guard<std::mutex> lock(mtx): 뮤텍스를 자동으로 잠그고, 함수가 종료될 때 자동으로 해제합니다.

2.4 std::unique_lock의 활용

std::unique_lockstd::lock_guard보다 더 유연한 뮤텍스 관리 기능을 제공합니다. 예를 들어, 뮤텍스를 수동으로 잠그고 해제할 수 있으며, 조건 변수와 함께 사용할 때 유용합니다.

void increment_counter(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    ++shared_counter;
    std::cout << "Thread " << id << ": Counter = " << shared_counter << std::endl;
    lock.unlock(); // 수동으로 뮤텍스 해제
}

2.5 데드락(Deadlock) 방지

데드락은 두 개 이상의 스레드가 서로의 자원을 기다리며 무한히 대기하는 상태를 말합니다. 데드락을 방지하기 위해서는 다음과 같은 전략을 사용할 수 있습니다:

  1. 락 순서 정하기: 모든 스레드가 같은 순서로 락을 획득하도록 합니다.
  2. 타임아웃 설정: 락을 획득할 때 타임아웃을 설정하여 무한 대기를 방지합니다.
  3. 락 계층 구조 사용: 락의 계층 구조를 정의하여 데드락 가능성을 줄입니다.

예제: 데드락 방지

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx1;
std::mutex mtx2;

void task1() {
    std::lock(mtx1, mtx2); // 동시에 두 뮤텍스를 잠금
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::cout << "Task 1 is running..." << std::endl;
}

void task2() {
    std::lock(mtx1, mtx2); // 동시에 두 뮤텍스를 잠금
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::cout << "Task 2 is running..." << std::endl;
}

int main() {
    std::thread t1(task1);
    std::thread t2(task2);

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

    return 0;
}
  • std::lock(mtx1, mtx2): 두 뮤텍스를 동시에 잠급니다. 이를 통해 데드락을 방지할 수 있습니다.

3. 조건 변수

3.1 조건 변수란 무엇인가?

조건 변수(Condition Variable)는 특정 조건이 충족될 때까지 스레드를 대기시키고, 다른 스레드가 그 조건을 만족시켰을 때 기다리고 있는 스레드를 깨울 수 있도록 도와주는 동기화 도구입니다.

3.2 조건 변수 사용 예제

아래는 생산자-소비자 문제를 해결하기 위해 조건 변수를 사용하는 예제입니다.

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> buffer;
const unsigned int maxBufferSize = 5;
std::mutex mtx;
std::condition_variable cv;

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::unique_lock<std::mutex> lock(mtx);

        // 버퍼가 가득 찼다면 대기
        cv.wait(lock, [] { return buffer.size() < maxBufferSize; });

        // 아이템 추가
        buffer.push(i);
        std::cout << "Produced: " << i << "\n";

        // 소비자에게 신호 보내기
        cv.notify_all();
    }
}

void consumer() {
    for (int i = 0; i < 10; ++i) {
        std::unique_lock<std::mutex> lock(mtx);

        // 버퍼가 비어있다면 대기
        cv.wait(lock, [] { return !buffer.empty(); });

        // 아이템 제거
        int item = buffer.front();
        buffer.pop();
        std::cout << "Consumed: " << item << "\n";

         // 생산자에게 신호 보내기
         cv.notify_all();
    }
}

int main() {
    std::thread prodThread(producer);
    std::thread consThread(consumer);

    prodThread.join();
    consThread.join();

    return 0;
}
  • cv.wait(lock, 조건): 조건이 충족될 때까지 스레드를 대기시킵니다.
  • cv.notify_all(): 대기 중인 모든 스레드에게 신호를 보냅니다.

3.3 조건 변수의 활용 사례

조건 변수는 주로 생산자-소비자 패턴, 스레드 풀, 이벤트 기반 프로그래밍 등에서 사용됩니다. 예를 들어, 작업 큐에 작업이 추가될 때까지 스레드를 대기시키고, 작업이 추가되면 스레드를 깨워 작업을 처리하는 방식으로 활용할 수 있습니다.

3.4 조건 변수의 고급 사용법

조건 변수는 복잡한 동기화 문제를 해결하는 데 매우 유용합니다. 예를 들어, 여러 조건을 동시에 확인하거나, 특정 조건이 충족될 때까지 스레드를 대기시키는 등의 고급 기법을 사용할 수 있습니다.

void complex_condition_example() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { 
        return buffer.size() < maxBufferSize && some_other_condition(); 
    });
    // 복잡한 조건을 만족할 때까지 대기
}

결론

C++에서 멀티스레딩은 성능 최적화와 효율성을 높이는 강력한 도구입니다. 스레드 생성, 뮤텍스를 통한 동기화, 조건 변수를 활용한 스레드 간 통신을 통해 복잡한 동시성 문제를 해결할 수 있습니다. 이러한 기법들을 적절히 활용하면 더욱 안정적이고 효율적인 프로그램을 작성할 수 있습니다. 추가적으로, 스레드 풀, 비동기 프로그래밍, 병렬 알고리즘 등 고급 주제로 나아가면 더욱 강력한 멀티스레딩 애플리케이션을 개발할 수 있습니다. 멀티스레딩은 복잡하지만, 그만큼 강력한 도구이므로 꾸준히 학습하고 실습해보는 것이 중요합니다.

728x90