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

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

L C++/Concurrency

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

보리남편 김 주부 2024. 1. 2. 09:00
이전 글에서

2023.12.29 - [L C++] - [Concurrency] mutex의 lock/unlock 관리 도구 소개 (1/2)

RAII 원칙을 지키는 mutex의 lock/unlock 관리도구를 2가지 소개했는데 나머지 2가지를 알아보자.

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

 

std::unique_lock 

Since C++11

 

unique_lock은 기능이 많다. (lock_guard를 포함한 확장 버전이라고 생각하면 좋을 것 같다.) 예제를 통해서 unique_lock의 특징을 확인해 보자.

 

예제 1-1
unique_lock의 특징을 테스트한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;

std::mutex       m1, m2, m3;
std::timed_mutex tm1, tm2, tm3;

int main()
{
    // 1.
    //std::lock_guard<std::mutex> u1(m1);
    std::unique_lock<std::mutex> u1(m1);
    
    // 2.
    m2.lock();
    //std::lock_guard<std::mutex> u2(m2, std::adopt_lock);   
    std::unique_lock<std::mutex> u2(m2, std::adopt_lock);   
    
    // 3.
    //std::lock_guard<std::mutex> u3; // 컴파일 error
    std::unique_lock<std::mutex> u3;
   

	// 4.
    std::unique_lock<std::mutex> u4(m3, std::try_to_lock);

    if (u4.owns_lock())
        std::cout << "acquire lock" << std::endl;
    else
        std::cout << "fail to lock" << std::endl;

    // 5. 
    std::unique_lock<std::timed_mutex> u5(tm1, std::defer_lock);
    auto ret = u5.try_lock_for(2s);

    // 6. 
    std::unique_lock<std::timed_mutex> u6(tm2, 2s);  // tm2.try_lock_for() 사용
    std::unique_lock<std::timed_mutex> u7(tm3, std::chrono::steady_clock::now() + 2s);
    // tm3.try_lock_until() 사용               => 인자가 time_point 이경우 
}

 

코드 리뷰

    // 1. 생성자에서 m1.lock
    //std::lock_guard<std::mutex> u1(m1);
    std::unique_lock<std::mutex> u1(m1);
    
    // 2. 이미 lock 을 획득한 뮤텍스의 unlock 관리
    m2.lock();
    //std::lock_guard<std::mutex> u2(m2, std::adopt_lock);   
    std::unique_lock<std::mutex> u2(m2, std::adopt_lock);

1,2 기능은 lock_guard와 동일한 기능을 제공한다.

 

// 3.
//std::lock_guard<std::mutex> u3; // 컴파일 error
std::unique_lock<std::mutex> u3;

3 기능은 mutex 연결을 하지 않고 변수를 생성할 수도 있다. (lock_guard는 지원 안 함)

 

// 4.
std::unique_lock<std::mutex> u4(m3, std::try_to_lock);

if (u4.owns_lock())
    std::cout << "acquire lock" << std::endl;
else
    std::cout << "fail to lock" << std::endl;

4. m.try_lock를 대체하는 기능을 제공한다.
try_lock() 대신 사용할 수 있지만 사용 방법이 조금 다르다. try_lock()처럼 lock을 획득할 수 있으면 lock을 하지만, lock 획득 여부는 변수 생성 시에는 알 수 없고 owns_lock() 함수에서 확인할 수 있다.

// 5. 
std::unique_lock<std::timed_mutex> u5(tm1, std::defer_lock);
auto ret = u5.try_lock_for(2s);
    
// 6. 
std::unique_lock<std::timed_mutex> u6(tm2, 2s);  // tm2.try_lock_for() 사용
std::unique_lock<std::timed_mutex> u7(tm3, std::chrono::steady_clock::now() + 2s);
// tm3.try_lock_until() 사용               => 인자가 time_point 이경우

5, 6. time_mutex를 위한 기능도 지원한다.
5. std::defer_lock을 넘기면 생성자에서 lock을 하지 않는다. 이 부분은  std::adopt_lock과 같지만
std::adopt_lock는 이미 lock이 획득되어 있어 lock을 호출하지 않는 것이고,

std::defer_lock는 lock을 획득하지는 않았으나 나중에 lock을 획득하겠다는 의미이다. (바로 다음줄처럼 이후에 lock을 호출할 수 있다.)

6. try_lock_for()나 try_lock_until()와 연계해서 사용할 수 있다.

⚠ 혼용 주의 
내부 lock 유무 상태 flag 설정이 달라지기에 용도에 맞게 인자를 전달하여야 한다.

 

여기서 끝이 아니다 unique_lock의 기능을 좀 더 알아보자.

 

예제 1-2
예제 1-1에 이어 unique_lock의 특징을 추가로 테스트한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;

std::timed_mutex m;

int main()
{
    std::unique_lock<std::timed_mutex> u1;
    std::unique_lock<std::timed_mutex> u2(m); // m 관리

//  u1 = u2;            // error
    u1 = std::move(u2); // ok

    std::cout << u1.owns_lock() << std::endl;  // true

    if (u1) // u1.owns_lock()
        std::cout << "acquire" << std::endl;

    u1.unlock();
    std::cout << u1.owns_lock() << std::endl;  

    if (u1.try_lock_for(2s))
    {
        //....
        u1.unlock();
    }

    u1.release();
}

 

결과
1
acquire
0

 

코드 리뷰

//  u1 = u2;            // error
    u1 = std::move(u2); // ok

복사 대입연산자는 지원하지 않고, rvalue로 소유권 이전은 지원한다.

 

    if (u1) // u1.owns_lock()
        std::cout << "acquire" << std::endl;

위 결과를 보면 알 수 있지만, 변환 연산자 bool을 지원하여 owns_lock() 대신 사용할 수 있다.

 

u1.unlock();
std::cout << u1.owns_lock() << std::endl;  

if (u1.try_lock_for(2s))
{
    //....
    u1.unlock();
}

소멸자에서 자동 unlock이 되지만 명시적으로 lock/unlock도 가능하다. 이는 기존 mutex를 사용 중인 레거시 코드를 최소한의 수정으로 대체가 가능하다는 의미이기도 하다.

 

u1.release();

mutex와 연결을 끊을 수도 있다. release()는 unlock 하지 않기에 별도로 lock/unlock에 대해 관리를 해야 한다.

 

std::shared_lock 

Since C++14

 

std::shared_lock를 설명하기 앞서 shared_mutex를 모른다면 std::shared_mutex (since C++17)를 이해한 뒤 아래 글을 읽어주기 바란다.

 

std::shared_mutex을 소개하며 상황에 따라 mutex의 성능을 향상하는 방법을 소개했었는데 RAII를 하기 위해서는 아래와 같이 코드를 수정한다.

lock/unlock -> lock_guard
lock_shared/unlock_shared -> shared_lock

 

예제 2
std::shared_mutex의 예제 코드에서 관리도구로 치환하여 사용한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
#include <shared_mutex>
#include <string>
using namespace std::literals;

std::shared_mutex m;
int share_data = 0;

void Writer()
{
    while (1)
    {
        {
            std::lock_guard<std::shared_mutex> g(m);
            // m.lock();
            share_data = share_data + 1;
            std::cout << "Writer : " << share_data << std::endl;
            std::this_thread::sleep_for(1s);
            // m.unlock();
        }
        std::this_thread::sleep_for(10ms);
    }
}

void Reader(const std::string& name)
{
    while (1)
    {        
        {
            std::shared_lock<std::shared_mutex> g(m);
            //m.lock_shared();

            std::cout << "Reader(" << name << ") : " << share_data << std::endl;
            std::this_thread::sleep_for(500ms);
            //m.unlock_shared();
        }
        std::this_thread::sleep_for(10ms);
    }
}

int main()
{
    std::thread t1(Writer);
    std::thread t2(Reader, "A");
    std::thread t3(Reader, "B");
    std::thread t4(Reader, "C");
    t1.join();
    t2.join();
    t3.join();
    t4.join();
}

 

⚠ 주의사항
shared_lock 은 C++14부터 사용할 수 있지만, shared_mutex는 C++17부터 사용할 수 있다. C++17 이상을 쓴다면 아무 문제가 없지만 C++14까지 지원을 한다면 shared_mutex 대신 shared_timed_mutex(C++14)를 사용해야 한다.

생각해 봅시다.

mutex가 공유 데이터의 액세스 직렬화를 보장하기 위한 도구인데 만약 우선적으로 데이터 액세스가 필요한 mutex에게 접근 우선권을 주려면 어떻게 처리해야 할까?

이를 위한 도구인 condition_variable에 대해 알아보자.

condition_variable

Since c++11

신호(signal) 기반 동기화 도구이다.


아래 예제를 통해 우선적으로 mutex 접근이 필요한 상황을 가정해 보자.

 

예제 3-1
생산자(producer)가 제공하는 공유데이터를 소비자(consumer)가 읽어가는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;

std::mutex m;
int shared_data = -1; // 쓰레기값, 생산되기 이전의 값

void consumer()
{
    std::lock_guard<std::mutex> lg(m);
    std::cout << "consume : " << shared_data << std::endl;
}
void producer()
{
    std::this_thread::sleep_for(10ms);
    std::lock_guard<std::mutex> lg(m);
    shared_data = 100;
    std::cout << "produce : " << shared_data << std::endl;
}
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); 
    t2.join();
}

 

공유 데이터가 쓰레기 값(shared_data = -1)이라서 반드시 생산자가 공유 데이터를 쓰고 난 뒤부터 제공이 되어야 한다. 다음 예제에서 최초 producer가 먼저 공유데이터를 쓸 수 있게 condition_variable을 사용해 보자.

 

예제 3-2
예제 3-1에서 condition_variable 도구를 사용하여 생산자(producer)가 우선 공유데이터를 읽는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;

// 1.
#include <condition_variable>

// 2.
std::condition_variable cv;

std::mutex m;
int shared_data = -1; 

void consumer()
{
    // 3.
    std::unique_lock<std::mutex> ul(m);

    // 4.
    cv.wait(ul);
    std::cout << "consume : " << shared_data << std::endl;
}

void producer()
{
    std::this_thread::sleep_for(10s);

    {
        std::lock_guard<std::mutex> lg(m);
        shared_data = 100;
        std::cout << "produce : " << shared_data << std::endl;
    }

    // 5.
    cv.notify_one();
}

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

 

코드리뷰

condition_variable 도구 사용 방법
// 1.
#include <condition_variable>

 

1. 헤더 포함

// 2.
std::condition_variable cv;

2. 전역변수 생성

// 3.
std::unique_lock<std::mutex> ul(m);

3. unique_lock으로 mutex 획득 (lock_guard는 안됨)

// 4.
cv.wait(ul);

4. cv.wait()로 신호(signal)를 대기
이 함수가 호출이 되면 내부에서는 ul.unlock()으로 lock을 먼저 풀고, cv의 신호(signal)가 올 때까지 대기한다. 신호가 오면 다시 ul.lock()으로 뮤텍스 획득 후 다음 줄부터 실행된다. (이 동작 때문에 lock_guard는 사용할 수 없다. - lock_guard는 lock/unlock을 실행하는 함수를 제공하지 않음)

 

// 5.
cv.notify_one();

5. wait에게 신호(signal)를 발생시킨다.

 

 

스레드 동작 흐름

t1 스레드 :
producer -> lock_guard(lock 획득) -> shared_data 쓰기 -> 소멸자 호출(unlock)                                                                -> cv.notify_one() 신호(signal) 발생

t2 스레드 :
consumer -> unique_lock(lock 획득 시도) : lock 획득 or 대기                               -> cv.wait() 신호(signal) 대기                                                                           -> 이후 shared_data 읽기

 

condition_variable::wait()의 weak point 🚨

cv.notify_one()로 신호 발생은 wait이 되어 있는지 여부에 관여하지 않는다(개런티 안됨) 신호 발생 이후 cv.wait()가 호출된다면 dead lock이 걸리기에 위 예제처럼 wait() 기본형으로는 사용하지 않는데 일반적으로 사용하는 방법을 다음 예제에서 확인해 보자.

 

예제 3-3
예제 3-2에서 dead lock이 발생할 수 있는 문제를 개선한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;
#include <condition_variable>

std::condition_variable cv;

// 1.
bool data_ready = false;

std::mutex m;
int shared_data = -1;

void consumer()
{
//    std::this_thread::sleep_for(10ms);
    std::unique_lock<std::mutex> ul(m);

// 2.
//  cv.wait(ul);
    cv.wait(ul, []() { return data_ready; } );

    std::cout << "consume : " << shared_data << std::endl;
}

void producer()
{
    std::this_thread::sleep_for(10ms);
    {
        std::lock_guard<std::mutex> lg(m);
        shared_data = 100;
        std::cout << "produce : " << shared_data << std::endl;
    }

//3.
    data_ready = true;
    cv.notify_one();
}


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

 

 

코드 리뷰

// 1.
std::condition_variable cv;
bool data_ready = false;

1. condition_variable은 보통 flag 변수와 같이 사용한다.

 

// 2.
//  cv.wait(ul); // 무조건 신호를 대기
    cv.wait(ul, []() { return data_ready; } );

2. 대기를 할지를 판단하는 함수를 인자로 받는다.

두 번째 인자로 함수를 전달하는데 함수의 실행 결과가

참이면 대기를 하지 않고,

거짓이면 ul.unlock()을 하고 대기를 하다 신호(signal)가 오면 다음 줄부터 실행
(실행 함수를 람다 표현식을 사용하였는데 별도 함수로 지정해도 된다.)

//3.
    data_ready = true;
    cv.notify_one();

전역 flag를 통해 대기가 필요 없는 상태로 변경한다.

위와 같이 data_ready라는 flag 상태에 따라 wait 여부를 결정한다.

예제 2처럼 여러 스레드를 사용하는 경우에는 wait 중인 스레드 모두 깨우는 방법이 필요한데 이 때는 std::condition_variable 대신에 std::condition_variable_any를 사용하면 된다.

 

예제 3-4
예제 2에 std::condition_variable_any를 사용하여, 최초 writer에서 공유데이터를 우선 접근하게 하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
#include <shared_mutex>
#include <string>
using namespace std::literals;

std::shared_mutex m;
int share_data = 0;

//1.
//std::condition_variable cv; 
std::condition_variable_any cv; 

bool data_ready = false;

void Writer()
{
    while (1)
    {
        {
            std::lock_guard<std::shared_mutex> g(m);
            share_data = share_data + 1;
            std::cout << "Writer : " << share_data << std::endl;
            std::this_thread::sleep_for(1s);
        }
        data_ready = true;
//2.
//      cv.notify_one();
        cv.notify_all();

        std::this_thread::sleep_for(10ms);
    }
}

void Reader(const std::string& name)
{
    while (1)
    {
        {
            std::shared_lock<std::shared_mutex> g(m);

            cv.wait(g, []() {return data_ready; });

            std::cout << "Reader(" << name << ") : " << share_data << std::endl;
            std::this_thread::sleep_for(500ms);
        }

        std::this_thread::sleep_for(10ms);
    }
}

int main()
{
    std::thread t2(Reader, "A");
    std::thread t3(Reader, "B");
    std::thread t4(Reader, "C");
    std::thread t1(Writer);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
}

 

코드 리뷰

//std::condition_variable cv; // unique_lock 만 사용가능
std::condition_variable_any cv; // 모든 lock management 사용가능

std::condition_variable_any는 모든 lock 관리 도구 사용이 가능하지만 대부분 shared_lock과 같이 사용한다.

//cv.notify_one(); // 한개만 깨우기
cv.notify_all(); // 대기 중인 스레드 모두 깨우기

cv.notify_all() 함수를 제공하여 대기 중인 스레드 모두 깨우기도 가능하다.(한 개만 깨우기도 가능)
어떤 lock 도 가능하지만 이런 용도이기에 대부분 shared_lock과 사용한다.


summary 

mutex의 lock/unlock 관리도구 4가지 특징 정리


lock 관리도구 4가지에 특징을 표로 정리하면 다음과 같다.

std::lock_guard 한 개의 뮤텍스를 lock/unlock
가장 가볍다. 생성자/소멸자만 존재
C++11
std::scoped_lock 여러 개의 뮤텍스를 deadlock 없이 안전하게 lock/unlock C++17
std::unique_lock std::lock_guard 보다 다양한 기능을 제공
lock_guard보다 다양한 기능을 제공, 무겁다.
std::condition_variable 사용시 반드시 필요
C++11
std::shared_lock shard_mutex 를 관리(전용) C++11

 

RAII를 쓰며 더욱더 안전한 코딩을 하는데 도움이 되었으면 좋겠다.

참고


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

728x90