본문 바로가기

C, C++/WINDOWS VIA C,C++

[WINDOWS VIA C/C++] 08. 유저 모드에서의 스레드 동기화

https://www.hanbit.co.kr/store/books/look.php?p_code=B2974835990

 

제프리 리처의 Windows via C/C++(복간판)

이 책은 윈도우 XP, 윈도우 비스타, 윈도우 서버 2008까지 내용을 포괄한다. 이미 윈도우 10이 출시된 지 오래지만 윈도우의 기본 구조는 변하지 않아 아직까지도 이 책은 윈도우 시스템 프로그래

www.hanbit.co.kr

해당 책을 읽고 학습 목적으로 간단하게 정리한 글입니다.


개요

스레드는 통신 없이 자신만의 작업을 할 때 성능이 좋지만, 스레드는 대부분 독립적으로 실행되지 않는다.

 

스레드는 아래의 상황에서 스레드 간 통신이 필요하다.

  • 스레드들이 공유 리소스에 동시에 접근하며 일관성 유지가 필요할 때
  • 스레드가 다른 스레드에게 작업이 완료되었다는 것을 알려야 할 때

Windows는 여러 스레드 동기화 방법을 제공하며

이번 챕터에서는 유저모드에서의 스레드 동기화에 대해서 알아본다.


원자적(atomic) 접근: Interlocked 계열 함수들

공유 리소스에 접근 시에 일관적인 결과를 보장할 수 없다.

int resource = 0;

void ThreadFunc1()
{
    for (int i = 0; i < 1000; ++i)
    {
        ++resource;
    }
}

void ThreadFunc2()
{
    for (int i = 0; i < 1000; ++i)
    {
        --resource;
    }
}

int wmain() {
    thread t1 = thread(ThreadFunc1);
    thread t2 = thread(ThreadFunc2);

    t1.join();
    t2.join();

    cout << resource;

    return 0;
}

이 경우에 1000번 더하고 1000번 빼는 연산이지만 항상 답이 다르게 나온다.

006210FF 8B 0D 40 54 62 00    mov         ecx,dword ptr [resource (0625440h)]  
00621105 83 C1 01             add         ecx,1  
00621108 89 0D 40 54 62 00    mov         dword ptr [resource (0625440h)],ecx

더하기, 빼기 연산은 어셈블리 3줄이기 때문에 연산 순서가 섞일 수 있다.

 

이러한 연산을 한번에 묶기 위해 Interlocked 계열 함수를 사용할수 있다.

https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/nf-winnt-interlockedexchangeadd

 

InterlockedExchangeAdd 함수(winnt.h) - Win32 apps

두 32비트 값의 원자성 추가를 수행합니다.

learn.microsoft.com

책에서 설명하는 락 함수

 

이 경우 CPU차원에서 제공하는 락계열 CPU연산을 사용하기 때문에 원자적인 연산을 보장한다.

006610FF B9 01 00 00 00       mov         ecx,1  
00661104 BA 40 54 66 00       mov         edx,offset resource (0665440h)  
00661109 F0 0F C1 0A          lock xadd   dword ptr [edx],ecx

lock xadd를 사용하는 것을 확인할 수 있다.

 

InterlockedExchange의 return값은 변경 이전의 값이므로 이를 이용해 SpinLock을 구현할 수 있다.

 

InterlockedCompareExchange라는 인터락 함수도 있다.

이는 destination과 comperand를 비교하고 같다면 exchange값이 dest에 저장되며, 아니라면 수행되지 않는다.

변경되기 전의 값을 return한다.

https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/nf-winnt-interlockedcompareexchange

 

InterlockedCompareExchange 함수(winnt.h) - Win32 apps

지정된 값에 대해 원자성 비교 및 교환 작업을 수행합니다. 함수는 두 개의 지정된 32비트 값과 교환을 비교 결과에 따라 다른 32비트 값과 비교합니다.

learn.microsoft.com


캐시 라인

CPU가 메모리에서 값을 가져올 때는 특정 값만 가져오는 것이 아닌 캐시 라인 단위로 같이 들고온다.

캐시 라인의 크기는 32, 64, 128바이트일 수 있고 보통은 64바이트이다.

 

정렬이 64바이트 단위로 된 것이어서 64바이트 내의 앞, 중간, 뒤 상관없이 가져올 때 64바이트로 나누어떨어지는 주소 기준으로 가져온다.

 

여러 CPU코어에서 동일한 캐시라인을 접근한다면 일관성을 보장할 필요가 있다.

이를 위해 MESI 프로토콜 등의 방법을 사용한다.


고급 스레드 동기화 기법

Interlocked 함수는 단순한 타입을 다루는 것에는 효과적이지만 복잡한 자료구조에 대한 동기화를 위해서는 다른 기능의 사용이 필요하다.

 

공유 스레드 변수에는 volatile 키워드가 유용하다.

이는 해당 변수를 컴파일러가 최적화하지 않을 것을 요청하는 것이다.

 

컴파일러는 해당 변수가 공유변수로 사용된다는 의미를 모를 수 있으므로 실행순서가 단일 흐름에서 의미가 없으며 이 때 성능이 더 좋아질 수 있다면 순서를 바꿀 수 있다. 

다만 공유하는 입장에서는 이 순서가 중요할 수 있는데 이 때 volatile을 사용할 수 있다.


크리티컬 섹션

critical section을 사용하면 여러 줄의 코드를 atomic하게 수행할 수 있다.

https://learn.microsoft.com/ko-kr/windows/win32/sync/critical-section-objects

 

중요 섹션 개체 - Win32 apps

중요한 섹션 개체는 단일 프로세스의 스레드에서만 중요한 섹션을 사용할 수 있다는 점을 제외하고 뮤텍스 개체에서 제공하는 것과 유사한 동기화를 제공합니다.

learn.microsoft.com

 

CRITICAL_SECTION 구조체 선언 이후 InitializeCriticalSection을 통해 초기화하고 사용할 수 있다.

 

사용시에는 진입 시 EnterCriticalSection을 호출해 락 획득, 빠져나올 때 LeaveCriticalSection을 호출해 락을 반환해야 한다. 

내부적으로 락 획득 반환 시 인터락 함수를 사용하기 때문에 원자성을 보장한다.

 

shared resource에 접근하는 thread는 동잃나 CRITICAL_SECTION 구조체의 주소를 알아야 한다.

 

EnterCriticalSection 스레드가 락을 얻을 수 없다면 해당 스레드는 대기 상태가 된다.

따라서 starvation상태에 놓일 수도 있기 때문에(이론적으로) TryEnterCriticalSection함수를 사용해볼 수 있다.

 

해당 함수는 SharedResouce 사용 여부에 따라 return 값이 달라지고 바로 반환되기 때문에 이후에 전략을 다양하게 할 수 있다.

 

critical section 진입 시에 락을 획득할 수 없다면 즉시 대기상태가 되어 커널 모드 전환이 일어난다.

모드 전환은 비싼 연산이기 때문에 잠시동안 스핀락을 돌려 즉시 락을 획득할 수 있다면 좋은 선택이 될 것이다.

 

InitializeCriticalSectionAndSpinCount 함수를 사용한다면 특정 spinCount만큼 스핀락을 수행한다. 만약 이 후에도 얻지 못한다면 대기 상태에 빠진다.

https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-initializecriticalsectionandspincount

 

InitializeCriticalSectionAndSpinCount 함수(synchapi.h) - Win32 apps

중요한 섹션 개체를 초기화하고 중요 섹션의 스핀 수를 설정합니다.

learn.microsoft.com

 

DeleteCriticalSection을 호출하면 CriticalSection의 모든 구조체 변수를 리셋한다.

내부적으로 커널 동기화 객체, wait queue등 동적 자원할당이 일어날 수 있기 때문에 Delete를 이용해 메모리 누수를 막아야한다.


슬림 리더-라이터 락

SRWLock은 크리티컬 섹션과 유사하다.

다만 Read, Write Lock이 구분되어있다.

 

생각해보면 여러 스레드가 Read만을 한다면 동시에 접근을 해도 상관 없을 것이다.

 

만약 어떤 스레드가 ReadLock을 잡고 있다면 다른 스레드는 ReadLock을 잡을 수 있다.

만약 어떤 스레드가 ReadLock을 잡고 있다면 다른 스레드는 WriteLock을 잡을 수 없다.(일관성이 깨질 수 있음)

만약 어떤 스레드가 WriteLock을 잡고 있다면, Read, WriteLock을 잡을 수 없다.

 

WriteLock을 잡은 본인이 ReadLock을 다시 잡는(reentrance) 상황은 SRWLock에서는 불가하다.

 

InitializeSRWLock을 통해 구조체를 초기한 뒤 

AcquireSRWLockExclusive, ReleaseSRWLockExclusive를 통해 writerlock을 잡을 수 있다.

AcquireSRWLockShared, ReleaseSRWLockShared를 통해 readlock을 잡을 수 있다.

 

critical section과 다르게 delete함수는 없다.(알아서 시스템이 해준다)

 

성능을 중요시한다면, shared resouce를 사용하지 않을 수 있다면 사용하지 않는 것이 좋다.

사용해야 한다면 Interlocked API, SRWLock, CriticalSection 순으로 사용가능하다면 사용하는 것이 성능적으로 좋다.


조건변수(condition variable)

cv를 사용하면 thread가 lock을 atomic하게 반납한 뒤 cv queue에 자신을 등록한다.

이로인해 대기 상태에 놓인다. 만약 condition variable의 predicate에 충족한다면 반환하고 락 재획득을 시도하며, 충족하지 못했더라도 dwMilliseconds가 지나면 락 재획득을 시도한다. 

https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-sleepconditionvariablecs

 

SleepConditionVariableCS 함수(synchapi.h) - Win32 apps

지정된 조건 변수에서 절전 모드로 전환하고 지정된 위험 섹션을 원자성 작업으로 해제합니다.

learn.microsoft.com

 

책에서는 FALSE 반환 시 (timeout발생) 락을 수행하지 않으며, 크리티컬 섹션을 획득하지도 못한다는데 msdn 표준 문서와 달라보여 정확한 확인이 필요하겠다.


유용한 팁과 테크닉

atomic하게 관리되어야 하는 set당 하나의 lock만을 사용하라

 

다수의 공유 리소스에 접근한다면 락 획득 순서와 락 반환 순서를 반대로 해 데드락이 발생하지 않도록 해야 한다.

 

락을 장기간 소유하지 말자.