icon
15장 : 멀티스레딩 기초

뮤텍스와 락

지난 장에서 std::thread를 사용하여 여러 실행 흐름(스레드)을 생성하고 관리하는 기본적인 방법을 학습했습니다.

이제 프로그램이 여러 스레드를 통해 동시에 실행될 때 발생하는 가장 흔하고 위험한 문제 중 하나인 경쟁 조건(Race Condition) 과 이를 해결하기 위한 동기화(Synchronization) 메커니즘인 뮤텍스(Mutex)락(Lock) 에 대해 심층적으로 다루겠습니다.


경쟁 조건 (Race Condition)

경쟁 조건은 여러 스레드가 동시에 공유 자원(변수, 데이터 구조, 파일 등)에 접근하여 변경하려고 할 때 발생하는 문제입니다.

스레드들의 실행 순서가 예측 불가능하기 때문에, 최종 결과가 스레드의 실행 순서에 따라 달라질 수 있습니다.

이는 논리적 오류이며, 디버깅하기 매우 어렵습니다.

경쟁 조건 예시
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

volatile int counter = 0; // 여러 스레드가 공유할 전역 변수 (volatile은 최적화 방지)

void increment_counter() {
    for (int i = 0; i < 100000; ++i) {
        counter++; // 이 부분에서 경쟁 조건 발생
    }
}

int main() {
    std::cout << "Initial counter value: " << counter << std::endl;

    // 10개의 스레드를 생성하여 counter를 10만 번씩 증가
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter);
    }

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

    std::cout << "Final counter value: " << counter << std::endl;
    // 예상 값: 10 * 100000 = 1,000,000
    // 실제 출력 값: 1,000,000보다 작을 가능성이 높음 (매번 다를 수 있음)

    return 0;
}

실행 결과 분석: 예상 값은 1,000,000이지만, 실제 실행해보면 매번 다른 값이 나오거나 1,000,000보다 작은 값이 나올 것입니다. 왜 그럴까요? counter++ 연산은 실제로는 여러 단계로 이루어집니다.

  1. counter의 현재 값을 레지스터로 로드.
  2. 레지스터의 값을 1 증가.
  3. 증가된 값을 다시 counter에 저장.

만약 스레드 A가 counter를 로드한 후 아직 저장하기 전에 스레드 B가 counter를 로드하고 증가시키면, 스레드 A의 증가된 값이 스레드 B의 증가된 값을 덮어쓰게 되어 갱신이 누락될 수 있습니다. 이를 손실된 갱신(Lost Update) 문제라고 합니다.


임계 영역 (Critical Section)

경쟁 조건이 발생하는 코드 영역, 즉 여러 스레드가 동시에 접근해서는 안 되는 공유 자원 접근 코드를 임계 영역(Critical Section) 이라고 합니다.

위의 예시에서 counter++가 임계 영역입니다.

임계 영역에는 한 번에 하나의 스레드만 접근하도록 보장해야 합니다.


뮤텍스 (Mutex)

뮤텍스(Mutex)Mutual Exclusion(상호 배제) 의 약자로, 임계 영역에 대한 접근을 제어하여 경쟁 조건을 방지하는 가장 기본적인 동기화 도구입니다.

뮤텍스는 잠금(lock)과 해제(unlock) 기능을 제공합니다.

작동 방식

  1. 스레드가 임계 영역에 진입하기 전에 뮤텍스를 잠급니다(lock).
  2. 뮤텍스가 이미 잠겨 있다면, 해당 스레드는 뮤텍스가 해제될 때까지 대기합니다.
  3. 스레드가 임계 영역 작업을 마친 후 뮤텍스를 해제합니다(unlock).

C++ 표준 라이브러리는 <mutex> 헤더에 std::mutex를 제공합니다.

std::mutex를 이용한 경쟁 조건 해결
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex를 위해

int counter_mutex = 0; // 공유 변수
std::mutex mtx;        // 뮤텍스 객체

void increment_counter_safe() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();    // 임계 영역 진입 전에 뮤텍스 잠금
        counter_mutex++; // 임계 영역 (오직 하나의 스레드만 접근 가능)
        mtx.unlock();  // 임계 영역 벗어난 후 뮤텍스 해제
    }
}

int main() {
    std::cout << "Initial counter_mutex value: " << counter_mutex << std::endl;

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter_safe);
    }

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

    std::cout << "Final counter_mutex value: " << counter_mutex << std::endl;
    // 이제 예상 값인 1,000,000이 정확히 출력될 것입니다.

    return 0;
}

이제 counter_mutex의 최종 값은 항상 1,000,000이 될 것입니다.

뮤텍스가 임계 영역에 대한 접근을 직렬화(serialize)하여 한 번에 하나의 스레드만 counter_mutex++ 연산을 수행하도록 보장했기 때문입니다.


락 (Lock) - RAII 기반의 안전한 뮤텍스 관리

std::mutexlock()unlock()을 직접 호출하는 것은 매우 중요합니다.

만약 lock() 후에 unlock()을 호출하는 것을 잊거나, 임계 영역 내에서 예외가 발생하면 unlock()이 호출되지 않아 뮤텍스가 영구적으로 잠겨버리는 교착 상태(Deadlock) 나 프로그램 정지 상태에 빠질 수 있습니다.

이러한 문제점을 해결하기 위해 C++는 RAII(Resource Acquisition Is Initialization) 원칙을 기반으로 하는 락 클래스들을 제공합니다.

락 객체들은 생성자에서 뮤텍스를 잠그고, 소멸자에서 자동으로 해제합니다.

따라서 스코프를 벗어나거나 예외가 발생하여 스택 풀기(Stack Unwinding)가 일어나더라도 뮤텍스가 항상 안전하게 해제됨을 보장합니다.

주요 락 클래스는 다음과 같습니다.

  1. std::lock_guard

    • 가장 기본적인 RAII 락입니다.
    • 생성 시 뮤텍스를 잠그고, 소멸 시 잠금을 해제합니다.
    • 뮤텍스의 소유권을 이전하거나 잠금을 해제할 수 없습니다. (RAII 목적에 충실)
    • 단순한 임계 영역 보호에 가장 적합합니다.
  2. std::unique_lock

    • std::lock_guard보다 훨씬 더 유연합니다.
    • 뮤텍스를 잠그거나 해제하는 시점을 개발자가 제어할 수 있습니다 (lock(), unlock()).
    • 뮤텍스 소유권을 다른 unique_lock 객체로 이동(std::move)할 수 있습니다.
    • std::condition_variable과 함께 사용될 때 필수적입니다.
    • 약간의 오버헤드가 더 있습니다.
std::lock_guard를 이용한 안전한 뮤텍스 관리
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex, std::lock_guard를 위해

int counter_lock_guard = 0;
std::mutex mtx_guard;

void increment_counter_lock_guard() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx_guard); // 생성 시 mtx_guard 잠금
        counter_lock_guard++;
        // 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_guard 잠금 해제
    }
}

int main() {
    std::cout << "Initial counter_lock_guard value: " << counter_lock_guard << std::endl;

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter_lock_guard);
    }

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

    std::cout << "Final counter_lock_guard value: " << counter_lock_guard << std::endl;
    // 항상 1,000,000이 출력될 것입니다.

    return 0;
}

std::lock_guardunlock()을 명시적으로 호출할 필요가 없어 코드가 간결하고, 예외 안전성을 보장합니다.

std::unique_lock을 이용한 뮤텍스 관리
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx_unique;

void process_data(int id) {
    std::unique_lock<std::mutex> lock(mtx_unique); // 생성 시 잠금
    std::cout << "Thread " << id << " acquired lock.\n";
    // 필요한 경우, 잠금을 일시적으로 해제하고 다른 작업을 수행할 수 있음
    lock.unlock(); // 수동으로 잠금 해제

    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 다른 스레드에게 기회

    lock.lock(); // 다시 잠금
    std::cout << "Thread " << id << " re-acquired lock.\n";
    // 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_unique 잠금 해제
}

int main() {
    std::thread t1(process_data, 1);
    std::thread t2(process_data, 2);

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

    return 0;
}

std::unique_lockstd::condition_variable과 함께 사용될 때 더욱 강력해집니다.


다양한 종류의 뮤텍스 및 락

C++11/14/17은 다양한 동기화 시나리오를 위한 추가 뮤텍스 및 락을 제공합니다.

  • std::timed_mutex / std::recursive_mutex

    • std::timed_mutex: try_lock_for() (시간 제한), try_lock_until() (특정 시점까지)을 통해 일정 시간 동안만 잠금을 시도할 수 있습니다.
    • std::recursive_mutex: 동일 스레드 내에서 여러 번 잠글 수 있는 뮤텍스입니다. 일반적으로는 권장되지 않으며, 재귀적 잠금이 필요하다면 디자인을 재고하는 것이 좋습니다.
  • std::shared_mutex (C++17):

    • 읽기/쓰기 락(Read-Write Lock) 또는 공유/배타적 락(Shared/Exclusive Lock)이라고도 합니다.
    • 여러 스레드가 동시에 읽기 접근을 허용하지만(공유 락), 쓰기 접근은 한 번에 하나의 스레드만 허용합니다(배타적 락).
    • 읽기 작업이 쓰기 작업보다 훨씬 많은 시나리오에서 성능 향상에 도움이 됩니다.
  • std::shared_lock (C++14):

    • std::shared_mutex와 함께 사용하여 공유(읽기) 락을 획득할 때 사용합니다.
std::shared_mutex와 std::shared_lock을 이용한 읽기/쓰기 예시
#include <iostream>
#include <thread>
#include <vector>
#include <shared_mutex> // std::shared_mutex, std::shared_lock을 위해
#include <chrono>

int shared_data = 0;
std::shared_mutex shared_mtx; // 공유 뮤텍스

void reader(int id) {
    for (int i = 0; i < 5; ++i) {
        std::shared_lock<std::shared_mutex> lock(shared_mtx); // 읽기 락 획득 (여러 스레드 동시 가능)
        std::cout << "Reader " << id << ": " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

void writer(int id) {
    for (int i = 0; i < 2; ++i) {
        std::unique_lock<std::shared_mutex> lock(shared_mtx); // 쓰기 락 획득 (단독 접근)
        shared_data++;
        std::cout << "Writer " << id << ": Increment to " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main() {
    std::vector<std::thread> readers;
    for (int i = 0; i < 3; ++i) {
        readers.emplace_back(reader, i + 1);
    }

    std::vector<std::thread> writers;
    for (int i = 0; i < 2; ++i) {
        writers.emplace_back(writer, i + 1);
    }

    for (auto& t : readers) t.join();
    for (auto& t : writers) t.join();

    std::cout << "Final shared_data: " << shared_data << std::endl;
    return 0;
}

이 예시에서는 여러 reader 스레드가 동시에 shared_data를 읽을 수 있지만, writer 스레드는 단독으로 접근하여 shared_data를 수정합니다.


데드락 (Deadlock)

데드락(Deadlock) 은 두 개 이상의 스레드가 서로가 점유하고 있는 자원을 얻기 위해 영원히 대기하는 상태를 말합니다.

가장 흔한 시나리오는 두 스레드가 서로 다른 뮤텍스를 잠그고, 상대방이 가진 뮤텍스를 기다리는 경우입니다.

데드락 발생 조건

  1. 상호 배제 (Mutual Exclusion): 자원은 한 번에 하나의 스레드만 사용할 수 있습니다.
  2. 점유 및 대기 (Hold and Wait): 자원을 점유한 상태에서 다른 자원을 기다립니다.
  3. 비선점 (No Preemption): 자원을 강제로 뺏을 수 없습니다.
  4. 순환 대기 (Circular Wait): 스레드들이 자원을 순환적으로 대기합니다 (A가 B를 기다리고, B가 A를 기다리는 등).

데드락 방지/해결 전략

  • 잠금 순서 일치: 모든 스레드가 동일한 순서로 뮤텍스를 잠그도록 합니다. (가장 효과적)
  • std::lock() 사용: 여러 뮤텍스를 동시에 잠글 때 std::lock(mtx1, mtx2, ...)를 사용하면 데드락 없이 모든 뮤텍스를 잠그거나, 모두 잠그지 않습니다(all-or-nothing).
  • std::unique_lock + std::defer_lock: unique_lock을 생성하되 바로 잠그지 않고, std::lock()과 함께 사용합니다.
  • 타임아웃 잠금: try_lock_for(), try_lock_until()을 사용하여 일정 시간 동안만 잠금을 시도하고, 실패하면 다른 작업을 수행하거나 재시도합니다.
  • 계층적 잠금: 자원에 우선순위를 부여하여 낮은 우선순위 자원을 잠그기 전에 높은 우선순위 자원을 먼저 잠그도록 합니다.
std::lock()을 이용한 데드락 방지 예시
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

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

void thread_func1() {
    // std::lock(mtx1, mtx2)는 두 뮤텍스를 동시에 잠그려고 시도하며,
    // 데드락 없이 안전하게 잠금을 획득하거나 실패합니다.
    // lock_guard를 사용하여 자동으로 해제되도록 defer_lock을 사용합니다.
    std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);
    std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);

    std::lock(lk1, lk2); // 두 뮤텍스를 동시에 잠금 시도 (데드락 방지)

    std::cout << "Thread 1: Acquired mtx1 and mtx2.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
    std::cout << "Thread 1: Released mtx1 and mtx2.\n";
} // 스코프 벗어나면 lk1, lk2 소멸하며 자동 해제

void thread_func2() {
    std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);
    std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);

    std::lock(lk2, lk1); // 두 뮤텍스를 동시에 잠금 시도

    std::cout << "Thread 2: Acquired mtx2 and mtx1.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
    std::cout << "Thread 2: Released mtx2 and mtx1.\n";
} // 스코프 벗어나면 lk2, lk1 소멸하며 자동 해제

int main() {
    std::cout << "Starting threads...\n";
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

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

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

만약 thread_func1thread_func2에서 std::lock() 대신 mtx1.lock(); mtx2.lock();과 같이 순서대로 잠그려 했다면, 데드락이 발생할 가능성이 매우 높습니다.

std::lock()은 이러한 위험을 줄여줍니다.