이전 글에서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를 쓰며 더욱더 안전한 코딩을 하는데 도움이 되었으면 좋겠다.
참고
* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.
'L C++ > Concurrency' 카테고리의 다른 글
[Concurrency] ❇ std::atomic ❇의 이해 (0) | 2024.01.16 |
---|---|
[Concurrency] C++20 스레드 조정 메커니즘 std::latch와 std::barrier (2) | 2024.01.09 |
[Concurrency] mutex의 lock/unlock 관리 도구 소개 (1/2) (4) | 2023.12.29 |
[Concurrency] 다양한 mutex 소개 (2) | 2023.12.19 |
[Concurrency] std::call_once - 중복 초기화 해소 기술 (0) | 2023.12.12 |