1. mutex란??
C++ 11 버전에 나온 class로 Thread들의 동기화를 해줄 수 있게 해주는 기능을 가지고 있습니다.
Thread들의 동기화란 무엇일까??
예를들어보자.
우리는 게임을 하기위해 캐릭터를 생성해야 합니다.
해당 캐릭터를 만들려면 캐릭터의 이름이 필요한데,
캐릭터 ID의 중복검사를 한 후 없으면 캐릭터 생성을 할 수 있습니다.
이런식으로 함수에 접근할 것입니다.
하지만 동시에 접근하는 유저수가 100명 이상이라면??? 그 100명이 똑같은 ID로 캐릭터를 생성하려고 한다면??
어떻게 될까요???
중복된다고 return받는 Thread들이 있을 수 있고 중복검사를 동시에 해서
똑같은 ID로 만들어지는 Thread들도 있을 것입니다.
왜?? context switching때문입니다. 1 ~ 100개의 Thread가 있다고 한다면
동시에 실행되는 것이 아니라 1 ~ 100까지 번갈아 가면서 실행하는 것이죠.
1번 Thread는 80%까지 실행하고 block, 2번 Thread는 20%까지 실행하고 block,
29번 Thread는 60%까지 실행하고 block.... 이런식으로 무작위로 여러번 실행되게 됩니다.
때문에 비동기(asynchronization)라고 불리고 있죠.
때문에 어떤 Thread들은 변경하려고 하는 ID가 해당 데이터 베이스에 값이 없다고 return 받을 수 있고,
어떤 Thread들은 변경된 직후 해당 ID가 있다고 return 받을 수 있는 것입니다.
그렇게 된다면 유저가 100명이라고 한다면 동일한 ID를 가진 유저는 1명이 아닐 것입니다.
이렇게 무작위로 접근하는 Thread들이 하나의 자원에 경쟁하듯 접근하는 것을 경쟁상태(Race condition)이라고 합니다.
Thread들의 동기화를 위해(순서를 지키기 위해)
접근하는 함수의 특정부분을 지정해 그 부분은 Thread가 온전히 100%처리하고 넘어가도록 도와주는 기능이 mutex입니다.
2. std::mutex 사용법
mutex는 <mutex>헤더 파일을 사용합니다.
#include <future>
#include <mutex>
int result = 0;
std::mutex m;
void fnc() {
m.lock();
result += 10;
m.unlock();
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
std::future<void> t2 = std::async(std::launch::async, fnc);
return 0;
}
이렇게 공유변수에 하나의 Thread만 접근할 수 있도록 해줄 수 있습니다.
그림으로 표현하면 다음과 같이 될 것입니다.
이렇게 하나의 Thread가 100%로 처리하도록 점유할 수 있게 해주는 영역을 임계영역(critical section)이라고 합니다.
그럼 m.unlock()
을 빼버리면 어떻게 될까요??
#include <future>
#include <mutex>
int result = 0;
std::mutex m;
void fnc() {
m.lock();
result += 10;
// m.unlock();
}
int main() {
std::future<void> t = std::async(std::launch::async, fnc);
return 0;
}
프로그램이 멈추게 됩니다.
Deadlock이 발생해서 그렇습니다.
이러한 프로그래머의 실수를 방지하기위해 RAII로 된 클래스들을 제공합니다.
3. RAII의 std::unique_lock(), std::lock_guard()
사용법
#include <future>
#include <mutex>
int result = 0;
std::mutex m;
void fnc() {
std::unique_lock<std::mutex> lock(m);
//std::lock_guard <std::mutex> lock(m);
result += 10;
}
int main() {
std::future<void> t = std::async(std::launch::async, fnc);
return 0;
}
이렇게 해주면 함수가 끝나면 자동으로 unlock이 보장됩니다.
또한 중괄호'{}'를 통해 특정 부분만 lock을 해줄 수 있습니다.
#include <future>
#include <mutex>
int result = 0;
std::mutex m;
void fnc() {
{
std::unique_lock<std::mutex> lock(m);
//std::lock_guard <std::mutex> lock(m);
result += 10;
}
std::cout << "lock 벗어남\n";
}
int main() {
std::future<void> t = std::async(std::launch::async, fnc);
return 0;
}
이렇게 해줌으로써 코드를 좀 더 효율적으로 만들 수 있습니다.
4. Dead lock
Thread를 동기화 시켜줄 때 가장 중요한 것이 Dead lock을 피하는 것입니다.
그럼 Dead lock이란 뭘까요??
Dead lock은 lock이 걸린 상태를 함수가 끝나도 계속 유지하는것을 말합니다.
lock이 계속 유지되는 이유는 여러가지가 있을 수 있습니다.
.unlock()을 빼먹어서, 동시에 lock()을 해줘서, 중첩된 lock() 이 대표적이고 그 외도 많습니다.
우선 동시에 lock()을 해주는 경우를 보겠습니다.
#include <future>
#include <mutex>
void bar();
int result = 0;
std::mutex m1, m2;
void fnc() {
for (int i = 0; i < 10000; i++) {
std::unique_lock<std::mutex> lock1(m1);
result += 5;
std::unique_lock<std::mutex> lock2(m2);
result += 10;
}
}
void bar() {
for (int i = 0; i < 10000; i++) {
std::unique_lock<std::mutex> lock1(m2);
result -= 10;
std::unique_lock<std::mutex> lock2(m1);
result -= 3;
}
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
std::future<void> t2 = std::async(std::launch::async, bar);
t1.get(); t2.get();
std::cout << result << std::endl;
return 0;
}
t1은 fnc에 접근하고 t2는 bar에 접근합니다.
서로 다른 함수이지만 mutex는 공유하고 있습니다.
하지만 fnc()함수는 m1을 먼저 호출하고 bar()함수는 m2를 먼저 호출합니다.
그렇게 되면 서로 기다리는 상태가 되는데 그림으로 그리자면 다음과 같이 됩니다.
즉, 서로가 서로를 기다리는 상태가 되버립니다.
#include <future>
#include <mutex>
void bar();
int result = 0;
std::mutex m1, m2;
void fnc() {
for (int i = 0; i < 10000; i++) {
std::unique_lock<std::mutex> lock1(m1);
result += 5;
std::unique_lock<std::mutex> lock2(m2);
result += 10;
}
}
void bar() {
for (int i = 0; i < 10000; i++) {
std::unique_lock<std::mutex> lock1(m1);
result -= 10;
std::unique_lock<std::mutex> lock2(m2);
result -= 5;
}
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
std::future<void> t2 = std::async(std::launch::async, bar);
t1.get(); t2.get();
std::cout << result << std::endl;
return 0;
}
순서를 일치시켜주면 됩니다. (반드시)
아니면 std::scoped_lock
을 사용해주면 됩니다.
이에대한 설명은 아래에 하겠습니다.
4. Self Deadlock
자기 자신한테 lock()을 한번 더 거는 것입니다.
#include <future>
#include <mutex>
void bar();
int result = 0;
std::mutex m1, m2;
void fnc() {
std::unique_lock<std::mutex> lock(m1);
result += 5;
bar();
}
void bar() {
std::unique_lock<std::mutex> lock(m1);
result -= 10;
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
t1.get();
std::cout << result << std::endl;
return 0;
}
코드를 짜다보면 이런식의 코드를 어쩔 수 없이 만들 경우가 생깁니다.
그럴때는 std::recursive_mutex
를 사용해 주면 됩니다.
#include <future>
#include <mutex>
void bar();
int result = 0;
std::recursive_mutex r_m;
void fnc() {
std::unique_lock<std::recursive_mutex> lock(r_m);
result += 5;
bar();
}
void bar() {
std::unique_lock<std::recursive_mutex> lock(r_m);
result -= 10;
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
t1.get();
std::cout << result << std::endl;
return 0;
}
5. RAII의 std::scoped_lock
Dead lock을 피하는 방법중 하나는 std::scoped_lock
를 사용하는 것입니다.
해당 클래스는 C++ 17버전에 나왔으며 RAII기능을 함과 동시에 중복된 mutex의 Dead lock을 회피할 수 있도록 해줍니다.
#include <future>
#include <mutex>
void bar();
int result = 0;
std::mutex m1, m2;
void fnc() {
for (int i = 0; i < 10000; i++) {
std::scoped_lock lock(m1, m2);
result += 5;
}
}
void bar() {
for (int i = 0; i < 10000; i++) {
std::scoped_lock lock(m2, m1);
result -= 10;
}
}
int main() {
std::future<void> t1 = std::async(std::launch::async, fnc);
std::future<void> t2 = std::async(std::launch::async, bar);
t1.get(); t2.get();
std::cout << result << std::endl;
return 0;
}
이렇게 말이죠.
때문에 요즘은 std::scoped_lock
사용을 권장하는 추세입니다.
※ std::scoped_lock
를 찾을 수 없다고 한다면 컴파일의 C++ 버전을 확인해 보세요.
https://hwan-shell.tistory.com/209 <- 여기서 확인 가능합니다.
6. mutex 잠금 순서.
A -> B -> C 순으로 잠근다고 가정해 봅시다.
1.
A.lock()
B.lock()
C.lock()
C.Unlock()
B.Unlock()
A.Unlock()
이렇게 잠금하면 교착상태를 일으키지 않습니다.
2.
A.lock()
C.lock()
C.Unlock()
A.Unlock()
이렇게 해도 교착상태를 일으키지 않습니다.
B를 생략했지만 순서는 지켰기 때문입니다.
3.
A.lock()
C.lock()
B.lock()
B.Unlock()
C.Unlock()
A.Unlock()
이 경우 교착상태를 일으킵니다.
순서가 바뀌었기 때문이죠.
4.
A.lock()
B.lock()
C.lock()
B.lock()
A.lock()
B.Unlock()
C.Unlock()
A.Unlock()
이 경우에는 교착상태를 일으키지 않습니다.
이미 앞에 A, B, C의 순서를 지켰기 때문에
나중에 B, A 순으로 재귀적 lock을 걸어줘도 상관이 없습니다.
이 예제를 통해 알 수 있는 사실은 lock()을 사용할 시 순서만 지켜준다면 문제가 없다는 것입니다.