산타는 없다

Window via C/C++ 8장 - 유저 모드에서의 스레드 동기화 본문

프로그래밍 서적/Window via C++

Window via C/C++ 8장 - 유저 모드에서의 스레드 동기화

LEDPEAR 2021. 11. 7. 18:02
반응형
  • 0. 개요
    • 마이크로 소프트 윈도우는 모든 스레드가 상호 통신 없이 각자의 작업을 수행할 때 최고의 성능을 발휘 한다. 다시 말해 동기화를 수행하지 않을 때를 의미한다.
    • 모든 스레드들은 힙(heap), 시리얼 포트, 파일, 윈도우와 같이 셀 수 없이 많은 종류의 시스템 리소스에 접근하게 된다.
    • 다음 두 가지 기본적인 상황에서 스레드는 상호 통신을 수행해야 한다.
      • 다수의 스레드가 공유 리소스에 접근해야 하며, 리소스가 손상되지 않도록 해야하는 경우
      • 어떤 스레드가 하나 혹은 다수의 다른 스레드에게 작업이 완료되었음을 알려야 하는 경우
  • 1. 원자적 접근 : Interlocked 함수들
    • 인터락 계열의 함수들은 모두 원자적으로 값을 다룬다.
    • LONG InterlockedExchangeAdd ( PLONG volatile plAdded, LONG lIncrement);
    • LONGLONG InterlockedExchangeAdd64 ( PLONGLONG volatile pllAdded, LONGLONG llIncrement);
    • 이 함수를 사용할 때에는 long 값을 저장하고 있는 변수의 주소와 얼마만큼 증가시킬 것인가를 나타내는 값을 인자로 전달하기만 하면 된다.
    • 예) InterlockedExchangeAdd(&g_x, 1); // g_x을 동기화하여 1 증가
    • 공유되는 변수 값을 수정하려고 시도하는 모든 스레드들은 반드시 이 함수를 사용해야 하며, 이 함수를 사용하지 않고 C++ 문장을 이용해서 공유되는 값을 수정하는 스레드가 있어서는 안 된다.
    • 인터락 함수들은 수행 중인 CPU 플랫폼마다 서로 다르게 동작된다. x86 계열의 CPU라면 인터락 함수들은 버스에 하드웨어 시그널을 실어서 다른 CPU가 동일 메모리 주소에 접근하지 못하도록한다.
    • 인터락 함수들은 매우 빠르게 동작하기 때문에 중요하다. 수행을 완료하는데 단 몇 CPU 사이클만을 필요로하며 유저 모드와 커널 모드 간의 전환도 발생하지 않는다.
    • InterlockedExchange는 특별히 스핀락을 구현해야 하는 경우에 매우 유용하게 사용될 수 있다.
      • 스핀락 : 임계 영역에 다른 스레드가 들어와 있으면 while 과 같은 루프문을 회전하게 하는 것
      • 스핀락과 같은 기법은 CPU 시간을 많이 낭비할 수 있기 때문에 세심한 주의가 필요하지만 수행 시간이 짧은 영역에서는 빠르게 동작할 수 있는 기법이다.
    • 락 변수와 락을 통해 보호받고자 하는 데이터는 서로 다른 캐시 란이에 있도록하는 것이 좋다. 만일 락 변수와 데이터가 동일한 캐시 라인에 있게 되면, 리소스를 사용 중인 CPU는 동일 리소스에 접근하고자 하는 다른 CPU와 경쟁하게 될 것이다. 이것은 성능에 나쁜 영향을 미치게 된다.
    • 스핀락은 보호된 리소스가 매우 짧은 시간 동안만 사용될 것이라고 가정한다. 따라서 일차적으로 수회 스핀(루프 회전)을 수행해 보고 그때까지도 리소스에 접근이 불가능하면 커널 모드로 스레드를 전환해서 대기하는 것이 좀 더 효과적이다. (4000회 정도) (크리티컬 섹션의 구현 방식이기도 하다)
    • 값을 읽기만 하는(변경하지 않고) 인터락 함수는 필요하지 않기 때문에 존재하지 않느다.
    • 인터락 함수는 메모리 맵 파일과 같이 공유 메모리 영역에 존재하는 값을 여러 프로세스 사이에서 동기적으로 접근하기 위해 사용되기도 한다.
  • 2. 캐시 라인
    • CPU가 메모리로부터 값을 가져올 때는 바이트 단위로 값을 가져오는 것이 아니라 캐시 라인을 가득 채울 만큼 충분한 양을 한 번에 가여온다.
    • 캐시 라인은 32, 64, 128바이트 크기로 구성되며(CPU에 따라 다르다), 각기 바이트 크기를 경계로 정렬되어 있다.
    • 동기화 할때 캐시에 있는 값을 활용하게 되면 실제로 변경한 내용이 갱신되지 않기 때문에 치명적인 오류가 생긴다.
    • 이러한 특성 때문에 애플리케이션이 사용하는 데이터는 캐시 라인의 크기와 그경계 단위로 묶어서 다루는 것이 좋다.
    • volatile를 사용하면 메모리에 직접 접근해서 값을 읽기 때문에 이런 문제를 피할 수 있다.
  • 3. 고급 스레드 동기화 기법
    • 복잡한 자료 구조에 대해 원자적 접근을 수행해야 한다면, 인터락 함수는 고려 대상이 될 수 없으며, 윈도우가 제공하는 다른 기능을 이용해야 한다.
    • 리소스가 가용하지 않거나 특별한 이벤트가 발생하지 않는다면, 시스템은 스레드를 대기 상태로 두어 CPU 시간이 낭비된느 것을 막을 수 있다.
    • ① 회피 기술
      • 다수의 스레드에 의해 공유되고 있는 변수의 상태를 지속적으로 폴링(polling)하여 다른 스레드가 작업을 완료했는지의 여부를 확인하는 동기화 기법이다.
      • 이 방법은 문제가 2가지 있다.
      • 이 같은 폴링 방법은 매우 간편하기 때문에 스핀락과 같은 경우 폴링 방법을 사용한다. 하지만 이보다 더 좋은 방법이 있다. 일반적으로 스핀락이나 폴링 방법은 가능한 한 사용하지 않는 것이 좋으며, 대신 스레드가 필요로 하는 자원이 가용 상태가 될 때까지 스레드를 대기 상태로 머무르게 하는 것이 좋다.
  • 4. 크리티컬 섹션
    • 공유 리소스에 대해 배타적으로 접근해야 하는 작은 코드의 집합을 의힌다.
    • 원자적이라는 의미는 현재 스레드가 리소스에 접근 중인 동안에는 다른 스레드가 동일 리소스에 접근할 수 없다는 것을 말한다.
    • 인터락 함수로 동기화 문제를 해결할 수 없는 경우라면 크리티컬 섹션을 사용하기 바란다. 크리티컬 섹션의 우수한 점은 사용하기도 쉽고 내부적으로 인터락 함수를 사용하고 있기 때문에 매우 빠르게 동작한다는 것이다. 그러나 이러한 장점에도 불구하고 서로 다른 프로세스에 존재하는 스레드 사이의 동기화에는 사용할 수 없다는 치명적인 단점이 있다.
    • ① 크리티컬 섹션 : 세부사항
      • CRITICAL_SECTION 구조체를 다루기 위해서는 항상 알맞는윈도우 함수들을 사용해야하며, 구조체의 주소를 인자로 사용해야한다. 이러한 함수들은 구조체의 멤버들이 어떻게 다루어야 할지 알고 있으며, 구조체의 상태가 항시 일관되게 유지될 수 있도록 해준다.
      • CRITICAL_SECTION 구조체는 프로세스 내의 모든 스레드가 손쉽게 접근할 수 있도록 전역변수로 선언하는 것이 일반적이지만 지역변수로도 선언될 수 있으며, 힙에 동적으로 할당될 수도 있다. 또한 클래스 정의 시에는 private 멤버로 선언한느 것이 보통이다. 여기에는 두 가지 요구 사항이 있다.
        • 첫째, 공유 리소스에 접근하고자 하는 모든 스레드들은 반드시 해당 리소스를 보호하는 CRITICAL_SECTION 구조체의 주소를 알고 있어야 한다. 스레드가 구조체의 주소를 얻어오는 메커니즘은 사용자가 원하는 방식이라면 어떤 방식이든 상관없다.
        • 둘째, 스레드가 리소스 보호를 위해 이 구조체를 사용하기 전에 반드시 구조체에 대한 초기화가 선행되어야 한다.(VOID InitializeCreticalSection(PCRITICAL_SECTION pcs);
        • 프로세스의 스레드가 더 이상 공유 리소스를 사용할 필요가 없으면, 다음과 같은 함수를 호출하여 CRITICAL_SECTION을 삭제해야 한다. 
          - VOID DeleteCriticalSection (PCRITICAL_SECTION pcs);
          DeleteCriticalSection은 구조체 내의 모든 멤버변수를 리셋한다. 당연히 다른 스레드가 이 구조체를 사용하는 동안에는 절대로 크리티컬 섹션을 삭제해서는 안된다.
        • 공유 리소스에 접근하는 코드를 작성하는 경우, 해당 코드 앞쪽에서 다음 함수를 호출해야 한다.
          - VOID EnterCriticalSection(PCRITICAL_SECTION pcs);
          EnterCriticalSection은 구조체 내의 멤버변수들을 확인하여 어떤 스레드가 현재 공유 리소스를 사용하고 있는지 알아낸다. EnterCriticalSection은 내부적으로 다음과 같은 테스트를 수행한다.
          • 만일 공유 리소스를 사용하는 스레드가 없다면 CRITICAL_SECTION 구조체 내의 멤버변수를 갱신하여 이 함수를 호출한 스레드가 공유 자원에 대한 접근 권한을 획득했음을 설정한 후 스레드가 계속 수행될 수 있도록(공유 리소스를 사용하도록) 지체 없이 반횐한다.
          • 만일 이 함수를 호출한 스레드가 접근 권한 획득을 위해 이 함수를 몇 번 호출하였는지를 멤버변수에 기록한 후 스레드가 계속 수행될 수 있도록 지체없이 반환한다. 이러한 상황은 자주 발생하지는 않지만 스레드가 LeaveCriticalSection을 호출하지 않고 EnterCriticalSection을 연속해서 두 번 호출한 경우 발생할 수 있다.
          • CRITICAL_SECTION 내의 멤버변수를 확인해 보았을 때 다른 스레드(이 함수를 호출한 스레드가 아닌)가 이미 공유 리소스에 대한 접근 권한을 획득한 상태라면 EnterCriticalSection은 이 함수를 호출한 스레드를 이벤트 커널 오브젝트를 이용하여 대기 상태로 만든다. 스레드가 대기 상태과 되면 CPU 시간을 낭비하지 않기 때문에 효율적이다. 시스템은 EnterCriticalSection 함수를 호출한 스레드가 리소스에 접근하고자 함을 기억하고 있으며, 현재 공유 리소스를 사용 중인 스레드가 LeaveCriticalSection을 호출하면 자동적으로 CRITICAL_SECTION 멤버변수를 갱신하여 대기 중인 스레드를 스케줄 가능하도록 만든다
        • EnterCriticalSection은 복잡한 작업을 수행하지는 않으며, 몇 가지 간단한 테스트 정도의 일만 수행한다. 이 함수가 가지 있는 이유는 이러한 테스트들이 모두 원자적으로 수행된다는데 있다.
        • EnterCriticalSection이 스레드를 대기 상태로 전환하면 이러한 스레드는 아주 오랫동안 스케줄될 수 없게 된다. 가끔 잘못 작성된 애플리케이션의 경우 대기 상태로 전환되 스레드가 다시 스케줄 가능 상태로 돌아오지 못하는 경우도 있다. 이러한 스레드를 기아 상태의 스레드라고 한다.
        • EnterCriticalSection 대신 다음 함수를 사용할 수도 있다.
          - BOOL TryEnterCriticalSection(PCRITICAL_SECTION pcs);
          이 함수를 호출한 스레드를 절대 대기 상태로 진입시키지는 않고 반환 값으로 리소스에 대한 접근 권한을 얻었는지의 여부를 가져온다. 이미 사용중이면 FALSE를 아니면 TRUE. TRUE를 반환하는 경우 CRITICAL_SECTION의 멤버변수를 현재 스레드가 공유 리소스의 접근 권한을 획득한 것으로 갱신하게된다. 따라서 TryEnterCriticalSection이 TRUE를 반환하는 경우에는 반드시 LeaveCriticalSection을 호출해야 한다.
        • 공유 리소스에 대한 사용을 마치면 반드시 다음 함수를 호출해야 한다.
          - VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);
          CRITICAL_SECTION 구조체 내의 멤버변수를 확인하고, 공유 리소스에 대해 접근 권한을 획득한 횟수를 1만큼 감소시킨다. 만일 이 값이 0보다 크면 LeaveCriticalSection은 아무런 작업도 수행하지 않고 반환된다.
    • ② 크리티컬 섹션과 스핀락
      • 다른 스레드가 이미 진입한 크리티컬 섹션에 특정 스레드가 재진입을 시도하면, 스레드는 바로 대기 상태로 변경된다. 이것은 스레드가 유저 모드에서 커널 모드로 전환되어야 함을 의미하며, 전환 과정은 매우 값비싼 동작에 해당한다.
      • 재진입을 시도한 스레드를 커널 모드로 완전히 전환하기도 전에 수행 중이던 스레드가 공유 리소스의 소유권을 반환할 수도 있다. 이런 경우 상당한 CPU 시간이 낭비된다.
      • 크리티컬 섹션의 성능을 개선하기 위해 마이크로소프트는 크리티컬 섹션에 스핀락 메커니즘을 도입하였다. 즉, EnterCriticalSection이 호출되면 일정 횟수 동안 스핀락을 이용하여 리소스 획득을 시도하는 루프를 수행하도록 하였다. 스핀락을 수행하는 동안 공유 리소스에 대한 획득에 실패한 경우에만 스레드를 대기 상태로 전환하기 위해 커널 모드로의 전환을 시도하도록 변경하였다.
      • 크리티컬 섹션에 스핀락을 사용하려면 크리티컬 섹션 초기화 시 다음함수를 사용해야 한다.
        - BOOL InitializeCriticalSectionAndSpinCount(PCRITICAL_SECTION pcs, DWORD dwSpinCount);
        dwSpinCount의 값으로 스핀락 루프의 횟수를 전달한다(0~0x00FFFFFF)
      • 크리티컬 섹션의 스핀 횟수는 다음 함수를 호출하여 변경할 수 있다.
        - DWORD SetCriticalSectionSpinCount(PCRITICAL_SECTION pcs, DWORD dwSpinCount);
      • 조언을 하자면 프로세스 힙에 대한 접근을 보호하기 위해 사용하는 크리티컬 섹션의 스핀 카운트는 대략 4000이다.
    • ③ 크리티컬 섹션과 에러 처리
      • InitializeCreticalSection가 실패할 가능성이 있지만 에러 발생 가능성에 대해 전혀 생각하지 않았기 때문에 반환값이 VOID이다. InitializeCreticalSectionAndSpinCount 함수를 사용하면 이런 문제를 해결할 수 있다. 메모리 블록 할당에 실패할 경우 FALSE를 반환한다.
      • 일단 이벤트 커널 오브젝트가 생성되면 DeleteCriticalSection 호출 시까지는 삭제되지 않을 것이기 때문에 크리티컬 섹션 사용을 마친 후에 DeleteCriticalSection 함수를 호출하는 것을 잊어서는 안된다.
  • 5. 슬림 리더-라이터 락
    • SRWLock (Slim Reader-Writer Lock)은 단순 크리티컬 섹션과 유사하게 다수의 스레드로부터 단일의 리소스를 보호할 목적으로 사용된다.
    • 크리티컬 섹션과의 차이점은, SRWLock의 경우 리소스의 값을 읽기만 하는 스레드(reader)들과 그 값을 수정하려는 스레드(writer)들이 완전히 구분되어 있을 경우에만 사용할 수 있다는 것이다.
    • 공유 리소스의 값을 읽기만 하는 리더들은 동시에 리소스에 접근한다 하더라도 공유 리소스의 값을 손상시키지 않기 때문에 동시에 수행되어도 무방하다.
    • 동기화는 라이터 스레드가 리소스의 내용을 수정하려고 시도하는 경우에만 필요하며, 이 경우 리소스에 대한 베타적인 접근이 이루어져야 한다. 라이터 스레드가 리소스의 내용을 수정하는 동안에는 어떠한 리더, 라이터 스레드도 공유 리소스에 접근해서는 안된다.
    • 최상의 성능으로 동작하는 애플리케이션을 작성하고 싶다면 가장 먼저 공유 리소스를 사용하지 않도록 작성할 수 있는지 검토한다. 다음으로 인터락 API, SRWLock, 크리티컬 섹션 순으로 사용을 검토해야 한다. 이러한 방식이 구현하고자 하는 상황에 전혀 적합하지 않는 경우에만 커널 오브젝트를 사용하는 것이 좋다.(속도차이가 10배가량 난다)
  • 6. 조건변수
    • 조건변수(condition veriable)를 사용하면 스레드가 리소스에 대한 락을 해제하고 SleepConditionVariableCS나 SleepConditionVariableSRW 함수에서 지정한 상태가 될 때까지 스레드를 브로킹해 준다. 또한 이러한 동작이 원자적으로 수행되도록 설계되어 개발 업무를 좀 더 간편하게 해준다.
    • Monitor 클래스와 조건변수는 매우 유사하다. 이 둘은 리소스에 대한 동기적인 접근을 위해 각기 SleepConditionVariable과 Wait 함수를 제공하고, 시그널 기능을 위해 WakeConditionVariable과 Pusle(All) 기능을 제공한다.
    • ① Queue 예제 애플리케이션
    • ② 유용한 팁과 테크닉
      • 크리티컬 섹션이나 리더-라이터 락과 같은 락을 사용할 때에는 반드시 사용해야 하거나 절대 사용하지 말아야 할 것이 있다. 다음에 락을 사용할 경우에 활용할 수 있는 몇 가지 유용한 팁과 테크닉을 설명하였다.
        • 원자적으로 관리되어야 하는 오브젝트 집합당 하나의 락만을 사용하라
          • 애플리케이션에서 사용되는 논리적 단일 리소스들은 모두 자신만의 락을 가지고 있어야 하며, 논리적 단일 리소스 전체 혹은 논리적 단일 리소스 내의 일부 리소스에만 접근하는 경우에도 이러한 락을 이용해서 동기화를 수행해야한다.
        • 다수의 논리적 리소스들에 동시에 접근하는 방법
          • 다수의 리소스에 락을 설정하는 순서를 항상 동일하게 유지하도록 코드를 작성해야 한다.
          • LeaveCriticalSection의 호출 순서는 문제가 되지 않는데, 이는 이 함수가 스레드를 대기 상태로 변경하지 않기 때문이다.
        • 락을 장시간 점유하지 마라
          • 락을 너무 오랜 시간 점유하고 있게 되면 다른 스레드들이 계속 대기 상태에 머물러 있게 되기 때문에 애플리케이션의 성능에 나쁜 영향을 미칠 수 있다.
          • 락 내부에 오래걸리는 작업(커널 모드 등)을 수행하지 마라
반응형
Comments