반응형
Q1. 데드락이 발생하는 이유는?
Q2. 데드락을 막기 위한 방법은?
lock_guard 를 활용한다고 해서 세상의 모든 데드락이 사라지는게 아냐.
lock이랑 unlock 짝 안맞춰서 발생하는 데드락 상황은 1차원적이고 자주 일어나지 않는 부분들이야.
까다롭게 데드락 발생하는 상황이 존재한다.
크래시가 일어나는 경우
1. 높은 확률로 null 포인터 잘못 참조하는 경우
2. 그 외에 데드락도 상위권으로 등장하는 버그다.
예제를 통해 알아보자.
실습용 클래스 2개 추가. AccountManager, UserManager를 Main 필터에 생성한다.
온라인 게임에서 입장할 때 계정이 있을 거고, 그 계정을 토대로 캐릭터 받아오는 작업을 할텐데 그 Account 라고 보면 된다
AccountManager.h에
#pragma once
#include <mutex>
class Account
{
// TODO
};
class AccountManager
{
public:
static AccountManager* Instance()
{
static AccountManager instance;
return &instance;
}
Account* GetAccount(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가를 갖고 옴
return nullptr;
}
void ProcessLogin();
private:
mutex _mutex;
// map<int32, Account*> _accounts;
};
#include <mutex> 를 해주고
Account 라는 클래스가 있다고 가정을 해보자.
class Account
{
// TODO
}
지금은 테스트라 비워두지만, 아이디, 계정 이름 등 온갖 정보가 들어가게 될거야.
AccountManager 클래스는 싱글톤으로 언제 어디서나 꺼내 쓸 수 있고, 실질적으로 게임에 접속해 있는 모든 어카운트들의 정보를 추출할 수 있는 편리한 클래스라고 가정을 해보자.
일단 간단하게 싱글톤을 만들어 주도록 하자.
public:
static AccountManager* Instance()
{
static AccountManager instance;
return &instance;
}
그 다음에 예를 들어 GetAccount 라는 함수가 있는데
Account* GetAccount(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가를 갖고 옴
return nullptr;
}
어떤 해당 account 의 id를 주면 여기서 뭔가를 찾아서 뱉어주는 그런 기능을 한다고 가정을 해보자.
그리고 멀티 스레드 환경에서 동작을 해야하기 때문에
private:
mutex _mutex;
멤버 변수 _mutex를 들고 있을 것이다.
};
GetAccount를 할 때
// map<int32, Account*> _accounts;
id와 account 짝을 이런식으로 갖고 있다고 가정을 할 수 있다.
멀티 스레드 환경에서 돌아가야 하니까
Account* GetAccount(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가를 갖고 옴
return nullptr;
}
락 가드를 걸어 줘서 크래시가 나지 않게 동시 다발적으로 접속할 수 있게 만들어 줄거야.
뭔가를 갖고 와서 account id로 추출해서 해당 account를 뱉어주고 하는 그런 기능들을 getAccount 함수가 담당한다고 볼 수 있다.
void ProcessLogin();
이라 해서 실질적으로 이 AccountManager를 통해가지고 어떤 유저가 처음에 게임에 로그인을 했을 때 로그인 하는 코드를 여기다가 넣어 둔다고 가정을 해본다. 로그인을 할 때 Account 정보도 필요하고 온값 정보들이 필요할 테니 ProcessLogin을 AccountManager에 배치하는 것도 나름 일리있는 선택이 될 수 있어.
그래서 AccountManager.cpp에
함수를 정의해 주는데 //map<int32, Account*> _accounts; 이 정보에 접근을 하려면 락을 잡아서 실행을 해야 한다.
void AccountManager::ProcessLogin()
{
// accountLock
lock_guard<mutex> guard(_mutex);
}
그 다음에 UserManager도 비슷한 느낌으로 똑같이 만들어 줄거야.
#pragma once
#include <mutex>
class User
{
// TODO
};
class UserManager
{
public:
static UserManager* Instance()
{
static UserManager instance;
return &instance;
}
User* GetUser(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가 갖고 옴
return nullptr;
}
void ProcessSave();
private:
mutex _mutex;
};
UserManager.h에도 #include <mutex>를 해주고
class User
{
// TODO
};
얘도 정보를 담은 User 클래스를 가지고 있다고 가정을 한다.
나머지는 중복되는 내용이니까 똑같이 만들어 준다.
class UserManager
{
public:
static UserManager* Instance()
{
static UserManager instance;
return &instance;
}
얘도 UserManager를 언제 어디서나 사용할 수 있게 싱글톤 패턴으로 만들어 준다.
User* GetUser(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가 갖고 옴
return nullptr;
}
그리고 GetUser라는 함수가 있는데
id를 받아서 해당 유저의 정보를 뱉어주는 역할을 하게 될거야.
이것도 멀티 스레드에서 실행이 되어야 하니 lock_guard를 해준 다음에
뭔가 갖고 오는 행동을 해줄 것이고
해당 정보를 뱉어주는 그런 기능을 한다고 가정을 해본다.
void ProcessSave();
얘도 마찬가지로 이런 저런 기능들이 있는데
어떤 유저를 플레이 하다가 해당 유저의 정보를 DB에다가 저장해야 된다거나 하는 그런 기능들을 왠지는 모르겠지만 UserManager 내부에 ProcessSave라는 함수를 만들어가지고 관리를 한다고 가정을 해본다. 얘도 대칭적인 상황
private:
mutex _mutex;
};
UserManager.cpp로 가서 기능을 구현을 하는데
얘도 멀티 스레드 환경이다 보니까 lock_guard를 걸어 줄거야.
void UserManager::ProcessSave()
{
// userLock
lock_guard<mutex> guard(_mutex);
이제 조금 복잡해 지는 건 다시 AccountManager로 돌아가서
void AccountManager::ProcessLogin()
{
// accountLock
lock_guard<mutex> guard(_mutex);
여기서 로그인을 할 때 account에 관련된 유저들을 로드하고 UserManager에 저장을 하고 하는 등등의 기능들이 필요하다 가정을 해보자.
그래서 결국에는 AccountManager.cpp에
#include "UserManager.h"를 추가 해가지고
여기서 UserManager에 접근을 해서 GetUser 함수를 활용해야 한다 가정을 해보자.
// userLock
User* user = UserManager::Instance()->GetUser(100);
// TODO
}
여기서 뭔가를 해주게 된다.
void AccountManager::ProcessLogin()
{
// accountLock
lock_guard<mutex> guard(_mutex);
// userLock
User* user = UserManager::Instance()->GetUser(100);
// TODO
}
결국엔 여기서 해준게 accountLock을 여기서 잡은 상태에서 간접적으로 userLock을 잡은 상태가 되는 거다.
왜냐하면 GetUser라는 함수 내부에서
User* GetUser(int32 id)
{
lock_guard<mutex> guard(_mutex);
// 뭔가 갖고 옴
return nullptr;
}
lock_guard<mutex> guard(_mutex); 이렇게 mutex를 잡아주고 있기 때문이야.
그러다보니 accountManager에도 mutex가 있고,
userManager에도 mutex가 하나 있는데 각기 다른 mutex 객체다.
lock을 딱 하나만 활용하는게 아니라 manager별로 락이 하나씩 있으면 경우에 따라 이렇게 두개의 락을 동시에 잡아야 하는 경우가 종종 생긴다는 걸 알 수 있어.
UserManager에서도 마찬가지로
void UserManager::ProcessSave()
{
// userLock
lock_guard<mutex> guard(_mutex);
ProcessSave라는 이런 기능이 있는데 어찌 어찌 하다 보니까 얘도 자신이 속해 있는 Account 정보를 긁어 와야 한다고 해보자.
이건 어디까지나 예제임.
void UserManager::ProcessSave()
{
// userLock
lock_guard<mutex> guard(_mutex);
// accountLock
Account* account = AccountManager::Instance()->GetAccount(100);
// TODO
}
100번 id에 해당하는 account 정보를 추출한다고 해보자.
얘는 거꾸로 userLock을 잡은 상태에서 accountLock을 잡은 다음에
뭔가 일을 해 주게 될거야.
여기까지는 별 다른 문제가 없어 보이지만 문제가 존재한다.
이제 GameServer.cpp 쪽으로 가서 간단하게 스레드를 2개를 만들어 볼거야.
#include "AccountManager.h"
#include "UserManager.h"
를 추가해주고, 함수 2개를 만들어 줄건데
#include "AccountManager.h"
#include "UserManager.h"
void Func1()
{
for (int32 i = 0; i < 1000; i++)
{
UserManager::Instance()->ProcessSave();
}
}
void Func2()
{
for (int32 i = 0; i < 1000; i++)
{
AccountManager::Instance()->ProcessLogin();
}
}
한번 하면 데드락이 잘 일어나지 않으니까 여러번 반복해서 해주고 있는 거야.
int main()
{
std::thread t1(Func1);
std::thread t2(Func2);
t1.join();
t2.join();
cout << "Jobs Done" << endl;
}
이렇게 UserManager와 AccountManager를 각기 다른 스레드에서 접근해서 하고 있는 거야.
일단은 1000번 반복하는게 아니라 1번만 하도록 for문을 변경해서 ctrl + f5로 실행을 해보자.
어쩔 때는 막히고 어쩔 때는 Jobs Done을 띄운다. 실행이 되었으면 아무 문제 없이 빠져 나온거.
이번에는 100번으로 늘려서 실행을 해보면 영영 끝나지 않고 계속 대기를 타고 있다는 걸 알 수 있다.

모두 중단 버튼을 눌러주고 스레드를 t1 스레드로 해서 보면




call stack을 보면 func1을 실행하고 processSave를 실행하고 있는데 userLock을 잡은 상태에서 AccountLock을 잡으려고 시도할 때 멈춘 상태다.
거꾸로 t2 스레드를 보면 순서가 뒤바뀌어서

accountLock을 잡은 상태에서 userLock을 잡으려고 하는데 얘를 잡지 못해서 대기를 타고 있는 상황이다.
이게 전형적인 DeadLock 상황이라고 보면 된다.
데드락이 발생하면 양쪽 스레드가 교착상태에 빠져서 그 스레드들이 서로 기다리는 그런 상태가 되어서 둘 중 어느 하나도 일을 할 수 없는 최악의 상황이 되었다고 볼 수 있다.
뭐가 문제가 되었는지 아직 아리달송해.
락이라는 거 자체가 화장실에 비유할 수 있어. 화장실에 들어간 사람이 문을 잠근 일반적인 상황이라 볼 수 있어.
다수의 사람들이 급하게 사용하려 할 때 경합이 붙어.
간발의 차이로 왼쪽애가 들어가면 자물쇠를 잠글거야, 볼일 다 보면 다음 사람이 들어가서 자물쇠를 잠그는 그런 상황이 될거야.
여기까지는 락을 하나 사용하는 거. 문제 없는데
경우에 따라서 자물쇠가 2개인 경우가 생긴다. 이 때 반드시 2개를 동시에 모두 잠궈야지만 사람이 안에 들어갈 권한을 얻게 된다고 보면 되는데
만약에 운이 나쁘게 왼쪽 애는 1번 자물쇠 잠근 상태고, 오른쪽 애는 자물쇠2를 잠군 상태라고 보자. 서로 조금씩 반쪽 짜리 승자가 되었다고 볼 수 있다. 근데 반드시 2개를 다 내가 획득을 해야지만 최종 승자가 되는 거니까 나머지 하나도 획득하려고 시도를 할거야.
이런 노란색 화살표로 나머지 애도 트라이해서 잠궈 보겠다 시도하는거.
여기서 발생하는 문제가 서로 반대쪽 애가 자물쇠를 해제 해주기를 기다리는거. 하지만 불행하게도 서로 양보를 하지 않고 있는거.
사실 서로의 존재를 잘 모르기 때문에 왼쪽 애는 무작정 기다리다가 아래 자물쇠 획득하면 승자가 되어서 들어갈 수 있겠구나 하고 굉장히 나이브하게 생각을 하고 있는거고, 오른쪽 애도 이미 반쪽짜리 자물쇠 획득하고 있는 상황이니까 위에 있는 자물쇠 획득하면 들어갈 수 있겠구나. 기다리고 있는거. 서로 놔주길 기다리는 꼬인 상태.
그러면 다시 돌이켜서 이 문제가 왜 발생했는지 생각해 보면 자물쇠 2개 있는 것도 문제지만 꼭 자물쇠 2개라고 문제 발생하는 건 아냐. 문제 발생한 이유는 서로 순서가 다르기 때문이야. 첫번째 애는 자물쇠1을 잠그고 2를 잠그려 시도를 했고, 오른쪽 애는 2번부터 잠그고 1번 잠그려 해서 발생한 문제 라고 볼 수 있어.
만약 통일된 규칙을 정해서 무조건 위의 자물쇠 부터 잠궈야 한다고 하면 문제가 사라진다.
사실상 위의 자물쇠를 잠근 시점부터 승자가 결판이 나기 때문에 아까쳐럼 서로 물고 늘어지는 현상은 발생하지 않을거야. 즉 경쟁을 통해 위의 자물쇠를 먼저 획득한 애가 나머지 하나도 잠궈 가지고 최종 승자가 되는 그런 상황이라고 볼 수 있다.
이 상황이 UserManager랑 AccountManager로 실습한 상황이랑 유사하다고 보면 된다.
다시 코드로 돌아와서 보면 원인이 명확하다
한쪽에서 userLock 잡고, accountLock 잡으려 하고
다른 한쪽에서는 accountLock 잡고 userLock 잡으려 하기 때문에
서로 반쪽짜리 승자가 등장을 했기 때문에 발생한 문제다.
이 문제를 해결하는건 간단해. 전체적인 락 순서를 맞춰주면된다.
예를 들면 accountLock을 무조건 먼저 잡아야 된다.
#include "pch.h"
#include "AccountManager.h"
#include "UserManager.h"
void AccountManager::ProcessLogin()
{
// accountLock
lock_guard<mutex> guard(_mutex);
// userLock
User* user = UserManager::Instance()->GetUser(100);
// TODO
}
이것에 맞춰서 UserManager::ProcessSave도 accountLock이 먼저 실행이 되도록
#include "pch.h"
#include "UserManager.h"
#include "AccountManager.h"
void UserManager::ProcessSave()
{
// accountLock
Account* account = AccountManager::Instance()->GetAccount(100);
// userLock
lock_guard<mutex> guard(_mutex);
// TODO
}
이렇게 고쳐 주면 데드락 문제는 1차적으로 해결이 될거야.
이 상황에서 실행을 해보면

이제는 항상 빠져나오는 걸 볼 수 있다.
이론상으로는 그렇긴 한데 사실 모든 경우에 대해 이렇게 순서를 맞춰 주는게 어려운 일이긴 하다.
데드락이 짜증나는 점은 어떨 때는 발생하고, 어떨 때는 발생 안하는 상황이 빈번하게 발생하기 때문에 짜증이 난다.
아까 얘기한 대로 한번씩만 실행하면 발생 안하긴 했는데 MMORPG 생각하면 개발 단계에서는 발생 안하다가
라이브 단계에서 동접이 많아질 때만 이게 극악의 확률로 1주일에 한번씩 터지는 버그가 있다면 개발 단계에서 잡기 힘들 수 있어.
버그 터졌을 때 사후 처리는 쉬운 경우야.
항상 순서를 항상 맞춰줄 방법이 있는가? 없다. 실수를 줄이기 위해 일반적으로 사용하는 방법이 있어.
락 사이의 순서를 맞춰 주기 위해서 락끼리의 번호를 매겨주는 것이다.
mutex를 바로 사용하는게 아니라 Wrapping을 해서 별도의 클래스로 만들어 준 다음에 일종의 ID를 부여 해서 ID가 무조건 큰 애가 먼저 실행이 되어야 한다고 해서 추적을 하는 거. 락을 걸 때 마다 추적을 해서 1000번째 락을 잡았는데 그 다음에 숫자가 더 작은 500대 락을 잡으면 문제라고 판별을 하는 식으로 꼼수를 이용해서 할 수 있긴 한데 그것도 정확하진 않다. 왜냐하면 결국에는 하이어라키를 만들어 주는 것도 우리가 손수 하는 것이기 때문에 방대한 코드에서 모든 경우를 예측하고 순서를 맞춰 주기는 어렵기 때문이야.
결론을 말하면 이 데드락은 미연에 예방을 하는게 아니라 조심하면서 사용해야 한다.
하지만 문제가 일어나면 고치기는 쉽다.
참고로 보여드리자면
int main()
{
std::thread t1(Func1);
std::thread t2(Func2);
t1.join();
t2.join();
cout << "Jobs Done" << endl;
// 참고
mutex m1;
mutex m2;
std::lock(m1, m2); // m1.lock(), m2.lock();
lock_guard<mutex> g1(m1, std::adopt_lock);
lock_guard<mutex> g2(m2, std::adopt_lock);
}
UserManger, AccountManager은 따로 각각 mutex가 배치되어 있으니까 좀 다르긴 한데
만약 경우에 따라서 동시 다발적으로
mutex m1;
mutex m2;
를 동시에 활용하고 있다고 하면
이걸 std::lock(m1, m2); 를 이용해 이렇게 잠그면
std::lock(m2, m1);으로 하나 std::lock(m1, m2);로 하나 알아서 내부적으로 어떤 일관적인 순서를 이용해 잠궈주는 기능도 한다.
실전에서 자주 활용할 일은 없지만 이런 기능도 있다.
내부적으로는 m1.lock(), m2.lock(); 을 해준 거.
여기서 락을 풀어주는 건 직접 해야 한다.
// adapt_lock : 이미 lock된 상태니까, lock까지 해줄 필요는 없고 나중에 소멸될 때 풀어주기만 해라는 힌트 주고 있는거.
lock_guard<mutex> g1(m1, std::adopt_lock);
lock_guard<mutex> g1(m1); 만약 이렇게 adapt_lock없으면 그냥 지금 lock 하라는 의미.
이런식으로 활용하게 되면 순서를 신경 쓸 필요 없이 일관적인 순서로 잠궈 준다고 보면 된다.
template <class _Lock0, class _Lock1, class... _LockN>
void lock(_Lock0& _Lk0, _Lock1& _Lk1, _LockN&... _LkN) { // lock multiple locks, without deadlock
_Lock_nonmember1(_Lk0, _Lk1, _LkN...);
}
lock을 보면 템플릿으로 되어 있고, 여러개 넣어 줘도 일관적인 순서를 보장해주는 방법도 있어.
결국 오늘은 문제 제기는 했지만 완벽한 해결 방법은 없었어.
나중에 멀티 스레드 후반에 가면 선생님 같은 경우는 어떻게 하냐면 100프로 해결법은 아니지만 락을 실시간으로 추적하는 일종의 락 매니져를 만들어준 다음에 사이클이 발생하는지를 추적을 해서 잡는 편이야.
자료구조 중에서 그래프 알고리즘이 있는데 순서에 따라서 문제가 일어날 수 있는 상황은 그래프로 치면 사이클이 일어나는 상황이라 볼 수 있어.
락을 잡는 순서를 일종의 그래프로 만들 수 있어. 그래프 알고리즘에서 이런 식으로 사이클이 일어나는지 안일어나는지 판별하는 코드를 만들 수 있는데 선생님은 이런 방법을 넣어서 디버그 상태에서 사이클이 일어나는지 판별해서 데드락을 잡는 편이야.
다양한 방법이 있긴 하지만 데드락을 100프로 방지할 수 있는 방법은 아니야. 극악의 확률로 생기는 경우도 있기 때문.
락의 순서에 대한 내용을 기억 하자.
반응형
'Server programming' 카테고리의 다른 글
02_08_멀티쓰레드 프로그래밍_Sleep (0) | 2022.08.05 |
---|---|
02_07_멀티쓰레드 프로그래밍_SpinLock (0) | 2022.08.05 |
02_06_멀티쓰레드 프로그래밍_Lock 구현 이론 (0) | 2022.08.03 |
02_04_멀티쓰레드 프로그래밍_Lock 기초 (0) | 2022.08.01 |
02_03_멀티쓰레드 프로그래밍_Atomic (0) | 2022.07.29 |
댓글