언어/C#

복합 스레드 동기화 요소

tsyang 2022. 5. 29. 23:31

2022.05.09 - [언어/C#] - 단순동기화3 - 커널 모드 동기화

 

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

2022.05.06 - [언어/C#] - 단순동기화2 (유저 모드 동기화 요소) 커널 모드 동기화 요소 커널 모드 동기화 요소는 유저 모드 동기화 요소에 비해서 상당히 느리다. 왜냐? 커널 모드 동기화 요소가 운영

tsyang.tistory.com

 

앞에서 유저모드 동기화, 커널 모드 동기화에 대해 다뤘다.

 

이 둘을 잘 섞은 복합 스레드 동기화 요소에 대해 알아보겠다. (제프리 리쳐가 만든 용어인듯?)

 

 

복합 스레드 동기화 요소


얘는 스레드들이 경쟁 상태가 아닐 때에는 유저 모드 동기화 요소를 써서 성능상의 장점을 취하고, 다수의 스레드가 경쟁 상태일 경우 커널 모드 동기화 요소를 써서 스피닝이 발생하지 않도록 한다.

 

 보통 단일 동기화 요소를 두고 여러 스레드가 경쟁하는 경우는 그리 자주 발생하는 경우가 아니기 때문에 상당한 성능 향상을 도모할 수 있다. (커널 모드 동기화 요소가 유저 모드보다 수십배 느리니까)

 

FCL에서 제공하는 복합 스레드 동기화 요소들(Monitor...등)을 알아보기 전에 간단한 복합 스레드 동기화 요소를 만들어서 메커니즘을 훑어보자

 

public sealed class SimpleHybridLock : IDisposable
{
    //대기중인 스레드의 수, 유저 동기화 요소를 사용하기 위함
    private int _waiters = 0;

    private readonly AutoResetEvent _waiterLock = new AutoResetEvent(false);

    public void Enter()
    {
        //기다리는 애들이 아무도 없다~ waiters 증가시키고 리턴
        if (Interlocked.Increment(ref _waiters) == 1)
            return;

        //기다리는 애들 있음. 스레드 대기시킨다. (느림)
        _waiterLock.WaitOne();

        //이 시점에 도달하면 스레드가 락을 소유함
    }

    public void Leave()
    {
        //아무도 안 기다린다~ 바로 리턴
        if (Interlocked.Decrement(ref _waiters) == 0)
            return;

        //다른 애들이 대기하니까 넘겨준다. (느리다)
        _waiterLock.Set();
    }

    public void Dispose()
    {
        _waiterLock?.Dispose(); //이것도 느림
    }
}

아~ 대충 느낌온다.

 

그러나 SimpleHybridLock은 객체가 생성될 때 AutoResetEvent도 생성되기에 성능에 나쁜 영향을 미친다. 이를 위하여 AutoResetEventSlim 이라는 클래스가 있는데 얘는 경쟁상태가 발생할 때 까지 AutoResetEvent 생성을 미룬다. 

 

 

 

 

FCL에서 제공하는 복합 동기화 요소


ManualResetEventSlim, SemaphoreSlim

~~Slim으로 이름 지어진 동기화 요소들은 복합 동기화 요소라고 보면 된다. 아무튼 이 둘은 유저 모드에서 스피닝을 하다가 경쟁 상태가 발생하면 그제서야 동기화 요소를 생성한다. 타임아웃과 CancellationToken 을 받을수도 있다.

 

 

Monitor 클래스와 싱크 블록 (Sync block)

힙에 생성되는 모든 객체는 싱크 블록이라 부르는 데이터 구조를 가지고 있다. 싱크 블록에는 다음의 필드가 있다.

  • 커널 객체를 위한 필드
  • 싱크 블록을 소유한 스레드의 ID
  • 중복 횟수
  • 대기 중인 스레드의 개수

 

딱 봐도 뭐가 많다. 모든 힙에 생성되는 객체가 이런 데이터 구조체를 갖는다는건 엄청난 낭비일 것이다. 그래서 그냥 포인터만 가지고 있다고 보면 된다. 그리고 싱크 블록 자체는 CLR이 싱크 블록 배열을 가지고 풀(pool)처럼 관리한다. 

 

아무튼 Monitor 클래스는 이런 싱크블록을 이용한 스레드 소유권, 중복 소유를 지원하는 상호 배제 락이다. 굉장히 많이 쓰이는 락이기도 하다. 그러면 일단 Monitor 클래스를 어떻게 쓰는지 보자.

 

public class Transation1
{
    private DateTime _timeOfLastTrans;

    public void Perform()
    {
        Monitor.Enter(this);
        _timeOfLastTrans = DateTime.Now;
        Monitor.Exit(this);
    }

    public DateTime GetLastTransaction()
    {
        Monitor.Enter(this);
        DateTime temp = _timeOfLastTrans;
        Monitor.Exit(this);
        return temp;
    }
}

아~ 대충 Enter과 Exit으로 동기화를 수행하는구나, 그런데 매개변수로 객체를 넘겨줘서 해당 객체의 싱크 블록을 사용하는구나~ 라는걸 추측할 수 있다.

 

이런데 위 코드는 심각한 문제의 가능성을 가지고 있다.

 

var t = new Transation1();
Monitor.Enter(t);
ThreadPool.QueueUserWorkItem(o => Console.WriteLine(t.GetLastTransaction()));
Monitor.Exit(t);

 

위 코드를 보자, 언듯 보면 Enter잘했고 Exit잘했고... 문제가 없어 보인다. 그러나 문제는 GetLastTransaction() 메서드에서 

 

Monitor.Enter(this);
Monitor.Exit(this);

 

위처럼 this를 사용한다는 점인데 이 this가 t와 같으니 동기화 타이밍이 이상해진다. 따라서 내부적으로 사용되는 락은 반드시 private로 선언하여 사용하는게 좋다.

 

public class Transation2
{
	private readonly object _lock = new object();

    private DateTime _timeOfLastTrans;

    public void Perform()
    {
        Monitor.Enter(_lock);
        _timeOfLastTrans = DateTime.Now;
        Monitor.Exit(_lock);
    }

    public DateTime GetLastTransaction()
    {
        Monitor.Enter(_lock);
        DateTime temp = _timeOfLastTrans;
        Monitor.Exit(_lock);
        return temp;
    }
}

 

아 참고로 Monitor는 static 클래스인데... 이로 인해 다음의 문제가 있다.

 

  • MarshalbyRefObject를 상속한 객체를 Monitor의 대상으로 사용하면 실제 객체가 아니라 프록시 객체가 락이 될 가능성이 있다.
  • 타입 객체를 Monitor의 대상으로 하면 안된다. 타입 객체는 도메인 중립적이라 모든 앱 도메인에 걸쳐서 락을 소유하게 되기 때문이다
  • 문자열 역시 주의해야 한다. 다른 앱도메인으로 넘어가도 참조만 넘어가기 때문이다. 
  • 값 타입을 넣으면 박싱된 객체가 락이 걸리므로 동기화가 이뤄지지 않는다.
  • 타입 생성자(정적 생성자)를 호출하면 CLR은 이 타입 객체에 대한 락을 획득하여 단일 스레드로 타입 객체를 초기화 한다. 타입은 도메인 중립 공간에 로드될 수 있기 때문에, 타입 생성자의 코드가 무한 루프에 빠지기라도 하면 모든 앱도메인에 걸쳐 타입을 상요하지 못할 수도 있다. 따라서 타입 생성자는 안 쓰는게 좋고, 쓰더라도 간결한 코드만 사용해야 한다.

 

 

lock

별 거 없다.

lock (this)
{
    SomeMethod();
}

위 코드는 다음의 코드와 완전히 동일한 작업을 수행한다.

 

bool lockTaken = false;
try
{
    Monitor.Enter(this, ref lockTaken);
    SomeMethod();
}
finally
{
    if(lockTaken)
        Monitor.Exit(this);
}

 

lock을 사용한 코드가 간결해 보이지만 사실 lock은 안 쓰는게 좋다. 이유는 다음과 같다.

 

  • finally에서 락을 풀어 응용 프로그램이 손상된 상태 정보와 잠재적인 보안 약점을 가진 채로 계속 수행되는 것 보다 그냥 멈추는게 낫다.
  • try 블록을 들락 날락 하는 것이 성능에 좋지 않은 영향을 미친다.

 

 

 

ReaderWriterLockSlim 

만약 여러 스레드가 한 데이터를 읽기만 하는 경우라면 동기화가 필요할까? 그렇지 않다. 따라서 이런 상황 염두에 두고 성능을 개선한 락이 바로 ReaderWriterLockSlim 클래스이다.

 

얘는 다음과 같이 동작한다

  • 특정 스레드가 데이터를 쓰고 있으면, 다른 스레드들의 접근 요청을 블로킹한다.
  • 특정 스레드가 데이터를 읽고 있으면, 다른 스레드들의 읽기 요청은 허용하되 쓰기는 막는다.
  • 데이터를 쓰고 있던 스레드가 작업을 완료하면, 쓰기 요청을 한 스레드나 읽기 요청을 한 다수의 스레드들 둘 중 하나의 블로킹을 풀어준다. 
  • 데이터를 읽고 있던 스레드가 작업을 완료하면, 쓰기 요청을 한 스레드의 블로킹을 풀어준다.

 

사용법은 다음과 같다.

public class Transaction2
{
    private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(
        LockRecursionPolicy.NoRecursion  //스레드 소유권과 중복 소유 허용?, 허용하면 성능 하락
        );

    private DateTime _timeOfLastTrans;

    public void Perform()
    {
        _lock.EnterWriteLock(); //쓰기에 대한 락
        _timeOfLastTrans = DateTime.Now;
        _lock.ExitWriteLock();
    }

    public DateTime GetLastTransaction()
    {
        _lock.EnterReadLock();  //읽기에 대한 락
        DateTime temp = _timeOfLastTrans;
        _lock.ExitReadLock();
        return temp;
    }
}

 

 

스레드 동기화 요소에 대한 요약


1. 스레드를 블로킹하는 코드는 그냥 웬만하면 안 쓰는게 좋다. 그러나 다음의 경우에는 고려해볼 수 있다.

 

  • 프로그래밍 모델을 단순화 : 콜백 메서드 없이 코드를 순차적으로 작성할 수 있다. 그러나 C#의 비동기 메서드는 스레드를 블로킹 하지 않으면서 단순화 할 수 있다.
  • 스레드가 고유의 목적을 가진다. : GUI전용 스레드 같은 경우.

 

2. 참고로 스레드의 용처를 마음속으로 제한하는건 지양해야 한다.예를 들어, 문법 검사 스레드, 철자 검사 스레드같은 특정 요청을 처리하는 스레드를 만들지 말라는 것이다. 스레드의 사용처를 제한하는 것은 결국 스레드가 다른 일을 못 하도록 제한하는 것과 같다. 스레드는 비싼 리소스이므로 그냥 스레드 풀에서 빌려쓰는 구조가 낫다. 

 

3. Monitor는 무난하게 쓰기 좋다. SpinLock이 Monitor보다 빠르긴 하지만 CPU 시간을 허비한다. SpinLock을 반드시 쓸 만큼  Monitor의 성능이 그렇게 나쁜 것도 아니고, 중복 소유를 지원함에도 성능이 괜찮다. 

 

4. 락을 소유하는 코드를 작성할 때는 너무 오랜 시간 락을 소유하지 않도록 코드를 작성하는게 좋다. (당연한 소리)

5. 계산 중심 작업의 경우 태스크와 ContinueWith를 적극 활용하자. 

 


참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 30장. 복합 스레드 동기화 요소  [921~942p]