동기화객체 성능테스트
멀티스레드와 동기화 객체
멀티스레드 프로그래밍은 하나의 기능을 여러 스레드가 동일하게 실행하는 호모지니어스 방식과, 업무를 다르게 각자의 스레드로 분배하는 헤테로지니어스 방식으로 구분됩니다.
예를 들어, 게임 서버에서 길찾기 기능을 하나의 스레드로 분리하고, 네트워크 IO를 하나의 스레드로 분리한다면, 이는 헤테로지니어스 방식의 멀티스레드 설계입니다.
하지만, IOCP의 워커 스레드와 마찬가지로 하나의 동일한 로직을 수행하는 스레드가 여럿 있다면, 이는 호모지니어스 방식의 멀티스레드입니다.
이러한 멀티스레드 프로그래밍에서는 주의해야 할 점들이 생깁니다. 동기화 문제입니다. 가장 단순한 연산인 증감 연산마저도 CPU atomic하지 않기에, 수행 도중 context switching이 발생한다면 적용한 데이터가 새 데이터를 덮어쓰거나, 옛 데이터를 가지고 작업하는 일이 발생하게 됩니다.
따라서 어떠한 자원에 접근할 때, 해당 자원을 동시에 하나의, 혹은 지정된 스레드만 접근이 가능하도록 ‘동기화 객체’를 사용하여 스레드의 접근을 제어하게 됩니다.
유저 모드와 커널 모드
이러한 동기화 객체를 깊게 알아보기 위해서는, 유저 모드와 커널 모드에 대해 알아야 합니다. 우리가 실행하는 대부분의 연산은 유저 모드에서 이루어지지만, OS의 도움을 받거나 HW의 도움을 받아야 하는 작업들은 커널 모드 상에서 동작되게 됩니다. System call이 필요하다면, CPU의 보호 레벨을 올리고 커널 모드로 전환하여, 커널 메모리 영역의 함수를 실행하고 메모리를 참조하는 식으로 동작되게 됩니다.
이는 캐시의 시공간적 지역성을 해칠 뿐만 아니라, CPU의 보호레벨 제어 등의 상당한 오버헤드를 가지고 있기에, 커널 모드 전환의 횟수를 줄이는 것에 유념하며 프로그래밍 해야 성능을 향상시킬 수 있습니다.
따라서 동기화 객체별로 어느 모드에서 동작하고, 어떨 때 모드 전환이 일어나는지도 살펴봅니다.
Atomic과 Interlocked
Interlocked 연산은 CPU에서 지원하는 원자적인 연산으로서, 이는 사용 방법이 간단하고, 유저 모드로서 최소한의 접근을 지원하기 때문에 효율적입니다.
Interlocked는 캐시 라인을 잠금으로서 다른 스레드에서의 메모리 가시성 문제로부터 자유로울 수 있도록 해줍니다.
C++11에 추가된 Atomic 타입도 마찬가지로, CPU가 제공하는 원자적인 연산을 사용하며, 단일 변수에 대한 간단한 연산만을 지원하기 때문에 사용 범위가 제한적이라는 단점이 있습니다.
벤치마크
위 벤치마크 결과에 따르면, atomic연산보다 Interlocked 함수를 사용하는 것이 조금 더 효율적임을 볼 수 있습니다. 이는 atomic이 내부적으로 동작을 Interlocked로 동일하게 수행하지만, 연산자 오버로딩을 통한 동작 과정에서 캐스팅 등 상대적으로 추가적인 작업이 더해지기 때문입니다.
_TVal operator++() noexcept {
return static_cast<_TVal>(_InterlockedIncrement(_Atomic_address_as<long>(this->_Storage)));
}
또한, Interlocked는 스레드 개수가 2개 이상일 때, 스레드 개수가 증가함에도 일정한 성능을 보장합니다. 이는 동기화 객체의 잠금과 해제로 동작하는 것이 아닌, 캐시 라인을 잠그고 out of ordering 문제를 해결하기 위해 load buffer와 store buffer를 잠그는 식으로, 스레드 개수와는 연관성이 없이 동작하기 때문입니다.
이처럼 Interlocked를 사용하는 방식에서는 한 변수에 대한 원자적인 연산만 가능하기에, 동기화 객체로서 일정 범위에 대한 배타적인 접근을 구현하기 위해서는 flag 방식의 동작을 구현해야 합니다. 이는 CRITICAL_SECTION 등이 사용하는 방법입니다.
Spinlock
스핀락은 다른 동기화 객체와는 상당히 다른 구조를 가지고 있습니다. spinlock이라는 명칭을 가지는 동기화 객체가 아닌, 사용중인지에 대한 변수 flag를, Interlocked 연산을 통해 설정하고 검사하는, (Test and Set) 방식으로 동작하기 때문입니다. 따라서 해당 flag가 아무도 사용중이지 않음을 나타낼 때까지 루프를 돌며 무한히 체크하다가, 내가 진입하며 이를 true로 설정하는 식으로 동작합니다.
따라서, 굉장히 효율적이기도, 대부분의 경우는 비효율적인 방식이라고 볼 수 있습니다. 매우 빠르게 끝나는 것이 기대되면서 (time quantum 이내), 하려는 작업이 다른 스레드들보다 우선순위가 높은 경우, 해당 스레드가 블로킹 상태로 진입하지 않고, 남은 time quantum시간동안 계속 flag를 검사하게 됩니다. 이 경우, 만일 다른 스레드의 작업이 빠르게 끝났다면, 해당 스레드는 블락 상태에 진입하지 않고, context switching 되기 전 작업을 수행할 수 있게 됩니다.
이 방식은 일반적으로 매우 비효율적이지만, 성공한다면 어마어마한 효율을 낼 수 있기 때문에, C++에서는 다양한 동기화 객체가 실제 blocking 상태로 진입하기 전 짧게 spinlock을 시도하는 식으로 동작하는 것을 어셈블리를 통해 확인할 수 있습니다.
벤치마크
위 이미지를 보면, 스핀락은 스레드의 개수가 증가하면 증가할수록, lock과 unlock을 하는 횟수가 증가할수록 소모되는 시간이 가파르게 증가합니다. 위 항목들 중 8 threads, logicPerLock 1의 경우, 총 8천만의 증가 연산을 수행하기 위해 18초 이상 걸리는 모습을 볼 수 있는데요, 이는 8개의 스레드가 한치의 양보도 없이 자신의 타임 퀀텀을 모두 소모하며, 그 중 하나의 스레드만 하나의 증가 연산을 수행하고 빠져나옴을 반복하기 때문입니다.
CRITICAL_SECTION
CRITICAL_SECTION은, 가장 기본적으로 사용되는 동기화 객체입니다. EnterCriticalSection을 시도할 때, 만일 해당 CRITICAL_SECTION 객체가 사용중이라면, 요청한 스레드는 블락 상태로 전환되고 (잠깐의 스핀락 이후), 이후 진입했었던 스레드가 LeaveCriticalSection을 하게 되면, 블락 상태로 기다렸던 스레드가 깨어나 EnterCriticalSection 이후 코드가 수행되게 됩니다.
이렇게 기본적인 개념을 가지고 있는 CRITICAL_SECTION은, 두 가지 장점 또한 가지고 있습니다.
우선, CRITICAL_SECTION은 해당 CRITICAL_SECTION 구조체에서 count방식으로 동작되기 때문에 하나의 스레드 내부에서 중첩되어 호출되어도 전혀 문제가 없습니다. 또한, 해당 임계 구역이 사용중인지 동기화 객체를 통해 확인할 때, 커널 모드가 아닌 유저 모드로 동작합니다.
벤치마크
위 벤치마크 결과에 따르면, 스레드가 증가함에 따라 소모되는 시간 또한 증가합니다. 이는 한 번에 하나의 스레드만 진입이 허용될 때, 많게는 7개까지의 스레드가 blocked 상태로 전환될 것이기 때문입니다. 또한, lock당 연산 수행 횟수가 적다면 (즉, 그만큼 lock, unlock)이 많이 수행된다면 이 또한 성능에 큰 영향을 주는 것으로 나타납니다. 이는 이후 SRWLock 때 이어서 언급하겠습니다.
SRWLOCK
SRWLock은 Slim Read-Write Lock으로서, 동기화 객체의 일종입니다. 이 또한 블락 상태에 들어가기 전, 임계 영역 검사는 유저 모드에서 최대한 동작합니다.
하지만 이것이 가지는 가장 큰 특징은, 바로 쓰기와 읽기 모드, 즉 배타적인 모드와 공유 모드로 구분된다는 점입니다.
기존에는 멀티스레드 공유 자원 데이터를 읽거나 쓸 때 모두 동일한 동기화 객체를 사용하였습니다. 따라서 쓰기 한 번에, 읽기 100번이 일어나는 변수 또한 매번 101번의 락과 언락이 필요한 구조입니다.
하지만 SRWLock은 Read, Write모드를 구분합니다. 이는 Read는 한 번에 여러 스레드가 접근해도 문제가 없고, Write가 일어날 때만 다른 Write와 Read가 블락되게 한다면 가장 효율적인 동기화가 가능하기 때문입니다. 따라서 CRITICAL_SECTION으로 읽기가 빈번하게 일어나는 변수에 동기화를 사용했을 경우, 이를 SRWLock으로 Read, Write 구분하여 동기화를 걸어주게 되면 성능 향상이 그만큼 일어나게 됩니다.
벤치마크 (WRITE LOCK)
위 벤치마크 결과에 따르면, 이전 CRITICAL_SECTION과 거의 차이가 없습니다. 이는, Write Lock만 가지고 동기화가 걸렸기 때문이고, 이 경우 CRITICAL_SECTION과 동작상의 차이가 전혀 없기 때문입니다. 혹은, 스핀락을 의심해볼 수 있습니다.
만일 동기화를 거는 자원이 읽기 모드로 자주 접근이 필요한 경우라면, CRITICAL_SECTION과 Read Write를 구분한 SRWLock의 성능 차이는 그만큼 벌어지게 됩니다.
일부 테스트의 경우 SRWLock이 CRITICAL_SECTION보다 더욱 월등한 성능을 보일수도 있다고 하기에, 개발하고 서비스하는 환경에서의 실제 프로파일링을 통해 검증해야 합니다.
MUTEX
뮤텍스는, 일반적인 CS 상에서 언급될 때에는 ‘커널 모드 상에서 동작하는 동기화 객체’를 의미합니다. 따라서, 유저 모드로 우선 검사를 시도하는 CRITICAL_SECTION이나 SRWLock보다 낮은 성능을 보일 수 있다고 생각할 수도 있지만, C++ 표준상에 포함된 std::mutex는 유저모드 동기화 락입니다. 내부 구현이 Windows기준 CRITICAL_SECTION이나 SRWLock으로 이루어지게 됩니다.
코드 모음
벤치마킹 코드
DWORD TestThread(ThreadFuncionType threadFunction, bool isAtomic = false)
{
ULONGLONG timeElapsed = 0;
ULONG valueResult = 0;
INT32 logicPerLoop = 0;
INT32 threadCount = 0;
ThreadParam threadParam;
for (int testingThread = 0; testingThread < 4; testingThread++)
{
if (testingThread == 0) threadCount = THREAD_COUNT1;
else if (testingThread == 1) threadCount = THREAD_COUNT2;
else if (testingThread == 2) threadCount = THREAD_COUNT3;
else if (testingThread == 3) threadCount = THREAD_COUNT4;
HANDLE *handles = new HANDLE[threadCount];
for (int testingLevel = 0; testingLevel < 4; testingLevel++)
{
if (testingLevel == 0) logicPerLoop = TEST_LOGIC_PER_LOOP1;
else if (testingLevel == 1) logicPerLoop = TEST_LOGIC_PER_LOOP2;
else if (testingLevel == 2) logicPerLoop = TEST_LOGIC_PER_LOOP3;
else if (testingLevel == 3) logicPerLoop = TEST_LOGIC_PER_LOOP4;
threadParam.logicPerLock = logicPerLoop;
threadParam.logicCount = TEST_COUNT / threadCount;
DWORD timeStart = timeGetTime();
for (int i = 0; i < threadCount; i++)
{
handles[i] = (HANDLE)_beginthreadex(nullptr, 0, threadFunction, &threadParam, false, nullptr);
}
WaitForMultipleObjects(threadCount, handles, true, INFINITE);
DWORD timeEnd = timeGetTime();
timeElapsed = timeEnd - timeStart;
if (!isAtomic) valueResult = value;
else valueResult = valueAtomic;
value = 0;
valueAtomic = 0;
for (int i = 0; i < threadCount; i++)
{
CloseHandle(handles[i]);
}
cout << threadCount << " threads\t\t" << "logicPerLock : " << logicPerLoop << "\t\t" << "total " << timeElapsed << "ms\t\t" << "value is " << valueResult << endl;
}
delete[] handles;
}
return 0;
}
SRWLock 테스트 스레드
UINT WINAPI SWThreadProc(LPVOID params)
{
ThreadParam threadParam = *(ThreadParam *)params;
for (INT32 countWorked = 0; countWorked < threadParam.logicCount; countWorked += threadParam.logicPerLock)
{
AcquireSRWLockExclusive(&value_srw_write);
for (INT32 countLoop = 0; countLoop < threadParam.logicPerLock; countLoop++)
{
++value;
}
ReleaseSRWLockExclusive(&value_srw_write);
}
return 0;
}
'연구한 이야기 > 깊게 공부해보기' 카테고리의 다른 글
IOCP 서버 코어 개발하기 (2) | 2023.06.06 |
---|---|
패킷 직렬화버퍼 (0) | 2023.05.02 |
메모리풀과 프리리스트 (0) | 2023.05.02 |
TCP와 링버퍼 (0) | 2023.05.02 |
더 빠른 길찾기 (0) | 2023.05.02 |