Developer Jay

[SystemProgramming] 스레드 동기화 기법 1

06 Dec 2022

Intro

스레드 동기화 기법 중 Interlocked 함수와 이를 활용한 SpinLock 기법에 대해 알아보자


1. Interlocked 함수들

  • 스레드 동기화를 수행하기 위해서는 공유자원에 원자적으로 접근해야 한다.
  • 간단하게 전역변수 int a 에 대해 ++연산을 하는 예를 들어보자.
MOV  EAX, [a]    // a값을 CPU 레지스터로 Load
INC  EAX         // 레지스터의 값을 증가
MOV  [a], EAX    // 레지스터에서 a로 Store
  • 두 개의 스레드가 위와 같은 연산을 동시에 진행하였을 때 a 값이 2가 증가되기를 기대하겠지만,
    윈도우는 선점형 멀티스레드 운영체제이므로 스레드가 수행 중 언제든 다른 스레드로 제어를 빼앗길 수 있기 때문에 값은 1만 증가될 수 있다.
  • 이것은 수행 환경과 관련된 것이기 때문에 우리가 언어에서 제어할 수 있는 방법이 없지만, 이를 해결해주기 위해 윈도우는 제대로 사용하기만 하면 올바른 결과 값을 얻을 수 있는 인터락과 같은 함수들을 제공하고 있다.
LONG InterlockedIncrement(    // 원자성 연산으로 지정된 32비트 변수의 값을 1씩 증가
  [in, out] LONG volatile *Addend
);
LONG InterlockedDecrement(    // 원자성 연산으로 지정된 32비트 변수의 값을 1씩 감소
  [in, out] LONG volatile *Addend
);
LONG InterlockedExchange(     // 원자성 연산으로 32비트 변수를 지정된 값으로 설정. Target 매개변수의 초기값을 반환 함
  [in, out] LONG volatile *Target,
  [in]      LONG          Value
);
  • 그렇다면 인터락 함수들은 어떻게 동작할까? CPU 아키텍처마다 다르지만 우리가 사용하는 x86 아키텍처의 경우 캐시메모리에서 원자성을 보장하려는 변수가 적재된 캐시라인에 락을 걸어, 작업이 끝날때 까지 다른 CPU 코어에서 해당 캐시라인에 접근하지 못하도록 하는 방식을 사용한다.
  • 따라서 변수가 자기 사이즈 경계에 서있지 않으면 인터락 함수의 원자적 연산을 보장받지 못하게 된다. ex) 4바이트 변수가 4가 아닌 2의 경계에 서있는 경우
  • 인터락 함수를 사용할 때는 그 변수의 경계의 세우는 것을 잊지 않아야 한다.
    [TIP] C++ 언어에서는 변수의 선언 경계를 맞추기 위해 alignas(N) 키워드를 사용할 수 있다.


2. SpinLock

  • 스핀락은 임계구역에 진입이 불가능할 때 진입이 가능할 때까지 루프를 돌면서 재시도 하는 방식으로 구현된 락이다.
  • 이러한 스핀락은 원자성 연산으로 변수를 지정된 값으로 바꾸면서 변수의 초기 값을 반환하는 InterlockedExchange 함수를 사용하는 방식으로 구현할 수 있으며 의사 코드는 아래와 같다.
BOOL _lock = FALSE;
void SpinLock::Lock()
{
   // 스레드 경합에 성공하면 break, 성공하지 못하면 무한 loop
   while (InterlockedExchange(&_lock, TRUE) != FALSE)
   {
   }
}
void SpinLock::UnLock()
{
   _lock = FALSE;
}
  • 일반적으로 커널이 개입하는 락은 스레드 경합에 성공하지 못하였을 때 block 상태에 돌입하여 다른 스레드에게 작업을 양보하지만, 스핀락은 block 상태로 돌입하지 않고 락을 획득할 때까지 루프를 돌기 때문에 주어진 퀀텀 타임을 소모하면서 CPU 자원을 계속 사용하게 된다.
  • 다른 스레드의 작업은 중요하지 않고 오직 내 스레드가 당장 임계 구역에 진입하는 것이 최우선인 매우 이기적인 동기화 방식이라고 할 수 있다.
  • 일반적으로 스핀락을 단독으로 사용하기에는 CPU 부담이 예상되므로 OS가 아닌 언어 차원에서는 잘 사용하지 않는 동기화 방식이다. 더군다나 유저모드 동기화 객체들은 이미 내부적으로 스핀락을 일부 사용하는 방식으로 구현되어 있으므로 스레드 동기화가 필요할 경우 스핀락이 아닌 동기화 객체를 사용하는 것이 합리적이다.


  • 그럼에도 불구하고 스핀락을 단독으로 사용하고자 한다면(ex. 임계 구역이 짧은 구간) 스핀락 경합에 실패할 때에 pause 명령을 사용해볼법 하다.
  • pause 명령은 하이퍼스레딩을 지원하는 CPU에게 BusyWait 상태라는 것을 알려주어 하이퍼스레드에 존재하는 논리 프로세서에게 작업을 넘겨주는 것이다.
    하이퍼스레딩을 지원하지 않는 CPU의 경우 pause는 아무것도 하지 않는다는 의미인 nop 명령으로 처리된다.
void SpinLock::Lock()
{
   while (InterlockedExchange(&_lock, TRUE) != FALSE)
   {
      // YieldProcessor는 함수가 아닌 매크로이며 C++ 내장 함수인 mm_pause를 호출하게 된다.
      // mm_pause 는 x86 CPU 명령어인 pause 어셈블리 코드로 대체된다.
      YieldProcessor();
   }
}