언어/C#

단순동기화3 - 커널 모드 동기화

tsyang 2022. 5. 9. 01:41

2022.05.06 - [언어/C#] - 단순동기화2 (유저 모드 동기화 요소)

 

커널 모드 동기화 요소


커널 모드 동기화 요소는 유저 모드 동기화 요소에 비해서 상당히 느리다.

 

왜냐?

커널 모드 동기화 요소가 운영체제에게 스레드 간의 동기화를 요청해서 그렇다. 또한 각각의 메서드들은 커널 객체를 이용하게 되고 이로 인해 스레드가

 

관리 코드 -> 네이티브 유저 모드 -> 네이티브 커널 모드 (돌아올 때도 역순으로 반복함) 

 

위와 같은 전환을 일으켜 CPU 시간을 엄청나게 소비하기 때문이기도 하다. 

 

 

그래도 커널 모드 동기화 요소를 쓰는 이유가 있다.

  1. 리소스에 대한 경쟁 상태를 확인할 수 있다. 스레드가 CPU를 낭비하지 않도록 한다.
  2. 네이티브 스레드와 관리 스레드 사이에서도 동기화를 할 수 있다.
  3. 동일 컴퓨터 내의 다른 프로세서 상의 스레드 간에도 동기화를 할 수 있다.
  4. 보안 체계가 적용되어 있다.
  5. 타임아웃 시간을 지정하여 스레드를 블로킹할 수 있음.

 

커널 모드 동기화 요소에는 이벤트(event)세마포어(semaphore)가 있다. 뮤텍스(Mutex)는 이 두 가지를 근간으로 만든 것이다.

 

System.Threading 네임스페이스에는 WaitHandle이라는 추상 클래스가 있다. 이놈이 윈도우 커널 오브젝트 핸들을 나타내는 녀석이다.

 

WaitHandle을 상속한 클래스들은 아래와 같다.

WaitHandle
- EventWaitHandle (하위에 AutoResetEvent, ManualResetEvent 있음)
- Semaphore
- Mutex

 

참고로 WaitHandle은 메모리 펜스(=메모리 베리어)가 적용되어 있으며, 내부적으로 커널 객체 핸들을 저장하는 필드를 가지고 있다.

 

WaitHandle의 대표적인 메서드로는 WaitOne, WaitAny, WaitAll 정도가 있는데..

 

  • WaitOne : 커널 객체가 시그널 상태가 될 때까지 호출한 스레드 대기
  • WaitAny : WaitHandle[] 내에 객체 중 하나가 시그널 상태일 때까지 대기
  • WaitAll : WaitHandle[] 내의 객체 모두가 시그널 상태일 때까지 대기

참고로 WaitHandle은 Dispose 메서드를 호출할 수 있는데, 가급적 피하는 게 좋다.

 

왜냐? Dispose() 내부에서 CloseHandle()을 호출하는데 만약 다른 스레드가 동일 커널 객체를 쓰고 있었다면..? 그러니까 웬만하면 가비지 수집기한테 맡겨라.

 

 

사용 예시 1

커널 객체를 이용해 응용프로그램이 딱 1개만 켜지게 할 수 있다.

public static void Main()
{
    bool createdNew;

                    //매개변수 : InitialCount, MaxCount, Name, CreatedNew 
    using (new Semaphore(0, 1, "MyApp", out createdNew))
    {
        if (false == isNewProgram)
        {
            //프로그램을 수행하지 않고 종료한다.
        }
    }
}

세마포어 말고도 커널 객체를 쓰는 뮤텍스나 이벤트를 써도 된다. 또한 세마포어를 쓰긴 했지만 동기화 용도로 쓴 것은 아니다. 

 

무슨 일이 벌어졌나? 똑같은 응용프로그램 2개를 동시에 실행했다고 하자. 그러면 두 개의 프로세스는 각자 고유의 스레드를 가지고 있고, 동일한 문자열("MyApp")을 이용하여 커널 객체를 생성한다. 첫 번째로 커널 객체를 생성한 스레드는 createdNew에 true가 저장된다. 

 

이제 두 번째 스레드가 커널 객체를 생성하려 하면 윈도우는 커널 객체가 이미 존재하므로 커널 객체를 신규로 생성하지 않고 이미 존재하는 커널 객체를 준다. 그리고 createdNew에는 false를 저장해준다. 따라서 이를 이용해 두 번째 스레드는 이미 동일한 응용프로그램이 수행 중임을 알 수 있고 자신을 종료할 수 있게 된다.

 

 

 

 

 

이벤트


이벤트는 커널에서 관리되는 단순한 bool 변수이다. 이벤트에는 두 가지 종류가 있는데

 

  1. AutoResetEvent : 여러 스레드가 이 이벤트로 블로킹되어 있을 때, 값이 true 변경되면 단 하나의 스레드만 블로킹을 풀어준다. 이후 커널이 자동으로 이 이벤트를 리셋하여 값을 false로 바꾼다.
  2. ManualResetEvent : 여러 스레드가 이 이벤트로 블로킹되어 있을 때, 값이 true로 변경되면 모든 스레드의 블로킹을 푼다. 값이 리셋되지는 않는다.

 

그럼 이걸 어떻게 쓸까? 이걸로 간단한 Lock을 만들어보자

public sealed class SimpleWaitLock : IDisposable
{
    private readonly AutoResetEvent _available;

    public SimpleWaitLock()
    {
        _available = new AutoResetEvent(true);  //true로 초기화
    }

    public void Enter()
    {
        _available.WaitOne();
    }

    public void Leave()
    {
        _available.Set();
    }

    public void Dispose()
    {
        _available?.Dispose();
    }
}

이거는 이전에 만들었던 SpinLock이랑 똑같이 작동한다.

 

그러나, 성능상의 차이가 명확한데, 왜냐면 WaitLock은 Enter()과 Leave() 메서드를 호출할 때마다 관리 코드와 커널 코드 사이를 왔다 갔다 해야 하기 때문이다. 

 

그러나, 스레드들이 경쟁 상태에 있는 경우라면 커널이 알아서 블로킹을 해주기 때문에, 스피닝으로 인한 CPU 소비가 발생하지 않는다.

 

또한 Dispose를 호출할 때도 커널로의 전환이 일어나니 참고.

 

 

 

 

 

세마포어


이벤트가 커널에서 관리되는 bool 변수라면 세마포어는 Int32 변수이다. 세마포어의 값이 0이면 스레드들은 블로킹되고, 0보다 크면 블로킹이 해제된다. 대기하던 스레드가 블로킹이 해제되면 값이 1만큼 줄어든다. 또한 세마포어는 사용자가 설정할 수 있는 최댓값을 가지고 있으며 AutoResetEvent는 최댓값이 1인 세마포어와 유사하게 동작한다.

 

세마포어로 만든 WaitLock을 보면 대충 감이 잡힌다.

public sealed class SimpleWaitLock : IDisposable
{
    private readonly Semaphore _available;

    public SimpleWaitLock(int maxConcurrent)
    {
        //count가 0이면 대기임
        _available = new Semaphore(maxConcurrent, maxConcurrent);
    }

    public void Enter()
    {
        _available.WaitOne();
    }

    public void Leave()
    {
        _available.Release(1);
    }

    public void Dispose()
    {
        _available?.Dispose();
    }
}

Semaphore.Release 메서드의 매개변수로는 releaseCount를 넘겨주며 지정한 개수만큼의 스레드가 블로킹 해제된다.

 

 

 

 

뮤텍스


​뮤텍스는 상호 배제 락을 표현하기 위한 요소이다. AutoResetEvent나 최대 카운트가 1인 세마포어와 유사하게 블로킹된 스레드가 여러 개인 경우, 이 중 하나의 스레드만이 수행될 수 있도록 해준다.

 

뮤텍스는 추가적인 기능을 포함하는데 먼저 자신을 소유하는 스레드 ID의 값을 기록해 주었다가, ReleaseMutex()가 호출되면 ID를 비교한다. (틀리면 예외 발생) 또한 Mutex를 소유한 스레드가 종료되면, 동일한 Mutex를 대기하던 스레드를 깨운다. (예외 전달로)

 

뮤텍스는 재귀 카운트를 가지고 있어서, 뮤텍스를 가진 스레드가 다시 뮤텍스를 소유하려고 하면 값을 증가시킨다. ReleaseMutex()를 호출해주면 이 값이 감소한다. 재귀 카운트가 0이 되어야만 다른 스레드가 뮤텍스를 소유할 수 있다.

 

그러나, 사실 뮤텍스는 잘 쓰지 않는다.

왜냐? 재귀 카운트랑 ID를 저장하는데 추가적인 공간을 쓰고, 이런 정보들을 연산하기 위해 속도를 희생하기 때문이다. 

 

그럼에도, 다음과 같이 재귀가 지원되는 락이 필요한 경우가 있는데

public class RecursionLock : IDisposable
{
    private readonly Mutex _lock = new Mutex();

    public void Method1()
    {
        _lock.WaitOne();
        //작업 수행
        //호출하는 메서드에서 다시 락을 소유
        Method2();
        _lock.ReleaseMutex();
    }

    public void Method2()
    {
        _lock.WaitOne();
        //작업 수행
        _lock.ReleaseMutex();
    }

    public void Dispose()
    {
        _lock?.Dispose();
    }
}

만약 뮤텍스가 아니라 AutoResetEvent 같은 애였다면 Method2의 WaitOne을 호출하였을 때 블로킹되어버릴 것이다.

 

 

이럴 땐, AutoResetEvent를 이용해서 이런 기능을 구현할 수도 있다.

public class RecursiveAutoResetEvent : IDisposable
{
    private AutoResetEvent _lock = new AutoResetEvent(true);
    private int _owningThreadID = 0;
    private int _recursionCount = 0;

    public void Enter()
    {
        int currentThreadId = Thread.CurrentThread.ManagedThreadId;

        //같은 스레드이면 재귀 카운트 증가
        if (_owningThreadID == currentThreadId)
        {
            _recursionCount++;
            return;
        }

        //아니면 ID를 기억하고 카운트 1 증가
        _lock.WaitOne();
        _owningThreadID = currentThreadId;
        _recursionCount = 1;
    }

    public void Leave()
    {
        //다른 스레드가 락을 푼다? '예외'
        if (_owningThreadID != Thread.CurrentThread.ManagedThreadId)
            throw new InvalidOperationException();

        // 재귀 카운트가 0이 되면 락 해제
        if (--_recursionCount == 0)
        {
            _owningThreadID = 0;
            _lock.Set();
        }
    }

    public void Dispose()
    {
        _lock?.Dispose();
    }
}

 

에? Mutex는 저런 정보 연산하느라 느려졌다면서 똑같이 해버리면 성능 차이가 있음? 

 

ㅇㅇ 있음. 얘가 훨씬 빠르다. 왜냐? 소유권을 추적하기 위한 코드들이 관리 코드 내에서 실행되기 때문이다. 커널 모드로 전환하는 순간은 WaitOne을 호출할 때와 Set을 호출할 때가 전부임. (바꿔 말하면, Mutex는 스레드 ID 처리하는 것도 커널 코드로 한다는 말인 듯)

 


참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 29장. 단순 스레드 동기화 요소  [883~919p]