윈도우즈 API

유저 모드 스레드 동기화

Khanne 2018. 11. 3. 02:18

■ 윈도우는 모든 스레드가 상호 통신 없이 각자의 작업을 수행할 때 최고의 성능을 발휘 -> 하지만 스레드가 독립적으로 상호 통신 없이 독립적으로 수행되는 경우는 거의 없음

 상호 통신 없이 독립적으로 수행?

○ Process A에서는 File을 읽고 있음과 동시에 Process B에서는 File을 쓰는 작업을 실행 -> 수정 중인 데이터를 읽는 행위 -> 매우 불안정한 데이터


 스레드 상호 통신을 수행해야 하는 상황

1. 다수의 스레드가 공유 리소스에 접근하며 리소스가 손상되지 않아야 할 때

2. 어떤 스레드가 하나 혹은 다수의 스레드에게 작업이 완료 되었음을 알려야 할 때


 스레드 동기화 중에선 제일 빠름 -> 최상의 성능


 프로세스 내의 스레드들 사이에서만 동기화 수행 가능





원자적 접근

 원자적 접근 -> 스레드가 특정 리소스를 접근할 때 다른 스레드는 동일 시간에 동일 리소스에 접근할 수 없는 것

윈도우는 선점형 운영체제이기 때문에 어떤 스레드가 수행 중에 언제든 다른 스레드가 수행될 수 있음을 명시하도록


 CPU 플랫폼마다 서로 상이한 동작원리 -> x86 계열의 CPU라면 인터락 함수들은 버스에 하드웨어 시그널을 실어서 다른 CPU가 동일 메모리 주소에 접근하지 못하도록 함


int a = 0;

int WINAPI Thread1(PVOID pvParam) {
    a++;
    return(0);
}
int WINAPI Thread2(PVOID pvParam) {
    a++;
    return(0);
}

 위의 코드로 두개의 스레드를 생성했을 경우 결과 값을 예상 -> int a ==2라고 생각할 수 있지만 정답은 "모른다" -> 위에 설명했듯이 윈도우는 선점형 멀티스레드 운영체제이기 때문에 스레드는 수행 중에 언제든 다른 스레드로 제어를 빼앗길 수 있음 -> 컴파일러에 따라, 설치된 CPU, 갯수에 따라 다 결과값은 다다름 -> 중간에 인터럽트(방해) 받지 않고 값을 "원자적으로" 변경할수 있는 방법이 필요


관련 함수

LONG InterlockedExchangeAdd (
   PLONG volatile plAddend,
   LONG   lIncrement
);

 단순히 두 번째 매개 변수로 음수 값을 전달하기만 하면 값이 감소됨


LONG InterlockedExchange (
   PLONG volatile plTarget,
   LONG   lValue
);

LONGLONG InterlockedExchange64 (
   PLONGLONG volatile plTarget,
   LONGLONG   lValue
);

PVOID InterlockedExchange64 (
   PVOID*  volatile ppvTarget,
   PVOID   pvValue
);

 첫 번째 매개변수로 전달되는 주소가 담고있는 값을 두 번째 매개변수로 전달되는 값을 원자적으로 변경하는 함수들





캐시라인

 캐시라인 -> 캐시영역의 기본단위, 지역성에 근거하면 CPU가 데이터를 로드할 때, 해당 데이터의 주변 데이터도 가까운 시간 안에 다시 참조 될 가능성이 높음 -> 이러한 공간적 지역성에 근거해 캐시는 필요 데이터 로딩 시, 그 주변 데이터들까지 함께 로딩 하는데 이때 로딩되는 단위를 캐시블록 또는 캐시라인이라고 함

 성능 향상을 위해 존재함 -> 분명 성능 향상에 도움이 되지만 멀티 프로세서 머신에서는 거꾸로 성능을 저해하는 요인이 될 수도 있음


 멀티 프로세서에서 CPU가 캐시 라인에 있는 정보를 변경하면 다른 CPU는 이러한 사실을 알아채고 자신의 캐시 라인에 있는 정보를 무효화 시킴 -> 여러 스레드가 하나의 리소스에 접근 했을때 발생하는 문제와 비슷한 문제에 대한 해결방안


 위와 같은 특성 때문에 애플리케이션이 사용하는 데이터는 캐시 라인의 크기와 그 경계 단위로 묶어서 다루는 것이 좋음 -> 적어도 하나 이상의 캐시라인 경계로 분리된 서로 다른 메모리 블록에 각각의 CPU가 독립적으로 접근하는 것을 보장할 수 있게 된다

 읽기 전용의 데이터와 읽고 쓰는 데이터를 분리하는 것이 좋음

 동일한 시간에 접근하는 데이터들을 묶어서 구성하는 것이 좋음


BOOL GetLogicalProcessorInformation(
  PSYSTEM_LOGICAL_PROCESSOR_INFORMATION Buffer,
  PDWORD                                ReturnedLength
);

 CPU의 캐시 라인 크기를 얻어오는 가장 쉬운 방법

 얻어온 값으로 __declspec(align(CACHE_SIZE))명령어로 캐시 기능 개선 가능





크리티컬 섹션

 크리티컬 섹션 -> 공유 리소스에 대해 베타적으로 접근해야하는 작은 코드의 집합

변수 단위가 아닌 코드를 "원자적으로" 수행하기 위한 방법

 서로 다른 프로세스에 존재하는 스레드 사이의 동기화에는 사용 불가


CRITICAL_SECTION NameOfStruct

 크리티컬섹션 사용 시 필요한 구조체

 프로세스 내의 모든 스레드가 접근할 수 있도록 전역변수로 선언하는 것이 일반적

 구조체 자체가 문서화되어 있지 않는 것은 아님 -> 단지 마이크로소프트가 이 구조체의 내용에 대해 사용자가 알 필요가 없다 생각해 구조체의 내용을 공개하지 않음


void InitializeCriticalSection(
  LPCRITICAL_SECTION lpCriticalSection
);

     CRITICAL_SECTION 구조체를 초기화 함 -> 해당 구조체 사용하기전(EnterCriticalSection) 호출 필수


void EnterCriticalSection(
  LPCRITICAL_SECTION lpCriticalSection
);

void LeaveCriticalSection(
  LPCRITICAL_SECTION lpCriticalSection
);

 EnterCriticalSection과 LeaveCriticalSection으로 공유 리소스를 작성하는 코드를 감싼다

 EnterCirticalSection -> 스레드가 공유 리소스에 접근함을 알림

 만일 공유 리소스를 사용하는 스레드가 없다면 CRITICAL_SECTIOIN 구조체 내의 멤버변수를 갱신하여 이 함수를 호출한 스레드가 공유 자원에 대한 접근 권한을 획득했음을 설정한 후 스레드가 계속 수행될 수 있도록 지체 없이 반환 -> 반환 == 코드 실행

 이미 한번 호출 후 다시 두번째 호출했을 경우 접근 권한 획득을 위해 이 함수를 몇 번 호출하였는지 멤버 변수에 기록 후 바로 함수 반환

 CRITICAL_SECTION 내의 멤버변수를 확인해 보았을 때 다른 스레드가 이미 공유 리소스에 대한 권한을 획득한 상태라면 해당 함수는 이벤트 커널 오브젝트를 이용해 대기상태로 만듬

멀티 프로세서 머신에서 두 개의 스레드가 완벽하게 동일한 시점에 해당 함수를 호출하는 경우  단 하나의 스레드만이 공유 리소스에 대한 접근 권한을 획득한 후 다른 스레드는 대기 상태로 전환한다 -> 이 경우 스레드는 아주 오랫동안 스케줄될 수 없게 됨

 LeaveCriticalSection -> 스레드가 공유 리소스 접근을 끝냈음을 알림

 해당 함수 호출시 자동으로 CRITICAL_SECTION 멤버변수를 갱신해 대기중인 스레드를 스케줄 가능 상태로 만듬

 CRITICAL_SECTION 구조체 내의 멤버변수를 확인하고, 공유 리소스에 대해 접근 권한을 획득한 횟수 1만큼 감소 시킴 -> 감소 후 0보다 크면 아무런 작업없이 반환 -> 0이 되면 공유 리소스에 대한 접근 권한을 획득한 스레드가 없는 것으로 멤버를 갱신하고, 이전에 EnterCriticalSection을 호출해 대기 상태로 진입한 스레드가 있는지 확인 -> LeaveCriticalSection은 이 함수를 호출한 스레드를 절대로 대기 상태로 빠뜨리지 않고 바로 반환됨

 해당 함수들 호출 시에 인자는 CRITICAL_SECTION 구조체의 주소를 전달

 내부적으로 인터락 함수를 사용해 매우 빠르게 동작하나 서로 다른 프로세스에 존재하는 스레드 사이의 동기화에는 사용할 수 없다는 치명적 단점 존재


void DeleteCriticalSection(
  LPCRITICAL_SECTION lpCriticalSection
);

 프로세스가 더 이상 공유 리소스를 사용할 필요가 없으면, 해당 함수를 호출해 CRITICAL_SECTION을 삭제해야 함


BOOL TryEnterCriticalSection(
  LPCRITICAL_SECTION lpCriticalSection
);

 TryEnterCriticalSection -> EnterCriticalSection 대신 사용 가능 

 함수를 호출한 스레드를 절대 대기 상태로 진입시키지 않음 -> 대신 함수의 반환 값으로 리소스에 대한 접근 권한을 얻었는지의 여부를 가져옴 -> TRUE로 반환 되는 경우 CRTICAL_SECTION의 멤버변수를 현재 스레드가 공유 리소스의 접근 권한을 획득한 것으로 갱신하게 됨



 다른 스레드가 이미 진입한 크리티컬 섹션에 특정 스레드가 재진입을 시도하면, 스레드는 바로 대기 상태로 변경됨 -> 스레드가 유저 모드에서 커널 모드로 전환(많은 리소스 필요, 무거운 작업)



 멀티 프로세서 머신의 경우 현재 공유 리소스를 소유하고 있는 스레드가 해당 공유 리소스에 대한 작업을 빨리 반환할 가능성 또한 존재 -> 커널 모드로 전환을 완료 하기전에 다시 유저모드로 돌아와야 할 수 있음 -> 리소스 낭비, CPU 시간 낭비


BOOL InitializeCriticalSectionAndSpinCount(
  LPCRITICAL_SECTION lpCriticalSection,
  DWORD              dwSpinCount
);

 크리티컬 섹션의 성능 개선(바로 위와 같은 예로 인한 시간 낭비)을 위한 스핀락 메커니즘EnterCriticalsSection이 호출되면 일정 회수 동안 스핀락을 사용하여 리소스 획득을 시도하는 루프를 수행 -> 스핀락을 수행하는 동안 공유 리소스에 대한 획득에 실패한 경우에만 스레드를 대기 상태로 전환하기 위해 커널 모드로의 전환을 시도하도록 한다

 단일 프로세스를 가진 머신에서는 스핀락을 사용하는 것이 좋지 않으므로 해당 함수를 단일 프로세서를 가진 머신에서 호출하게 되면 dwSpinCount 매개변수로 전달한 값은 무시되며 0으로 설정된다

 크리티컬 섹션을 사용할 때 항상 이함수를 사용하는걸 추천함


DWORD SetCriticalSectionSpinCount(
  LPCRITICAL_SECTION lpCriticalSection,
  DWORD              dwSpinCount
);

 크리티컬 섹션의 스핀 횟수를 변경할 수 있는 함수





슬림 리더-라이터 락

 크리티컬 섹션과 유사하나 SRWLock의 경우 리소스의 값을 읽기만 하는 리더(스레드)들과 그 값을 수정하려는  라이터(스레드)들이 완전히 구분되어 있을 경우에만 사용할 수 있다 -> 공유된 리소스를 읽기만 하는 리더들은 동시에 여러 스레드가 접근하더라도 리소스의 값을 손상시키지 않기 때문에 동시에 수행되어도 무방한 특징을 이용 -> 라이터가 리소스의 내용을 수정하는 동안에는 어떠한 리더, 라이터도 공유 리소스에 접근해선 안된다


 SRWLOCK 오브젝트를 삭제하거나 파괴하는 함수는 존재하지 않으며, 이러한 작업은 시스템이 자동으로 수행해줌


 SRWLOCK 오브젝트를 반복적으로 획득할 수 없다


void InitializeSRWLock(
  PSRWLOCK SRWLock
);

 해당 함수를 이용해 SRWLOCK 구조체를 할당하고 초기화

 사용되는 SRWLOCK 구조체는 실제로 이 포인터가 무엇을 가리키는지 문서화되어 있지 않기 때문에, 이 멤버를 직접 사용할 수는 없다


void AcquireSRWLockExclusive(
  PSRWLOCK SRWLock
);

void ReleaseSRWLockExclusive(
  PSRWLOCK SRWLock
);

 AcquireSRWLockExclusive -> 라이터가 SRWLock을 이용해 보호하려는 공유 리서스에 대한 배타적인 접근 권한을 획득하기 위해 해당 함수를 사용

 ReleaseSRWLockExclusive -> 라이터가 공유 리소스 사용 후 락을 해제하기 위해 호출하는 함수


void AcquireSRWLockShared(
  PSRWLOCK SRWLock
);

void ReleaseSRWLockShared(
  PSRWLOCK SRWLock
);

 리더가 사용하는 함수들




조건변수

 프로세스간 공유 불가능한 유저 모드 오브젝트


 조건변수를 사용하면 스레드가 리소스에 대한 락을 해제하고 SleepConditionVariableCS나 SleepConditionVariableSRW 함수에서 지정한 상태가 될 때까지 스레드를 블로킹 해준다

void InitializeConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

 조건 변수를 초기화 해주는 함수


BOOL SleepConditionVariableCS(
  PCONDITION_VARIABLE ConditionVariable,
  PCRITICAL_SECTION   CriticalSection,
  DWORD               dwMilliseconds
);

BOOL SleepConditionVariableSRW(
  PCONDITION_VARIABLE ConditionVariable,
  PSRWLOCK            SRWLock,
  DWORD               dwMilliseconds,
  ULONG               Flags
);

 CriticalSection, SRW에 따른 함수들


void WakeConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

void WakeAllConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

 SleepConditionVariable*류 함수들로 블로킹 된 스레드를 다시 실행시키는 함수






각 유저모드 스레드 동기화 속도 비교

1. Interlock*류 함수

 CPU가 배타적으로 메모리에 접근할 수 있도록 락을 설정하기 때문에 일반적인 읽기/쓰기에 비해 느림 

 락을 설정한다 -> 결국 특정 시간에 단일의 CPU만 메모리에 접근할 수 있도록 허용한다는 것을 의미

2. SRWLock

 크리티컬 섹션의 성능 결과와 비교적 유사하나 크리티컬 섹션보다 조금 더 성능이 뛰어나다는 것을 확인할 수 있음 -> 크리티컬 섹션 대신 SRWLock이 크리티컬 섹션보다 조금 더 성능이 뛰어나다는것이 확인 가능


3. 크리티컬 섹션

 크리티컬 섹션에 대한 경쟁이 발생하면 성능은 더욱더 나빠지게 됨

 SRWLock와 다르게 읽기 작업또한 한번에 하나씩 밖에 접근 못하므로 SRWLock보다 느림