[STL 컨테이너의 병렬 처리 위험성]
데이터의 공유 단위가 변수가 아닌 컨테이너일 경우(대부분의 STL 컨테이너에서), 병렬 작업에 굉장히 취약해집니다. vector를 통해 확인해보겠습니다.
#include <iostream>
#include <vector>
#include <thread>
vector<int32> vec;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
vec.push_back(i);
}
}
int main()
{
thread temp1(Push);
thread temp2(Push);
temp1.join();
temp2.join();
cout << vec.size() << endl;
}
vector에 자료가 할당된 메모리만큼 가득차면, 새로운 메모리를 할당한 뒤 그곳으로 값을 복사해서 이동시키고, 원래 메모리는 소거시킵니다. 이 과정은 원자성을 띄지 않기 때문에, 단순히 병렬 처리를 할 경우 크래시가 발생하게 됩니다. 그렇다고 reserve(20,000)이 해결책은 아닙니다. 크래시가 발생하진 않지만, size의 결과값이 20,000보다 작게 나옵니다.
1. reserve를 실시하면 vector 공간에 20,000개 생성 ([][][][][][][][]....)
2. 이제 데이터를 0부터 2번 인덱스까지 1, 2, 3을 넣었다고 가정 ([1][2][3][][][][][].... )
3. 벡터는 다음 공간에 값을 넣기 위해 인덱스를 계속 파악중인 상태
4. 싱글 쓰레드 환경이라면 다음의 숫자 4를 넣기 위해 3번 인덱스에 넣겠지만, 멀티 쓰레드 환경에서 쓰레드들이 작업을 진행하면 병렬 처리가 진행됨
5. 1번 쓰레드가 3번 인덱스에 4를 밀어넣으려는 순간, 2번이 동시에 3번 인덱스에 4를 넣어야겠다고 판단할 수 있음
6. 이 과정이 여러 번 발생할 수 있음
7. 따라서 20,000이하의 값이 출력됨
[Lock(mutex)]
이럴 때 atomic이 아닌 lock을 활용할 수 있습니다. Windows에서는 <windows.h>를 include 한 뒤, CirticalSection() 함수를 호출하여 기능을 활용 할 수 있지만, C++에서 호환성 문제 해결을 위하여 <mutex> 기능을 만들어두었습니다.
#include <iostream>
#include <thread>
#include <mutex>
vector<int32> vec;
mutex mu;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
mu.lock();
vec.push_back(i);
mu.unlock();
}
}
int main()
{
thread temp1(Push);
thread temp2(Push);
temp1.join();
temp2.join();
cout << vec.size() << endl;
}
mutex 헤더 참조 후 변수를 선언한 뒤, lock을 걸고싶은 지점에 lock() 함수를 호출해주면 됩니다. 이러면 스레드가 해당 영역에 접근 시, 다른 스레드는 접근이 불가능해집니다. 이후 unlock()을 통해 작업이 끝난 스레드가 탈출할 수 있도록 선언해주면 됩니다. unlock()을 실시하지 않으면 lock()을 통해 작업에 들어간 스레드가 무한히 대기하는 현상을 확인할 수 있습니다.
mutex는 데드락 회피 기법 중 하나인 상호배타적(Mutaul Exclusive)의 앞글자를 따온 것으로, 보통 설명할 때 화장실을 이용하는 원칙에 비유하여 설명하곤 합니다.
1. 화장실에 가기 전 키를 챙김
2. 잠긴 화장실을 키로 염
3. 들어가서 문을 잠금
4. 볼 일을 본 뒤 문을 열고 나와 키를 제자리에 둠
충분히 좋은 기능이지만, 남용은 좋지 않습니다. 하나의 스레드만 작업을 진행할 수 있도록 하는 방법이기 때문에 경합 조건이 심해지고 싱글 스레드를 활용하는 것과 다름없는 방식이라고도 볼 수 있기 때문입니다.
[재귀적 Lock]
lock(), unlock() 선언에도 주의할 점이 몇 가지 있는데, 먼저 아래와 같은 선언은 크래시가 발생하게 됩니다.
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
mu.lock();
mu.lock();
vec.push_back(i);
mu.unlock();
mu.unlock();
}
}
이런 형태를 재귀적 lock이라고 하는데, 이는 recursive_lock이라고 하여 다른 버전으로 지원해줍니다. 재귀적 lock은 보통 mmo server와 같이 방대하고 복잡한 코드가 있을 때 유용하게 활용됩니다(lock 안에 다른 함수를 호출하고 또 다른 함수를 호출하는 방식으로 진행할 경우 활용).
[lock_guard]
lock() 이후 unlock() 과정을 빼먹으면 인텔리전스가 해당 오류를 잡아주지만, 종종 실수가 있을 수 있습니다.
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
mu.lock();
vec.push_back(i);
if (i == 5000)
{
break;
}
mu.unlock();
}
}
break를 통해 반복문을 탈출할 경우 unlock()을 수행하지 못해 작업이 끝난 스레드가 해당 영역에서 무한히 대기하게 됩니다. break에도 unlock()을 실시해도 되지만, 조건이 추가될때마다 unlock()을 걸어버리면 코드 복잡성이 증가하고 유지보수가 힘들어질 것입니다.
이를 위해 lock_guard()를 활용할 수 있습니다. lock_guard는 RAII(Resource Acquisition is Initialization) 패턴을 활용하여 생성 시 lock(), 소멸 시 unlock()이 가능하도록 설계되어 있습니다. 아래에 임의로 만든 lockguard 클래스가 있는데, STL 자료구조에서 지원해주는 std::lock_guard와 동일한 로직으로 동작합니다.
template<typename T>
class LockGuard
{
public:
// 값을 받고 생성자에서 직접 lock 호출
LockGuard(T& _m)
{
_mutex = &m;
_mutex->lock();
}
// 소멸 시 unlock 실시
~LockGuard()
{
_mutex->unlock();
}
private:
T* _mutex;
};
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
LockGuard<mutex> lockGuard(mu);
vec.push_back(i);
if (i == 5000)
{
break;
}
}
}
std::lock_guard는 아래와 같이 사용하면 됩니다.
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
std::lock_guard<std::mutex> lockGuard(mu);
vec.push_back(i);
if (i == 5000)
{
break;
}
}
}
사용이 완료되면(스택 영역을 빠져나가면), 자동으로 소멸자를 호출하면서 객체 스스로의 데이터, 메모리도 소거하고 unlock()도 실시해주기 때문에 lock() unlock() 선언의 스트레스 없이 편리하게 사용할 수 있습니다.
[lock_guard 종류]
lock_guard도 여러 종류가 있습니다. 먼저 unique_guard 입니다.
std::unique_guard<std::mutex> uniquelockGuard(mu);
unique_guard 생성 시 두 번째 인자로 defer_lock을 전달해줄 경우, 객체 생성과 동시에 lock을 생성하지 않고, 나중에 따로 호출을 통해 lock을 실시할 수 있도록 기능을 지원해줍니다. 물론 추가적인 기능을 활용하는 것이기 때문에 약간의 오버헤드가 발생하지만, 경우에 따라서는 활용 시 좋은 점이 있습니다.
lock은 선언 위치에 따라서도 활용 방식이 달라지는데, 위에서는 for문 내에 lock()을 실시하고 있지만, 실제로 이런 동작은 매 순간 lock()을 위해 객체를 만들고 소멸을 반복하게 됩니다. 이럴 때는 for문 바깥에 lock()을 선언해주는 것이 좋을 수도 있습니다.
void Push()
{
LockGuard<mutex> lockGuard(mu);
for (int32 i = 0; i < 10'000; i++)
{
vec.push_back(i);
if (i == 5000)
{
break;
}
}
}'서버' 카테고리의 다른 글
| [Server] 스핀락(Spinlock)과 sleep (0) | 2025.11.14 |
|---|---|
| [Server] 데드락(DeadLock) (0) | 2025.03.17 |
| [Server] atomic (0) | 2025.03.17 |
| [Server] 스레드 (0) | 2025.03.17 |