이전 글에서 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/
'L C++ > Concurrency' 카테고리의 다른 글
[Concurrency] C++20 스레드 조정 메커니즘 std::latch와 std::barrier (2) | 2024.01.09 |
---|---|
[Concurrency] mutex의 lock/unlock 관리 도구 소개 (2/2) (2) | 2024.01.02 |
[Concurrency] 다양한 mutex 소개 (2) | 2023.12.19 |
[Concurrency] std::call_once - 중복 초기화 해소 기술 (0) | 2023.12.12 |
[Concurrency] thread_local - thread 전용 변수 ✔ (0) | 2023.12.05 |