atomic vs. lock
지난 시간에 배운 atomic보단
이번 시간에 배울 lock을 사용하는 게 일반적이다
STL 자료구조와 멀티스레드 환경
STL 자료구조는 멀티스레드 환경에서
각 상황에 따른 다양한 이유로 인해 정상적으로 동작하지 않는다
- 예시: 더블프리문제
vector<int32> v;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
v.push_back(i);
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
위의 코드를 실행하면, 크래시가 발생한다
그 이유는 아래와 같다
1번 스레드와 2번 스레드가 동시에 vector가 꽉 찬 걸 확인하고
1번이 새로운 동적배열을 만들고 복사하고 기존 동적배열은 삭제했는데
2번이 새로운 동적배열을 만들고 복사를 시도하려 하니 기존 배열이 이미 삭제된 버린 상황이 발생한다
- 해결?: reserve
vector<int32> v;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
v.push_back(i);
}
}
int main()
{
v.reserve(20000);
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
reserve를 이용해 해결하면 위의 크래시 문제는 발생하지 않는다
하지만,
push_back이 원하는 개수만큼 되지 않아서
v.size()가 20000이 아닌 값이 출력된다
그 이유는
동시에 원소를 push_back하는 경우가 일부 발생해서
씹힌 것이다
- 해결?: atomic
std::atomic<vector<int32>> v;
v.push_back(i);// 빨간줄 발생 (기능 사용 불가)
v.size();// 빨간줄 발생 (기능 사용 불가)
atomic을 이용해 해결하면 되지 않을까 생각할 수 있다
하지만, atomic으로 래핑하게 되면
기존 vector 기능 사용 불가하기에 적절치 않다
mutex
위와 같은 상황에서 적절한 해결방법은
mutex를 이용하면 된다
mutex는 mutual exclusive(상호배타적)의 약자이다
자물쇠 같은 개념으로
lock과 unlock을 통해 하나의 스레드만 해당 작업을 진행하도록 하는 것이다
결과적으로는 단일 스레드처럼 동작하게 되니 느려진다는 단점이 있다
#include <mutex>
vector<int32> v;
mutex m;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
m.lock();
v.push_back(i);
m.unlock();
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
mutex의 lock 재귀
mutex lock을 재귀적으로 사용하기 위해
아래와 같이 코드를 작성하면, 크래시가 발생한다
필요하다면, recursive_mutex를 활용해야 한다
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
m.lock();
m.lock();
v.push_back(i);
m.unlock();
m.unlock();
}
}
mutex의 lock unlock 세트
mutex를 사용하는 경우
아래의 코드처럼
lock을 하고 unlock을 안 하면
프로그램이 계속 도는 무한대기 상태가 되기에
반드시 같이 세트로 작성을 해줘야 한다
#include <mutex>
vector<int32> v;
mutex m;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
m.lock();
v.push_back(i);
if (i == 10)
break;
m.unlock();
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
mutex의 불편함
위에서 살펴봤듯이
lock과 unlock을 수동 관리하는 것은 매우 귀찮고 위험하다
따라서, RAII 방식인
wrapper 클래스를 통해 생성자에서 잠그고 소멸자에서 풀도록 구현하면 좋다
#include <mutex>
vector<int32> v;
mutex m;
template<typename T>
class LockGuard
{
public:
LockGuard(T& m)
{
_mutex = &m;
_mutex->lock();
}
~LockGuard()
{
_mutex->unlock();
}
private:
T* _mutex;
};
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
LockGuard<std::mutex> lockGuard(m);
v.push_back(i);
if (i == 10)
break;
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
lock_guard와 unique_lock
위에서 직접 만든 wrapper 클래스가 아닌
STL에서 제공하는 wrapper 클래스가 바로
std::lock_guard<std::mutex>이다
std::unique_lock<std::mutex>는
lock_guard와 달리 바로 잠그는 것이 아닌
수동으로 lock을 해주는 시점을 정할 수 있다
#include <mutex>
vector<int32> v;
mutex m1;
mutex m2;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
// lock_guard 활용
lock_guard<std::mutex> lockGuard(m1);
// unique_lock 활용
unique_lock<std::mutex> uniqueLock(m2, std::defer_lock);
uniqueLock.lock();
v.push_back(i);
if (i == 10)
break;
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
Lock 범위
lock_guard 작성 위치에 따라 Lock 범위는 달라진다
아래의 코드에서
lockGuard1은 Push 함수가 실행될 때부터 lock이 잠기고(생성) 해당 함수가 종료되면 unlock(소멸)된다
lockGuard2는 반복문이 돌 때마다 lock이 잠기고(생성) unlock(소멸)된다
#include <mutex>
vector<int32> v;
mutex m1;
mutex m2;
void Push()
{
lock_guard<std::mutex> lockGuard1(m1);
for (int32 i = 0; i < 10'000; i++)
{
lock_guard<std::mutex> lockGuard2(m2);
v.push_back(i);
if (i == 10)
break;
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << endl;
}
'게임 서버' 카테고리의 다른 글
[게임서버 섹션2 Note] Lock 구현 이론 (0) | 2024.12.29 |
---|---|
[게임서버 섹션2 Note] DeadLock (0) | 2024.12.29 |
[게임서버 섹션2 Note] Atomic (0) | 2024.12.28 |
[게임서버 섹션2 Note] 스레드 생성 (0) | 2024.12.28 |
[게임서버 섹션2 Note] 멀티스레드 개론 (0) | 2024.12.28 |