본문 바로가기

진리는어디에/C++

[C++] 스레드 동기화 - mutex

들어가며

이번 포스트는 C++에서 스레드간 동기화를 위해 제공하는 std::mutex에 대해 자세히 알아 보도록 하겠다.

본 포스트에서는 mutex가 무엇인지 크리티컬 섹션이 무엇인지 같은 교과서적인 기본 내용을 다루기 보다는 C++에서 제공하는 std::mutex 의 특징과 사용법에 대해 집중하도록 한다.

스레드 동기화에 대한 원론적인 개념에 대해 궁금하신 분들은 Operating System Concepts 같은 전통적인 컴퓨터 공학 전공 서적을 살펴 보시거나 간단 하게는 나무위키의 뮤텍스 항목을 살펴 보는 것도 도움이 될 것이다.

C++ 표준이 제공하는 mutex 종류

C++ 표준 라이브러리에서는 아래와 같이 총 6가지의 뮤텍스를 제공하고 있다.

std::mutex(C++11) std::timed_mutex(C++11)
std::recursive_mutex(C++11) std::recursive_timed_mutex(C++11)
std::shared_mutex(C++17) std::shared_timed_mutex(C++14)

6가지 종류의 뮤텍스라고 하지만 실제 구분은 mutex, recursive_mutex, shared_mutex 이렇게 세 가지와 각각의 뮤텍스에 시간 제한 옵션이 추가된 "timed" 시리즈로 구분해 생각하면 쉽다. timed 시리즈 뮤텍스들은 기존 뮤텍스에 타임 아웃 옵션이 추가 된 것 뿐이므로 이 포스트에서는 mutex, recusive_mutex, shared_mutex의 차이를 알아 보는 것에 보다 집중하도록 하겠다.

std::mutex

먼저 C++ 스레드 동기화에 제일 자주 사용되는 std::mutex를 살펴 보도록 한다. std::mutex의 멤버 함수는 아래와 같다.

멤버 함수 설명
lock 뮤텍스를 잠근다. 만일 이미 잠겨 있다면 프로그램이 block 된다.
try_lock 뮤텍스를 잠그려 시도한다. 만일 이미 잠겨 있어 뮤텍스를 획득하지 못하면 false를 리턴한다.
unlock 뮤텍스의 잠금을 해제한다.
native_handle 각 운영 체제에 따른 뮤텍스 객체의 구분자를 리턴한다.

이해를 돕기 위해 위 멤버 함수들을 사용한 아래 예제 코드를 살펴 보자. 아래 예제는 shared_data 변수에 1부터 5까지 더하는 작업을 두 개의 스레드가 동시에 진행하는 것을 보여주고 있다.

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

int shared_data = 0;

void foo(const std::string& indent)
{
    for (int i = 1; i <= 5; i++)
    {
        shared_data += i;   // 크리티컬 섹션
        std::cout << "+" << i << " " << indent << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::microseconds(10));
    }
}

int main()
{
    std::thread t1(foo, ""); // 스레드별 출력 구분을 위해 ""와 "\t"를 사용
    std::thread t2(foo, "\t");

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

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

위 예제 코드에는 스레드간 동기화 장치가 전혀 되어 있지 않기 때문에, 두 개의 스레드가 동시에 공유 자원에 값을 쓰거나 읽는 것을 실행할 때 마다 제멋대로인 결과가 나온다. 아래 결과를 보면 심지어 답도 틀렸다.

++1     21 2

+2 6
+2      6
+3      12
+3 12
+4 20
+4      20
+5 25
+5      25
result:25

lock & unlock

이제 std::mutex를 이용해 크리티컬 섹션에 동기화를 걸어 보도록 하자. std::mutex를 사용하기 위해서는 <mutex> 헤더가 필요 하며, 각 스레드는 mutex 객체를 공유 할 수 있어야 한다. mutex를 공유하는 다양한 방법이 있겠지만 여기에서는 편의를 위해 전역 변수로 공유하도록 하겠다.

스레드에서 lock 멤버 함수를 호출해 뮤텍스를 획득하려 할 때, 뮤텍스가 아직 잠겨 있지 않은 상태라면 정상적으로 프로그램이 진행 된다. 하지만 뮤텍스가 다른 스레드에 의해 이미 획득 되어진 상태인 경우, 즉, 이미 lock 멤버 함수가 호출 되어진 경우는 unlock을 통해 뮤텍스가 해제 될 때 까지 프로그램이 블록 된다.

※ 만일 같은 스레드에서 lock을 두번 획득하려고 하는 경우 '데드락'이 된다. 데드락에 대한 내용은 [여기]를 참고 하자.
같은 스레드에 대해 뮤텍스를 두 번 소유할 수 있게 하기 위해서는 뒤에서 소개 될 recursive_mutex를 사용하면 된다.

#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <mutex>

std::mutex m;
int shared_data = 0;

void foo(const std::string& indent)
{
    for (int i = 1; i <= 5; i++)
    {
        m.lock();
        shared_data += i; // 크리티컬 섹션
        std::cout << "+" << i << " " << indent << shared_data << std::endl;
        m.unlock(); // lock 이후에는 꼭 unlock!!
        std::this_thread::sleep_for(std::chrono::microseconds(10));
    }
}

int main()
{
    std::thread t1(foo, "");
    std::thread t2(foo, "\t");

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

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

위 프로그램의 실행 결과는 아래와 같다. 뮤텍스를 이용해 크리티컬 섹션에서 동기화가 보장 되었기 때문에 공유 자원에 덧셈 연산이 우리가 원하는 대로 누락없이 진행 되었음을 확인할 수 있다.

+1      1
+1 2
+2 4
+2      6
+3 9
+3      12
+4      16
+4 20
+5 25
+5      30
result:30

덧셈 연산 부분이 뮤텍스로 묶여 동기화가 보장 되었기 때문에 우리가 원하는 결과가 나온것을 확인할 수 있다.

try_lock

try_lock은 lock과 달리 뮤텍스 획득에 실패하는 경우 프로그램을 블록 시키지 않고 false를 리턴한다. 필요에 따라 크리티컬 섹션에 진입하지 못하는 경우 바로 다른 일을 시작해야하는 경우 사용 되어질 수 있다.

void foo()
{
    if (true == m.try_lock())
    {
        shared_data = 100;
        std::this_thread::sleep_for(std::chrono::microseconds(10));
        m.unlock();
    }
    else
    {
        std::cout << "fail to acquire mutex" << std::endl;
    }
}

native_handle

실제로 mutex는 각 운영 체제 마다 다양하게 구현 되어 있으며, C++ 표준 라이브러리는 각 운영체제의 mutex를 C++에서 제공하는 동일한 인터페이스를 이용해 운영 체제에 관계 없이 동일한 코드를 이용해 프로그래밍 할 수 있도록 도와주고 있을 뿐이다.

하지만 가끔은 특정 운영 체제에 의존적인 기능를 써야만 하는 경우도 있다. 이런 경우 운영 체제 종속적인 뮤텍스의 구분자가 필수적이며 C++에서는 native_handle 이라는 멤버 함수를 이용해 운영체제 종속적인 구분자를 리턴 받을 수 있도록 제공하고 있다.

아래 예제는 뮤텍스 객체의 운영 체제 의존적인 구분자를 얻는 예제를 보여주고 있다. 운영체제 마다 핸들의 타입이 다를 수 있기 때문에 C++에서는 typedef로 미리 정의된 std::mutex::native_handle_type을 제공하고 있다.

std::mutex m;

// 생략 된 코드들...

std::mutex::native_handle_type h = m.native_handle();

std::recursive_mutex

앞에서 살펴 본 std::mutex는 한 번만 소유가 가능한 반면, std::recursive_mutex의 경우는 하나의 스레드가 여러번 lock을 통해 뮤텍스를 소유하는 것이 가능하다(단, 소유한 만큼 unlock해야 한다)

아래 예제는 foo 함수 내에서 동일한 뮤텍스 객체에 lock을 두 번 시도하고 있다. 

std::mutex m;
int shared_data = 0;

void foo()
{
    m.lock();
    m.lock(); // 데드락
    
    shared_data = 100;
    
    m.unlock();
    m.unlock();
}

6라인에서 이미 뮤텍스가 획득 되었기 때문에 7라인에서의 lock은 뮤텍스를 획득하지 못하고 영원한 블록(데드락) 상태에 빠지게 된다(비주얼 스튜디오의 경우 데드락에 빠지지 않고 프로그램이  abort 된다). 하지만 위와 같은 호출을 가능할수 있도록 해주는 뮤텍스가 std::recursive_mutex다. 위 예제에서 다음과 같이 std::mutex를 std::recursive_mutex로 변경하면 아무런 문제 없이 실행되는 것을 확인할 수 있다.

std::recursive_mutex m;
int shared_data = 0;

void foo()
{
    m.lock();
    m.lock(); // 아무런 문제 없음
    
    shared_data = 100;
    
    m.unlock();
    m.unlock();
}

그런데 위 예제 처럼 크리티컬 섹션 하나를 잡기 위해 동일한 뮤텍스를 두 번이나 획득할 일은 없을것 같다. 이번에는 recursive_mutex를 사용하는 좀 더 현실적인 예제를 살펴 보자. 아래 Foo 클래스는 공유자원에 접근하는 func1과 func2 멤버 함수를 제공하고 있다.

주의 깊게 볼 부분은 두 함수 모두 공유 자원으로 접근을 위해 뮤텍스를 획득하지만, func2에서 func1을 호출하고 있다는 것이다. 사용자가 func2를 호출하는 경우 func2와 func1에서 각각 동일한 뮤텍스를 획득하고자 시도하기 때문에 일반 std::mutex를 사용하게 되면 이 부분에서 데드락 상태에 빠지게 된다.

하지만 위에서 살펴본 std::recursive_mutex를 사용한다면 동일한 스레드가 여러번 뮤텍스 획득하는 것을 허용하기 때문에 문제 없이 실행 된다.

class Foo
{
    int shared_data = 0;
    std::recursive_mutex m;

public:
    void func1()
    {
        m.lock();
        shared_data = 1;
        m.unlock();
    }

    void func2()
    {
        m.lock();
        func1();   // 다른 멤버 함수 func1 호출
        shared_data = 2;
        m.unlock();
    }
};

만일 recursive_mutex가 없다면 위와 같이 공유 자원에 접근하는 멤버함수 끼리는 서로 호출이 불가능하다. 

std::shared_mutex

예전 포스트에서 read/write lock에 대해 다룬적이 있었다. 포스트의 내용을 간단히 요약하자면 아래와 같다.

  • 공유 자원에 대한 read 작업은 값을 변경하지 않으니 하나의 크리티컬 섹션에 여러 개의 스레드가 동시 진입해도 일관성에 문제가 없다.
  • write 작업은 값을 변경 할 수도 있으니 하나의 크리티컬 섹션에 여러개의 스레드가 진입 할 경우 일관성이 깨질 수 있다.
  • 결론적으로 쓰는 동안에 읽을 수 없어야 하며, 읽는 동안에 쓸 수 없어야 한다.

shared_mutex는 read/write lock의 C++ 표준 라이브러리 버전이라 생각하면 된다. shared_mutex 사용을 위해서는 기존의 <mutex> 헤더 말고 <shared_mutex>라는 새로운 헤더가 필요하다.

#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <shared_mutex>

std::shared_mutex m;
int shared_data = 0;

void Write()
{
    while (true)
    {
        m.lock(); // write 작업에서는 다른 스레드에서는 접근해서는 안되기에 lock을 사용한다.
        shared_data += 1;
        std::cout << "write : " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
        m.unlock();
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

void Read()
{
    while (true)
    {
        m.lock_shared(); // 공유 할 때는 lock_shared
        std::cout << "read : " << shared_data << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
        m.unlock_shared(); // lock_shared를 이용했을 때는 unlock_shared를 사용해야 한다.
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

int main()
{
    std::thread t1(Write);
    std::thread t2(Read);
    std::thread t3(Read);

    t1.join();
    t2.join();
    t3.join();
    return 0;
}

위 코드의 14라인과 27라인을 주목하자. 14라인에서는 공유 자원에 무엇인가를 쓰기 위해 'lock'을 이용해 단일 스레드만이 자원에 접근 가능하도록 보장하고 있다.

하지만 27라인에서는 lock 대신 lock_shared 함수를 이용하여 현재 스레드 외에도 다른 스레드에서도 공유 자원에 접근하여 읽을 수 있도록 허락하고 있다. lock_shared 로 크리티컬 섹션을 잡은 경우 lock_shared 함수를 이용해 크리티컬 섹션에 접근하려는 스레드들은 진입이 가능하지만, lock 함수를 이용해 접근하려는 스레드들은 블록킹 상태에 진입하게 된다.

lock_shared를 이용해 크리티컬 섹션에 진입한 경우, 해제하기 위해서는 unlock 대신 unlock_shared를 함수를 이용해야만 한다.

write 작업 보다 read 작업이 압도적으로 많은 경우 std::shared_mutex를 사용하면 성능 향상에 도움이 될 것이다.

요약

이상 C++ 표준에서 제공하고 있는 스레드 동기화 라이브러리인 std::mutex에 대해 살펴 보았다. 다음 포스트에서는 RAII를 이용해 mutex객체를 보다 안전하게 사용할 수 있는 lock_guard에 대해 살펴 보도록하겠다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!