SRWLock 빠른 성능의 비결

Windows Vista부터 추가된 스레드 동기화 API가 있다.
AcquireSRWLockExclusive()/ReleaseSRWLockExclusive()
AcquireSRWLockShared()/ ReleaseSRWLockShared() 가 그것이다.
뭐 함수이름을 보면 금방 눈치챌 수 있듯이 여러 스레드가 동시에 락을 읽기 전용으로 획득하려고 AcquireSRWLockShared()를 호출한다면 대기없이 않고 바로 락을 통과한다. 쓰기 작업을 해야할 경우 AcquireSRWLockExclusive()를 호출하면 된다. 이 경우는 다른 스레드가 AcquireSRWLockShared()로 락을 획득하려고 하는 경우에도 무조건 락이 걸린다.

이게 중요한게 아니고…
이 함수의 내부 구현에 대해서 얘기해보고자 한다.
이 함수는 굉장히 빠르다. 무슨 얘기나면 락을 획득하는 과정-

1)다른 스레드가 점유하지 않은 락을 먼저 획득하는 경우,
2)다른 스레드가 먼저 점유한 락을 대기하다가 획득하는 경우,

모두 빠르다. 예전엔 그래도 Critical Seciton이 빠르다고 알려져 있었지만 비교도 안되게 빠르다. 응답성이 확실히 좋다.
이게 어떻게 가능할까?

간단히 EnterCriticalSectionAndSpinCount()처럼 내부적으로 Spin-Count를 이용할거라고 추측할 수 있다. 그러니까 대기중이다가도 다른 스레드 빠져나가면 번개같이 락을 획득할 수 있겠지.
Critial Section의 SpinCount를 설정하지 않는 경우, 락이 이미 걸려있으면 시스템콜을 호출하고 해당 스레드는 wait상태로 들어간다. cpu자원을 점유하지 않는다.
문제는 이 상태가 되면 다른 스레드가 LeaveCriticalSection()을 호출해도 대기하던 스레드가 다시 스케쥴링될때까지 시간이 꽤 걸린다. cpu점유율이야 낮을지 모르지만 응답성에선 손해를 꽤 본다.
그리고 이를 보완하기 위해서 EnterCriticalSectionAndSpinCount()에 스핀카운트를 설정해서 임의의 회수만큼 락을 획득하기 위한 시도를 먼저 해볼수 있다.

Critial Section의 경우로 확인할 수 있듯이 Spin-Count를 사용하는 것이 일단 응답성을 높이는 확실한 방법이다. SRWLock도 Spin-Count를 사용한다.

SRWLock은 다음과 같이 작동한다.

  1. 첫번째 스레드가 진입한다.
    • SRWLOCK 구조체의 Ptr값이 0이다. Ptr의 0번째 비트를 검사한다.
    • 비트가 0이라면 ptr의 0번 비트를 1로 설정하고(0b01) 그대로 빠져나간다.
    • OK. 이제 락이 걸린 상태고 경쟁상태는 아니다.
  2. 두번째 스레드가 진입한다.
    • SRWLOCK 구조체의 Ptr값은 1이다.Ptr의 0번째 비트를 검사한다.
    • 비트가 0이 아니므로 그대로 빠져나갈 수 없다.
    • Ptr의 내용(이하 락변수의 내용이라 칭한다)을 다른 메모리에 카피하고 Ptr은 그 어드레스를 가진다. 정확히는 Ptr의 내용을 포함하는 구조체의 어드레스를 가진다. 그리고 그 메모리는 지금 진입한 스레드의 stack공간이다. 즉 지금 진입한 스레드의 로컬변수이다. 세번째 스레드가 진입했을땐 Ptr변수로부터 비트테스트를 바로 하지 않고 Ptr변수가 가리키는 락변수의 비트를 검사하게 된다.
    • 락변수의 1번째 비트를 1로 설정한다(0b11). 이제 경쟁상태이다.
    • 두번째 스레드는 1024번 루프를 돌며 pause명령을 수행한다.
    • SRWLOCK Ptr이 가리키는 메모리-락변수의 1번째 비트를 btr 명령으로 검사하고 0으로 세팅한다. 여기서 다음과 같이 분기가 갈린다.
    • 첫번째 스레드가 RelaseSRWLockXXXX()을 호출하여 락을 해제한 경우.
      • 락변수의 1번 비트값이 0이다(0b01). 이제 두번째 스레드가 락을 획득한 상태이고 경쟁하지 않는 상태이다.
    • 첫번째 스레드가 아직 ReleaseSRWLockXXXX()를 호출하지 않은 경우.즉 아직 락을 쥐고 있는 경우.
      • 락변수의 1번 비트값이 1이다(0b11). 이 경우 1024번의 루프를 돌며 대기했지만 락을 획득하지 못했으므로 call _NtWaitForAlertByThreadId@8 를 수행하여 대기 상태(스레드가 스케쥴링 되지 않은 상태)로 진입한다.

돌아가는 원리는 간단하다. 그럼 이걸 내가 직접 구현해도 되지 않을까?
그래서 다음과 같이 구현해본다.


void AcquireSpinLock(volatile LONG* plCount)
{
LONG	lOldCount;

lb_try:
lOldCount = _InterlockedCompareExchange(plCount,1,0);

if (lOldCount)
{
goto lb_try;
}

}
void ReleaseSpinLock(volatile LONG* plCount)
{
_InterlockedDecrement(plCount);
}

무척 간단한 코드다.

이제 성능 테스트를 해보자.
10000000번 루프를 돌면서 전역 카운트변수를 증가시키는데 걸리는 시간을 측정했다.

테스트 코드는 다음과 같다. 4스레드로 모든 스레드 합쳐 총 10000000번을 호출할때까지 돌아간다.


void TestSpinlock()
{
AcquireSpinLock(&g_lSpinLock);
g_dwCount++;
ReleaseSpinLock(&g_lSpinLock);
}
void TestSRWLock()
{
AcquireSRWLockExclusive(&g_SRWLock);
g_dwCount++;
ReleaseSRWLockExclusive(&g_SRWLock);
}
void TestCriticalSection()
{
EnterCriticalSection(&g_cs);
g_dwCount++;
LeaveCriticalSection(&g_cs);
}

그리고 측정 결과다.

spincount_yield

기대와는 다르게 성능이 많이 떨어진다. 왜 이런 일이 발생했는가? 프로세서가 계속 락을 획득하려고 atomic 오퍼레션이션을 수행하면서 버스가 수시로 잠기고 모든 프로세서가 동작에 방해를 받는다. 다른 스레드가 락을 획득한 상황이라면 약간 기다려주는 편이 성능에 더 도움이 된다. 그런데 커널로 진입해서 대기하면 다음번 스케쥴링할때 지연시간이 너무 길다.
스케쥴링되고 있는 상태는 그대로 유지하면서 아무짓도 안하고 다른 프로세서가 원활히 작동할 수 있도록 해주면 좋겠다. SRWLock의 동작에서 잠깐 언급했던 pause명령어가 그것이다.

AcquireSRWLockXXXX()함수의 내부 코드를 보자.
srwlock_pause

문서를 찾아보면 스핀루프의 성능을 향상시킨다고 나와있다. OK. 이거다.
코드에 추가하기 위해 어셈명령을 직접 사용할 필요는 없다. YieldProcessor()를 호출하면 pause명령으로 바로 바뀐다.

스핀락 함수에 YieldProcessor()를 추가한다.

void AcquireSpinLockWithYield(volatile LONG* plCount)
{
LONG	lOldCount;

lb_try:
lOldCount = _InterlockedCompareExchange(plCount,1,0);

if (lOldCount)
{
for (DWORD i = 0; i < 1024; i++)
{
YieldProcessor();	// __asm pause
}
goto lb_try;
}

}

그리고 다시 성능 테스트.

srwlock_spincout_cs_yield

기대한대로 빨라졌다. 가장 빠르다. 물론 SRWLock대신 이렇게 만들어쓰자는 얘기는 아니다. SRWLock의 성능의 비결이 바로 저것이라는 얘기를 하고 싶었다.

MVP 짤렸지만 아직 기간이 만료된건 아니라서 Windows 소스코드 열람하려면 할순 있을텐데 그냥 어셈코드 보고 분석했다. 몇시간 봤더니 눈이 침침하네.


SRWLock 빠른 성능의 비결”에 대한 답글 9개

  1. Reader Writer lock 이 있는지 이 글을 보고 알았습니다.
    이런 기능이 필요했었는대 감사합니다. 저는 interlocked 로 비슷한기능을 하는 함수를 만들고있었는대 이글을보고 회사 코드에는 이 함수를 넣어야겠습니당. 포스팅해주셔서 감사합니다~

    좋아요

    1. 도움이 되었다니 기쁘네요. 저도 Interlocked로 만들어쓰고 있었는데 사실은 최근에 성능 비교를 해보고 SRWLock이 너무 빨라서 놀라서 따라가보게 됐습니다. 거의 웬만한 경우는 SRWLock을 쓰는게 가장 좋은 방법입니다.

      좋아요

      1. 바로 답글을 달아주셨군용 코드에 적용해보고 테스트를 해보고 만족했습니당 ㅎㅎ 다시한번 감사드립니다.

        좋아요

  2. 안녕하세요 매우 재미있게 봤습니다.
    pause 명령어는 첨알았네요 ㅎㅎ

    스핀락구현에 memory order가 필요할것으로 보였는데
    _InterlockedCompareExchange함수에서 full memory barrier를 제공해주는것처럼 보이네요

    성능상 큰 차이가 없을것 같긴한데 물론 직접구현해서 쓸일은 없다지만…;
    다른 환경에서 더 최적성능을 위해서는 atomic compare exchange는 memory order 관계없이 수행하고
    적절한 memory fence를 사용하면 이득이 있을까요?

    ex)
    acquire함수 끝에 atomic_thread_fence(memory_order_acquire);
    release 함수 앞부분에 atomic_thread_fence(memory_order_release);

    좋아요

    1. 다른 코어가 몇개의 명령어 정도는 실행할 시간을 벌어주기 위해서죠. YieldProcessor한번 호출로는 명령어 1개 실행할 틈도 주지 못하니까요.

      좋아요

댓글 남기기