본문 바로가기
개발/C++ (98,03,11,14,17,20,23)

Modern C++ : std::mutex (11, 14, 17)

by snowoods 2025. 10. 18.

Modern C++

 

std::mutex

 

개요

std::mutex는 "Mutual Exclusion"의 약자로, 여러 스레드가 동시에 공유 자원(shared resource)에 접근하는 것을 막기 위한 동기화 메커니즘입니다. 코드의 특정 영역을 임계 영역(critical section)으로 지정하여, 한 번에 하나의 스레드만 해당 영역의 코드를 실행할 수 있도록 보장합니다. 이를 통해 데이터 경쟁(data race)을 방지하고 프로그램의 안정성을 높일 수 있습니다.

 

C++ 버전별 주요 키워드 도입 시기

  • C++11: std::mutex, std::lock_guard, std::unique_lock 등 기본적인 뮤텍스 기능이 도입되었습니다.
  • C++14: std::shared_timed_mutex가 추가되어 읽기-쓰기 잠금(read-write lock)을 구현할 수 있게 되었습니다.
  • C++17: std::scoped_lock이 추가되어 여러 뮤텍스를 데드락(deadlock) 발생 위험 없이 안전하게 잠글 수 있는 기능을 제공합니다.

 

내용 설명

경쟁 상태 (Race Condition)

여러 스레드가 동일한 메모리 위치(공유 자원)에 동시에 접근하여 읽거나 쓰려고 할 때 경쟁 상태가 발생할 수 있습니다. 특히 하나 이상의 스레드가 데이터를 수정하는 경우, 연산이 원자적(atomic)으로 처리되지 않아 예기치 않은 결과가 발생할 수 있습니다.

예를 들어 worker1 함수에서 ++global_counter 연산은 단일 기계어 명령이 아닐 수 있습니다. 일반적으로 아래와 같은 3단계로 나뉩니다.

  1. 메모리에서 global_counter 값을 레지스터로 읽어온다. (Read)
  2. 레지스터의 값을 1 증가시킨다. (Modify)
  3. 레지스터의 값을 다시 메모리에 쓴다. (Write)

두 개의 스레드가 이 연산을 거의 동시에 수행하면, 한 스레드의 변경 사항이 다른 스레드에 의해 덮어씌워져 카운트가 누락될 수 있습니다. 그 결과 global_counter의 최종 값은 NUM_THREADS * NUM_INCREMENTS보다 작아지게 됩니다.

std::mutexstd::lock_guard

std::mutex는 이러한 경쟁 상태를 해결하기 위해 사용됩니다. 공유 자원에 접근하는 코드 블록(임계 영역)의 시작과 끝에서 lock()unlock()을 호출하여 해당 영역을 보호합니다.

하지만 unlock()을 직접 호출하는 방식은 프로그래머가 호출을 잊거나, 코드 중간에 예외가 발생했을 때 잠금이 해제되지 않는 문제를 야기할 수 있습니다. 이러한 문제를 해결하기 위해 C++에서는 RAII(Resource Acquisition Is Initialization) 패턴을 활용하는 std::lock_guard를 제공합니다.

std::lock_guard는 생성자에서 전달받은 mutex를 자동으로 잠그고, 객체가 소멸될 때(스코프를 벗어날 때) 소멸자에서 자동으로 잠금을 해제합니다. 이를 통해 코드가 더 안전하고 간결해집니다.

worker2 함수는 std::lock_guard를 사용하여 global_counter를 안전하게 업데이트하는 방법을 보여줍니다. 각 스레드는 먼저 로컬 변수 local_counter를 증가시킨 뒤, 뮤텍스로 보호된 임계 영역에 진입하여 global_counter에 한 번만 더해줍니다. 이는 뮤텍스 잠금 시간을 최소화하여 성능을 향상시키는 좋은 방법입니다.

 

예제 코드

#include <array>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <numeric>
#include <thread>

namespace
{
constexpr auto NUM_THREADS = std::uint32_t{20U};
constexpr auto NUM_INCREMENTS = std::uint32_t{100'000U};
}; // namespace

auto global_counter = std::int32_t{0};

// 경쟁 상태를 유발하는 워커 함수 (사용되지 않음)
void worker1(const std::int32_t input, std::int32_t &output)
{
    output = input * 2;

    for (std::uint32_t i = 0; i < NUM_INCREMENTS; ++i)
    {
        ++global_counter; // 데이터 경쟁 발생 지점
    }
}

auto mutex = std::mutex{};

// std::lock_guard로 임계 영역을 보호하는 워커 함수
void worker2(const std::int32_t input, std::int32_t &output)
{
    output = input * 2;

    auto local_counter = std::uint32_t{0U};

    for (std::uint32_t i = 0; i < NUM_INCREMENTS; ++i)
    {
        ++local_counter;
    }

    // lock_guard가 생성되면서 mutex가 잠김
    auto guard = std::lock_guard<std::mutex>{mutex};
    global_counter += local_counter;
    // guard가 스코프를 벗어나면서 소멸되고, mutex가 자동으로 해제됨
}

int main()
{
    auto inputs = std::array<std::int32_t, NUM_THREADS>{};
    std::iota(inputs.begin(), inputs.end(), 0);
    auto outputs = std::array<std::int32_t, NUM_THREADS>{};
    std::fill(outputs.begin(), outputs.end(), 0);

    std::array<std::thread, NUM_THREADS> threads;
    for (std::uint32_t i = 0; i < NUM_THREADS; ++i)
    {
        threads[i] = std::thread(worker2, inputs[i], std::ref(outputs[i]));
    }

    for (std::uint32_t i = 0; i < NUM_THREADS; ++i)
    {
        threads[i].join();
    }

    for (std::uint32_t i = 0; i < NUM_THREADS; ++i)
    {
        std::cout << "Outputs[" << i << "] = " << outputs[i] << '\n';
    }

    std::cout << "Global counter = " << global_counter << '\n';
    assert(global_counter == NUM_THREADS * NUM_INCREMENTS);

    return 0;
}

 

실행 결과

Outputs[0] = 0
Outputs[1] = 2
Outputs[2] = 4
Outputs[3] = 6
Outputs[4] = 8
Outputs[5] = 10
Outputs[6] = 12
Outputs[7] = 14
Outputs[8] = 16
Outputs[9] = 18
Outputs[10] = 20
Outputs[11] = 22
Outputs[12] = 24
Outputs[13] = 26
Outputs[14] = 28
Outputs[15] = 30
Outputs[16] = 32
Outputs[17] = 34
Outputs[18] = 36
Outputs[19] = 38
Global counter = 2000000

std::mutex를 사용하여 global_counter에 대한 접근을 동기화했기 때문에, 최종 결과는 정확히 20 * 100,000 = 2,000,000이 되며 assert 문이 성공적으로 통과합니다.

 

활용팁

  • 잠금 범위 최소화: 뮤텍스는 성능에 영향을 줄 수 있으므로, 임계 영역의 범위를 최대한 작게 유지하는 것이 중요합니다. 예제 코드의 worker2처럼, 동기화가 필요 없는 계산은 잠금 영역 밖에서 수행하고 꼭 필요한 부분만 잠그는 것이 좋습니다.
  • std::lock_guard 사용 생활화: unlock()을 직접 호출하기보다 std::lock_guard를 사용하여 잠금 해제를 자동화하면 실수를 줄이고 예외 안전성을 높일 수 있습니다.
  • 데드락 주의: 두 개 이상의 뮤텍스를 사용할 때는 데드락이 발생할 수 있습니다. 항상 같은 순서로 뮤텍스를 잠그거나, C++17 이상이라면 std::scoped_lock을 사용하여 여러 뮤텍스를 한 번에 안전하게 잠그는 것을 권장합니다.
  • std::mutex는 복사/이동 불가: std::mutex 객체는 복사나 이동이 불가능합니다. 클래스 멤버로 사용할 경우 포인터나 참조로 관리해야 할 수 있습니다.