WinCNT

유저 모드에서의 스레드 동기화 본문

게임 프로그래밍(학습 내용 정리)/시스템 프로그래밍

유저 모드에서의 스레드 동기화

WinCNT_SSS 2022. 3. 29. 14:06
같은 스레드라도 유저 모드와 커널 모드 간 Context Switch가 발생한다
따라서 유저 모드에서 동기화하는 것이 커널 모드에서의 동기화 보다 조금 더 가볍다
(커널 모드의 동기화 중 하나는 Mutext)
참고로 C++의 STL는 스레드의 동기화를 보장하지 않는다
동기화를 보장 받기 위해서는 concurrent*와 같은 클래스를 사용하자
(concurrent_unordered_map, concurrent_queue 등)

스레드는 공유 자원을 같는다(유저 모드에서는 주로 메모리 - 전역 변수 혹은 힙이다)
Thread-safe하다는 것은 그러한 공유 자원의 무결성이 보장된다는 뜻이다
사실 MS Windows는 모든 스레드가 상호 통신 없이
각자의 작업을 수행할 때 최고의 성능을 발휘한다
하지만 스레드는 독립적으로 수행되는 경우는 거의 없고,
작업이 완료되면 그 사실을 다른 스레드에게 알려줘야 한다
 
시스템에서 수행되는 모든 스레드는 힙인 경우, 할당 중에 락을 하므로 Thread-safe이지만
여러 스레드가 같은 주소에 할당하는 상황이 되면 성능 이슈가 발생한다
그래서 자주 할당/해제가 필요하면 메모리 풀을 만들어서 성능 이슈를 회피할 수 있을 것이다
(물론 유저 모드에서의 동기환느 필요하며, 구글 메모리 등의 대안도 있다)
 
스레드의 상호 통신은 다음 두 가지의 기본적인 상황에서 필요하다
  • 다수의 스레드가 공유 리소스에 접근해야 하며, 리소스의 무결성이 보장되어야 하는 경우
    • 물론 설계 시에 공유 리소스에의 접근 횟수점유 시간을 줄이는 것이 좋다
      • 점유 시간을 줄이는 예 - 더블 큐(스위칭 할 때만 Lock을 건다) 등
      • 접근 횟수를 줄이는 예 - Worker Quere를 만들어서 가져올 때 한 번에 가져오는 등
  • 어떤 스레드가 다른 스레드에게 작업이 완료되었음을 통지해야 하는 경우
    예) 네트워크 이벤트를 대기하는 네트워크 스레드, 파일 탐색 스레드 등
유저 모드에서의 동기화 방법은 여러가지 있다
현재는 실용적이지 않을 수도 있지만 다양하게 알아보자

 

원자적 접근: Interlocked 함수들

스레드 동기화를 수행하기 위해서는 리소스에 원자적으로 접근해야 한다
원자적 접근이란 어떤 스레드가 특정 리소스를 접근할 때,
다른 스레드는 동일 시간에 동일 리소스에 접근할 수 없는 것을 말한다
예를 들어 2개의 스레드가 공유 리소스(값은 0)에 1씩 더하는 작업을 1000번 한다고 하자
이 때 스레드가 원자적 접근을 하지 않는 경우, 리소스가 2000이 된다는 보장이 없다
 
Interlocked 계열 함수들은 모두 원자적으로 값을 다룬다
Interlocked 함수는 메모리 단위로 하나의 메모리를 직접적으로 보호한다
(따라서 함수도 CPU에 맞춰서 32비트, 64비트가 나눠져있다)
다시 말해, Interlocked 계열 함수들은 데이터 동기화라고 할 수 있다
 
Interlocked 함수들이 어떻게 동작하는지는 CPU 플랫폼마다 다를 것이다
x86계열의 CPU의 경우는 버스에 하드웨어 시그널을 실어서
서로 다른 CPU가 동일 메모리 주소에 접근하지 못하도록 한다
 
Interlocked 함수를 사용할 때 주의해야 할 것은
함수에 전달하는 주소 값은 반드시 정렬되어 있어야 한다는 것이다
그렇지 않으면 호출은 실패한다
C 런타임 라이브러리는 _aligned_mallocm 함수를 제공한다
함수의 alignment의 인자는 반드시 2의 n승이어야 한다
 
Interlocked 계열 함수의 장점매우 빠르게 동작한다는 것이다
반대로 Interlocked 계열 함수의 한계메모리 하나에만 접근 가능다는 것이다
 
Interlocked 함수의 특징을 살려서 스핀락(spinlock)을 구현할 수 있다
Bool g_fResourceInUse = FALSE;

void Runc1()
{
    // InterlockedExchange의 반환 값(변경 이전 값)을 확인해서
    // TRUE(리소스 접근 불가)이면 퀀텀 타임을 포기한다
    while(InterlockedExchange(&g_fResourceInUse, TRUE) == TRUE)
    	sleep(0);

    // 리소스에 접근함
    ...
    
    // 리소스에 더 이상 접근할 필요가 없음
    InterlockedExchange(&g_fResourceInUse, FALSE);
}
락 변수와 락을 통해 보호받고자 하는 데이터서로 다른 캐시 라인에 있도록 하는 것 좋다
만일 락 변수와 데이터가 동일한 캐시 라인에 있으면, 리소스를 사용 중인 CPU는
그 리소스에 접근하려는 다른 CPU와 경쟁하게 되어 성능에 악영향을 미칠 것이다
스핀락은 기본적으로 CPU의 자원을 많이 사용하므로 사용에는 주의해야 한다
실제로 현재에는 더 좋은 방법들이 존재하므로 스핀락의 사용에는 신중을 기해야 한다

 

 

캐시 라인


CPU는 메모리로부터 값을 가져올 때는 바이트 단위가 아닌
캐시 라인 단위(32, 64, 128바이트 - CPU마다 다름)로 가져온다
다만 같은 메모리를 각각의 CPU가 따로 캐시 라인으로 로드했을 경우,
동기화가 깨진다는 문제(거짓 공유, False Sharing)가 생길 수도 있다

 

False Sharing의 예

물론 CPU 설계 시에는 이러한 동기화 이슈를 피하기 위해
CPU가 캐시 라인의 정보를 변경하면 다른 CPU는 자신의 캐시 라인을 무효화하고 리로드 한다
이와 같은 구조이기 때문에 서로 다른 CPU가 같은 캐시 라인을 로드하게 되면 성능에 악영향이 생긴다
성능(혹은 메모리)에 아주 민감한 어플리케이션을 개발할 때
위와 같은 거짓  공유(False Sharing) 이슈에 대해서 충분히 인지하고 있어야 한다
(그 외의 경우에도 항상 신경 쓰기는 어렵다)

거짓 공유(False Sharing)을 피하기 위해서는 어플리케이션에 사용하는 데이터
캐시 라인의 크기와 그 경계 단위로 묶어서 다루는 것이 좋다
캐시 라인의 정보는 GetLogicalProcessorInformation 함수가 반환하는
SYSTEM_LOGICAL_PROCESSOR_INFORMATION 구조체의 CACHE_DESCRIPTOR 구조체에서
얻을 수 있으며 캐시 라인의 크기는 LineSize 필드에 저장되어 있다

 

LineSize의 값으로 C/C++ 컴파일러가 제공하는 __declspec(align(#)) 지시어를 사용하면
필드의 정렬을 제어할 수 있다
#define CACHE_ALIGN 64

// 구조체의 인스턴스가 각기 다른 캐시 라인에 들어갈 수 있도록 한다
struct __declspec(align(CACHE_ALIGN)) CUSTINFO
{
    DWORD dwCUSTOMERID;    // 거의 읽기 전용
    wchar_t szName[100];   // 거의 읽기 전용
    
    // 아래 필드는 다른 캐시 라인에 들어갈 수 있도록 한다
    __declspec(align(CACHE_ALIGN))
    int nBalanceDue;            // 읽고 쓰기용
    FILETIME ftLastOrderData;   // 읽고 쓰기용
}

 

 

고급 스레드 동기화 기법


회피 기술

다수의 스레드에 의해 공유되고 있는 변수의 상태를 지속적으로 폴링(Polling)하여
다른 스레드가 작업을 완료했는지의 여부를 확인하는 동기화 기법이다
동기화 객체나 특별한 이벤트를 대기하는 기능운영체제가 제공하지 않을 때
자체적으로 동기화를 수행하기 위해 사용할 수 있을 것이다
반대로 운영체제가 동기화 기법을 지원한다면 절대로 사용해서는 안 된다
volatile BOOL g_FinishedCalculation = FALSE;

int WINAPI _tWinmain()
{
    CreateThread(..., RecalcFunc, ...);

    // 재연산이 완료될 때까지 대기
    while(!g_FinishedCalculation)
        ;
}

DWORD WINAPI RecalcFunc(PVOID pvParam)
{
    // 재연산을 수행한다
    ...
    g_FinishedCalculation = TRUE;
    return(0);
}
참고로 volatile는 컴파일러가 최적화(변수를 상수화)하는 것을 방지하기 위한 키워드이다
volatile가 없으면 컴파일러가 컴파일 시, while(!g_FinishedCalculation)를
while(true)로 치환(최적화)해리므로, g_FinishedCalculation = TRUE의 처리로도 while문이 멈추지 않게 된다

 

크리티컬 섹션

출처: wikipedia

크리티컬 섹션은 유저 모드에서의 스레드 동기화 중 현재에도 그나마 사용되는 기법 중 하나이다
현재에는 C++에서 제공하는 mutext 클래스를 사용하는 경우가 많다
(커널 모드의 동기화 기법인 Mutext와 이름이 같지만 다른 것이며, 유저 모드에서 동작한다)
다만 크리티컬 섹션을 여러 곳에서 사용하면 데드락(DeadLock)이 발생할 수 있다
(식사하는 철학자 문제)
 
TryEnterCriticalSection 함수는 EnterCriticalSetion 함수와는 다르게 접근을 시도해보고
접근에 실패하면 다른 작업을 하게 할 수 있다(즉, CPU 점유를 포기 안 함)

 

 

슬림 리더 - 라이터 락


공유 데이터 영역에 접근할 때
그 접근의 목적이 Read가 되었든 Write가 되었든 Lock을 할 필요가 있다
하지만 그렇다고 Read와 Write에 같은 Lock을 사용하는 것도 비효율적이다
예를 들어 여러 스레드가 공유 데이터 영역에 Read만 한다고 할 때
각각의 스레드가 Read 작업 시 Lock를 걸 필요는 없을 것이다
그러한 점을 보완하기 위한 개념이 슬림 리더 - 라이터 락(Slim Reader-Writer Lock)이다
간단히 말해 Read 작업 시에는 공유 Lock을 걸고, Write 시에는 배타 Lock을 거는 것을 뜻한다

'게임 프로그래밍(학습 내용 정리) > 시스템 프로그래밍' 카테고리의 다른 글

메모리  (0) 2022.05.16
동기화 방법들  (0) 2022.04.08
윈도우OS의 시간 측정 함수 조사  (0) 2022.03.28
스레드 스케줄링  (0) 2022.03.28
Exception Filter  (0) 2022.03.22