SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] mutex의 lock/unlock 관리 도구 소개 (1/2)

보리남편 김 주부 2023. 12. 29. 03:36
이전 글에서 2023.12.19 - [L C++] - [Concurrency] 다양한 mutex 소개
deadlock 방지 사례와 성능 향상에 대한 mutex 소개가 있었는데 C++ Core guidelines에 보면 lock()/unlock()을 직접 사용하지 말라고 합니다. 😨

CP.20: Use RAII, never plain lock()/unlock()
* RAII : Resource Acquisition Is Initialization.
(자원의 획득은 초기화 과정이다. => 자원의 획득은 (자원관리 객체의) 초기화 과정이다. => 자원 관리는 항상 생성자/소멸자에 의존해라.)

왜? mutex 사용 시 RAII를 쓰라고 하는 이유에 대해 함께 알아봅시다.

 

mutex 사용 시 RAII를 쓰라고 하는 이유는?


우선 dead lock이 발생하는 2가지 예제를 우선 보자.

 

예제 1
lock 사용 중 예외를 발생시켜 dead lock이 발생하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <exception>

std::mutex m;

void goo()
{
    m.lock();    
    std::cout << "using shared data" << std::endl;

    // 공유자원 사용중 다양한 이유로 예외 발생
    throw std::runtime_error("goo fail");

    m.unlock();
}
void foo()
{
    try { goo(); }
    catch (...) { std::cout << "occurred exception" << std::endl; }
}

int main()
{
    std::thread t1(foo);
    std::thread t2(foo);
    t1.join();
    t2.join();
}

 

결과
using shared data
occurred exception
(이 시점에서 dead lock 발생)

 

스레드 동작 흐름을 보면 아래와 같다.

t1 스레드 : goo -> lock 실행 -> runtime_error -> (unlock 하지 못하고) catch로 넘어감
t2 스레드  : goo -> 소유자가 lock 실행 중이라 대기(dead lock 발생)

 

예제 코드는 의도적으로 예외를 발생시켰지만 의도치 않게 발생하는 경우 dead lock을 피할 수 없다. 하나의 예제를 더 훑어보자.

 

예제 2
은행 계좌에서 돈을 인출/입금하는 시나리오에서 2개의 스레드가 각각 하나의 뮤텍스를 잡고 다른 뮤텍스를 요구하면서 dead lock이 발생하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
using namespace std::literals;

// 은행 계좌
struct  Account
{
    std::mutex m; // money 의 동시 접근을 막기 위한 뮤텍스
    int money = 100;
};

// 계좌 이체
void transfer(Account& acc1, Account& acc2, int cnt)
{
    acc1.m.lock();
    
    std::this_thread::sleep_for(1s);

    acc2.m.lock();

    acc1.money -= cnt;
    acc2.money += cnt;
    std::cout << "finish transfer" << std::endl;

    acc1.m.unlock();
    acc2.m.unlock();
}

int main()
{
    Account kim, lee;
    std::thread t1(transfer, std::ref(kim), std::ref(lee), 4);
    std::thread t2(transfer, std::ref(lee), std::ref(kim), 5);    
    t1.join();
    t2.join();

    std::cout << "kim's money :" << kim.money << std::endl;
    std::cout << "lee's money :" << lee.money << std::endl;
}

 

Account 객체에 각각의 mutex를 가지고 있고, 두 스레드를 실행하는데 아래 코드를 보면

Account kim은 t1에서는 두 번째 인자로, t2에서는 세 번째 인자로 대입되고,

Account lee 은 t1에서는 세 번째 인자로, t2에서는 두 번째 인자로 대입하고 있다.

std::thread t1(transfer, std::ref(kim), std::ref(lee), 4);
std::thread t2(transfer, std::ref(lee), std::ref(kim), 5);


이렇게 하면 transfer 함수에서 스레드별로 acc1.m.lock()가 호출되지만 실상은 다른 뮤텍스가 호출이 되고, 아래 흐름대로 스레드가 실행된다.

t1 스레드 : transfer -> kim.m.lock -> 1초 sleep -> lee.m.lock
t2 스레드  :                        transfer ->  lee.m.lock      or             lee.m.lock

lee

lee.m.lock이 타이밍적으로 언제 불릴지는 모르지만 lee.m.lock이 두 번 불려서 dead lock이 발생된다.

 


 

다시 질문으로 돌아와 mutex 사용 시 RAII를 쓰라고 하는 이유는 개발 의도와 다르게 dead lock이 발생할 수 있고, (lock 중에 lock을 호출하면서 dead lock이 발생하는 문제를 해결한)  std::recursive_mutex (since C++11)를 소개했지만 이 mutex로도 위의 dead lock은 해결할 수 없기에 안전한 방법은 아니다. 하지만 RAII 원칙대로 구현된 도구를 쓰면 문제를 해결할 수 있기 때문이다.

 

 예제 1 => std::lock_guard로
예제 2 => std::scoped_lock로
dead lock 해결 👍

 

lock을 관리하는 도구 4가지


lock을 관리하는 도구는 4가지가 있다.

std::lock_guard (C++11)
std::scoped_lock (C++17)
std::unique_lock (C++11)
std::shared_lock (C++14)

 

우선 예제 1,2에서 발생한 dead lock을 해결한 기술을 소개한다.

std::lock_guard

since C++11

 

예제 1-1
예제 1에서 unlock을 삭제하고 lock 대신 lock_guard를 사용하여 dead lock이 발생하지 않는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <exception>

std::mutex m;

void goo()
{   
    {
        std::lock_guard<std::mutex> g(m);
        //  m.lock();
        std::cout << "using shared data" << std::endl;
        throw std::runtime_error("goo fail");
        //  m.unlock();
    }
}

void foo()
{
    try { goo(); }
    catch (...) { std::cout << "occurred exception" << std::endl; }
}

int main()
{
    std::thread t1(foo);
    std::thread t2(foo);
    t1.join();
    t2.join();
}

 

결과
using shared data
occurred exception
using shared data
occurred exception

 

std::lock_guard는 생성자에서 m.lock() 이 호출되며, 소멸자에서 m.unlock()이 발생하기에 lock/unlock을 직접 호출하지 않는다. 소멸되기 전에 예외가 발생했지만 스택 풀기에 의해서 지역변수 g의 소멸자 호출이 보장되어 함수 호출 순서는 lock -> 예외발생 -> lock_guard 소멸자 호출(unlock) -> catch로 전달되어 문제없이 unlock이 호출이 된다. 👍 

참고 : 예외 상황에서 스택에 쌓여있는 함수를 역으로 호출하면서 try/catch를 찾아가는 것을 스택 풀기(stack unwinding)라고 한다.

 

예제 1-2
lock_guard를 이해하기 위한 활용 방법 2가지를 추가로 실행하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <exception>

std::mutex m;

void foo()
{
    // 방법 1.
    {
        std::lock_guard<std::mutex> g(m);
    }

    // 방법 2. 
    if (m.try_lock())
    {
        // std::lock_guard<std::mutex> g(m); // 버그, 생성자에서 lock
        std::lock_guard<std::mutex> g(m, std::adopt_lock); // ok
        // ..... 

        //m.unlock();
        //m.unlock();
    }
    else
    {
        //....
    }
}

int main()
{
    std::thread t1(foo);
    std::thread t2(foo);
    t1.join();
    t2.join();
    std::cout << "end" << std::endl;
}

 

방법 1.

lock_guard g는 지역변수이기에 {}를 벗어나면 자동 소멸되기에 이를 이용하여 lock/unlock의 호출 범위를 조정할 수 있다. 👍

    {
        std::lock_guard<std::mutex> g(m);
    }



방법 2.

기존 try_lock 사용으로 lock 성공/실패에 대한 처리 코드에서 unlock만 사용하는 방법

if (m.try_lock())
    {
        std::lock_guard<std::mutex> g(m, std::adopt_lock); // ok

        // ..... 

        //m.unlock();
        //m.unlock();
    }

 

중요코드 설명

std::lock_guard <std::mutex> g(m, std::adopt_lock)
lock_guard '생성자에서 lock을 하지 마라'는 의미이다. unlock을 소멸자에서 자동으로 호출되게 하기 위해 사용되었다.

 

레거시 코드에서 m.try_lock을 사용하고 있다면, if/else를 고치는 것이 아니라 lock이 걸리는 부분에 mutex를 lock_guard에 넘겨주고 unlock만 지우면 된다. 👍

 

std::scoped_lock

since C++ 17

 

아래 예제는 std::scoped_lock이 아닌 std::lock에 대한 예제인데 우선 동작을 확인해 보자.

 

예제 2-1
예제 2의 문제를 std::lock를 이용하여 해결한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
using namespace std::literals;

// 은행 계좌
struct  Account
{
    std::mutex m; // money 의 동시 접근을 막기 위한 뮤텍스
    int money = 100;
};

// 계좌 이체
void transfer(Account& acc1, Account& acc2, int cnt)
{
//    acc1.m.lock();
//    acc2.m.lock();

    std::lock(acc1.m, acc2.m);
    std::this_thread::sleep_for(1s);
    
    acc1.money -= cnt;
    acc2.money += cnt;
    std::cout << "finish transfer" << std::endl;

    acc2.m.unlock();
    acc1.m.unlock();
}

int main()
{
    Account kim, lee;
    std::thread t1(transfer, std::ref(kim), std::ref(lee), 5);
    std::thread t2(transfer, std::ref(lee), std::ref(kim), 5);
    t1.join();
    t2.join();
}
결과
finish transfer
finish transfer

 

중요코드 설명

std::lock(acc1.m, acc2.m);
생성자를 보면 뮤텍스를 무한대로 받을 수 있게 구현되어 있다.
void lock(_Lock0& _Lk0, _Lock1& _Lk1, _LockN&... _LkN) { // lock multiple locks, without deadlock
ex) std::lock(m1, m2, m3, m4....)
그래서 std::lock는 여러 개의 뮤텍스를 lock 할 수 있으며 deadlock avoid 알고리즘으로 구현된 함수라 dead lock 없이 잘 동작한다.

 

위 예제에서 dead lock이 해결됨을 확인되었지만 std::lock는 unlock을 직접 호출하기에 RAII 가 아니며 안전한 방법도 아니다. (중간에 예외가 발생하면 dead lock이 발생할 수 있음) std::scoped_lock는 std::lock을 사용하여 RAII 하게 만들어졌다.

각각의 mutex에 lock_guard를 사용하여 해결할 수도 있지만, mutex의 개수가 많다면 효율적인 방법은 아니다.

 

예제 2-2
예제 2-1는 RAII 원칙을 지킨 std::scoped_lock를 사용하여 lock/unlock을 호출하지 않고 deadlock 문제를 해결한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
using namespace std::literals;

// 은행 계좌
struct  Account
{
    std::mutex m; // money 의 동시 접근을 막기 위한 뮤텍스
    int money = 100;
};

// 계좌 이체
void transfer(Account& acc1, Account& acc2, int cnt)
{
    // std::lock(acc1.m, acc2.m);   
     std::scoped_lock g(acc1.m, acc2.m);

    acc1.money -= cnt;
    acc2.money += cnt;
    std::cout << "finish transfer" << std::endl;

//    acc2.m.unlock();
//    acc1.m.unlock();
}

int main()
{
    Account kim, lee;
    std::thread t1(transfer, std::ref(kim), std::ref(lee), 5);
    std::thread t2(transfer, std::ref(lee), std::ref(kim), 5);
    t1.join();
    t2.join();
}

 

결과
finish transfer
finish transfer

 

중요코드 설명

std::scoped_lock g(acc1.m, acc2.m);
scoped_lock의 생성자를 보면 mutex를 무한대로 인자를 받을 수 있고 받는 대로 std:lock을 호출한다.
explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
        _STD lock(_Mtxes...);
    }

std::scoped_lock g(acc1.m, acc2.m);
예제는 2개의 mutex를 인자로 받고 있는데 인자에 대한 템플릿은 아래와 같지만 C++17부터 클래스 템플릿 인자가 생략가능해졌고 std::scoped_lock가 C++17부터 추가되었기에 템플릿 인자는 생략해도 된다.
std::scoped_lock<std::mutex, std::mutex> g(acc1.m, acc2.m);

 

std::unique_lock (C++11), std::shared_lock (C++14) 및 추가 활용 기술은 다음 글에서 이어 작성하겠다.

 

참고


* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.

 

C++ Core Guidelines site : https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

한글화 프로젝트 site : https://cppkorea.github.io/CppCoreGuidelines/

 

 

728x90