C# 6.0 완벽 가이드/ 고급 스레드 기법

동기화 개요

  • 동기화(synchronization)란 동시에 실행되는 작업들이 예측 가능한 최종 결과를 내도록 그 작동을 조율하는 것을 말한다. 동기화는 여러 스레드가 같은 자료에 접근할 때 특히나 중요하다. 그런 코드를 작성할 때는 뭔가를 빼먹거나 잘못 구현하기가 놀랄만큼 쉽다
  • 가장 간단하고 유용한 동기화 도구는 14장에서 설명한 연속(continuation) 기능과 작업 조합기(task combinator)일 것이다. 동시적 프로그램을 다수의 비동기 연산들이 연속 작업 객체들과 조합기들로 연결된 구조로 만들면 잠금과 신호 전달의 필요성이 줄어든다.
    • 그렇지만 저수준 수단들을 동원해야 하는 경우도 여전히 존재한다.
  • 동기화 수단들은 크게 다음 세 부류로 나뉜다.
    • 독점 잠금
        • 독점 잠금(exclusive locking)은 한 번에 단 하나의 스레드만 어떠한 활동을 수행하거나 코드의 한 부분을 실행하게 만드는 수단이다. 독점 잠금은 여러 스레드가 서로 간섭하지 않고 공유 상태에 접근해서 상태를 변경할 수 있게 하는데 주로 쓰인다.
      • C#의 독점 잠금 수단으로는 lock과 Mutex, SpinLock이 있다.
    • 비독점 잠금
      • 비독점 잠금(nonexclusive locking)은 동시성을 제한하는 수단이다. 비독점 잠금 수단으로는 Semaphore(Slim)과 ReaderWriterLock(Slim)이 있다.
    • 신호 전달
      • 신호 전달(signaling)은 다른 스레드로부터 하나 또는 여러 개의 통지를 받을 때까지 한 스레드의 실행을 차단하는 수단이다.
      • 신호 전달 수단으로는 ManualResetEvents(Slim), AutoResetEvent, CountdownEvent, Barrier가 있다. 처음 셋을 이벤트 대기 핸들(event wait handles)이라고 부른다.
  • 비차단 동기화(nonblocking synchronization) 수단들을 이용해서 잠금 없이 공유 상태에 대한 동시적 연산을 수행하는 것도 가능하다.(까다롭긴 하지만)
    • 비차단 동기화 수단으로는 THread.MemoryBarrier, Thread.VolatileRead, Thread.VolatileWrite.volatile 키워드, Interlocked 클래스가 있다.

독점 잠금

  • C#의 독점 잠금 수단은 lock문, Mutex, SpinLock 세 가지이다. lock 문이 가장 편하고 널리 쓰이지만 다른 둘도 나름의 용도가 있다.
    • Mutext를 이용하면 독점 잠금을 여러 프로세스에 걸쳐 적용할 수 있다. (컴퓨터 범위 자물쇠)
    • SpinLock은 고도의 동시성이 필요한 상황에서 문맥 전환 비용을 줄일 수 있는 세밀한 최적화를 구현한다.

lock 문

  • 잠금의 필요성을 보여주는 예로, 다음과 같은 클래스르 생각해 보자.
class ThreadUnsafe
{
  static int _val1 = 1, _val2 = 1;

  static void Go()
  {
    if (_val2 != 0)  Console.WriteLine(_val1 / _val2);
    _val2 = 0;
  }
}
  • 이 클래스는 스레드에 안전하지 않다. Go를 두 스레드가 동시에 호출하면 0으로 나누기 오류가 발생할 수 있다.
    • 다음은 lock문을 이용해서 이 문제를 해결한 버전이다.
class ThreadSafe
{
  static readonly object _locker = new object();
  static int _val1 = 1, _val2 = 1;

  static void Go()
  {
    lock(_locker)
    {
      if (_val2 != 0)  Console.WriteLine(_val1 / _val2);
      _val2 = 0;
    }
  }
}
  • 하나의 동기화 대상 객체(이 예제의 _locker), 즉 독점 자물쇠는 한 번에 한 스레드만 잠금 수 있으며, 같은 자물쇠를 잠그려 하는 다른 모든 스레드는 자물쇠가 풀릴 때까지 차단된다.
    • 둘 이상의 스레드가 하나의 자물쇠를 두고 경합하는 경우, 그 스레드들은 ‘준비 대기열(ready queue)’에 추가되며, 선착순 방식으로 자물쇠가 주어진다.
    • 독점 자물쇠는 자물쇠가 보호하려는 대상에 대한 접근을 강제로 직렬화하는 효과를 낸다고 할 수 있다.
    • 다른 말로 하면 독점 잠금에서는 한 스레드의 접근이 다른 어떤 스레드의 접근과 겹치는 일이 없다.

Monitor.Enter와 Monitor.Exit

  • 사실 C#의 lock 문은 Monitor.Enter 메서드 호출과 Monitor.Exit 메서드 호출 그리고 하나의 try/finally 블록으로 확장되는 일종의 단축 표기이다.
    • 앞의 예제의 Go 메서드는 실제로는 다음과 같은 코드가 된다(어느 정도 단순화 했음)
Monitor.Enter(_locker);
try
{
  if (_val2 != 0)  Console.WriteLine(_val1 / _val2);
  _val2 = 0;
}
finally { Monitor.Exit(_locker); }
  • Monitor.Exit를 호출하려면 같은 객체에 대해 먼저 Monitor.Enter를 호출했어야 한다. 이를 위반하면 Monitor.Exit가 예외를 던진다.

lockTaken 메서드 중복 적재

  • 앞의 코드는 C# 1.0, 2.0, 3.0의 컴파일러가 lock문을 번역한 결과에 해당한다.
    • 그런데 이 코드에는 미묘한 취약점이 있다. 확률이 높지는 않지만, Monitor.Enter 호출과 try 블록 사이에서 예외가 발생할 수도 있다.(이를테면 그 스레드에 대해 Abort가 호출되었거나, OutOfMemoryException 예외가 발생했거나 등의 이유로)
    • 그런 경우에는 자물쇠가 잠길(획득) 수도 있고 아닐 수도 있다. 예외 때문에 실행이 try/finally 블록에 진입하지 않으므로, 만일 자물쇠가 잠겼다면 그 자물쇠는 절대 풀리지 않는다.
    • 그러면 ‘자물쇠 누수’가 생긴다. 이러한 위험을 피하기 위해 CLR 4.0 설계자들은 Monitor.Enter에 다음과 같은 중복 적재 버전을 추가했다.
public static void Enter(object obj, ref bool lockTaken);
  • Enter 호출 이후에 lockTaken이 false라면 Enter 메서드가 예외를 던졌으며 자물쇠가 획득되지(잠기지) 않았다는 뜻이다. 그 외의 상황에서 lockTaken이 false가 되는 경우는 전혀 없다.
  • 다음은 이를 이용해서 자물쇠 누수를 방지하는 예이다. (C# 4.0 이후의 컴파일러가 lock 문을 번역한 결과와 정확히 일치한다.)
bool lockTaken = false;
try
{
  Monitor.Enter(_locker, ref lockTaken);
  // 작업을 수행 ...
}
finally { if (lockTaken) Monitor.Exit(_locker); }

TryEnter 메서드

  • Monitor는 TryEnter라는 메서드도 제공한다. 이 메서드는 Enter와 같되, 만료 시간을 밀리초 단위의 정수 또는 TimeSpan 객체로 지정할 수 있다는 점이 다르다.
    • 이 메서드는 만일 자물쇠를 획득했으면 true를 자물쇠를 획득하지 못한 채로 시간이 만료 되었으면 false를 돌려준다.
    • 인수 없이 TryEnter를 호출할 수 있다. 그런 경우 메서드는 만일 자물쇠를 당장 획득하지 못하면 즉시 false를 돌려준다.
    • 즉, 자물쇠 획득 가능 여부를 즉시 판정하고 싶다면 인수 없이 TryEnter를 호출하면 된다.
    • Enter 메서드처럼 CLR 4.0부터 TryEnter는 lockTaken 인수를 받도록 중복적재 되었다.

동기화 대상 객체의 선택

  • 동기화할 모든 스레드가 볼 수 있는 객체라면 어떤 것이라도 동기화 대상 객체(synchronizing object)가 될 수 있다. 단, 반드시 참조 형식의 객체이어야 한다는 규칙을 지켜야 한다.
    • 흔히 클래스의 전용(private) 인스턴스 필드 또는 전용 정적 필드를 동기화 대상 객체로 사용한다(전용 필드를 사용하면 잠금 논리를 캡슐화 하는데 도움이 되기 때문이다.)
    • 동기화 수단을 통해서 보호하고자 하는 객체 자체를 동기화 대상 객체로 사용하기도 한다. 다음의 _list 필드가 그러한 예이다.
class ThreadSafe
{
  List<string> _list = new List<string>();

  void Test()
  {
    lock(_list)
    {
      _list.Add("Item 1");
      ...
  • 이와는 달리 자물쇠로만 사용할 필드를 따로 둘 수도 있다. 그러면 잠금의 범위와 입도(granularity)를 좀 더 세밀하게 제어할 수 잇다는 장점이 생긴다.
    • 또한 보호할 필드를 담고 있는 객체(this)를 동기화 대상 객체로 사용하거나
lock(this) { ... }
  • 심지어는 그 형식을 동기화 대상 객체로 사용할 수도 있다.
lock(typeof (Widget)) { ... }  // 정적 멤버에 대한 접근을 보호하려는 경우
  • 이런 방식의 잠금에는 잠금의 논리를 캡슐화 할 수 없다는, 그래서 교착(deadlock)과 과도한 차단을 방지하기가 어렵다는 단점이 있다.
    • 또한 형식에 대한 잠금은 현재 응용 프로그램 도메인 경계를 벗어나서 다른 도메인(같은 프로세스 안의)으로 스며들 여지가 있다.
  • 람다 표현식이나 익명 메서드에 갈무리된 지역 변수에 대해 자물쇠를 걸 수도 있다.
  • 동기화 대상 객체를 잠근다고 해서 동기화 대상 객체 자체에 대한 접근이 제한되지는 않는다. 예컨대 다른 스레드가 lock(x)를 실행했다고 해서 x.ToString() 호출이 차단되지 않는다.
    • 차단이 일어나라면 두 스레드 모두 lock(x)를 실행해야 한다.

언제 잠글 것인가?

  • 기본적인 규칙은 임의의 쓰기 가능 공유 필드에 대한 접근이 일어나는 부분을 잠가야 한다는 것이다.
    • 한 필드에 하나의 배정 연산을 수행하는 아주 간단한 경우에도 동기화를 고려할 필요가 있다.
    • 한 예로 다음 클래스의 Increment 메서드와 Assign 메서드 모두 스레드에 안전하지 않다.
class ThreadUnsafe
{
  static int _x;
  static void Increment() { _x++; }
  static void Assign() { _x = 123; }
}
  • 다음은 Increment와 Assign의 스레드 안전 버전이다.
static readonly object _locker = new object();
static int _x;
static void Increment() { lock(_locker) _x++; }
static void Assign() { lock(_locker) _x = 123; }
  • 잠금이 필요한 곳에 자무로시를 걸지 않으면 두 가지 문제가 발생한다.
    • 변수 증가 같은 연산이(심지어는 특정 상황에서는 하나의 변수를 읽고 쓰는 연산도) 원자적이지 않다.
    • 컴파일러와 CLR, CPU는 성능 향상을 위해 명령들의 순서를 바꾸거나 변수들을 CPU 레지스터들에 보관할 수 있다. 단일 스레드 프로그램의 (또는 자물쇠를 사용하는 다중 스레드 프로그램의) 행동을 변경하지 않는 한 그러한 최적화는 적법하다.
  • 잠금은 자물쇠 전후에 메모리 장벽(memory barrier)을 만들어서 둘째 문제를 완화한다.
    • 메모리 장벽은 명령 순서 변경과 캐싱의 효과가 넘지 못하는 ‘울타리’에 해당한다.
  • 자물쇠 뿐만 아니라 다른 모든 동기화 수단도 메모리 장벽을 만든다.
    • 따라서 예컨대 신호 전달 수단을 이용해서 한 번에 하나의 스레드만 변수를 읽고 쓸 수 있게 했다면 추가로 자물쇠를 둘 필요가 없다.
    • 다음 코드는 x 접근 주변을 잠그지 않아도 이미 스레드에 안전하다.
var signal = new ManualResetEvnet(false);
int x = 0;
new Thread(() => { x++; signal.Set(); }).Start();
signal.WaitOne();
Console.WriteLine(x);  // 1 (항상)

잠금과 원자성

  • 일단의 변수들을 항상 같은 자물쇠를 걸어서 읽고 쓴다면 그 변수들의 읽기/ 쓰기는 원자적(atomic)으로 일어난다고 할 수 있다.
    • 에컨대 필드 x와 y를 항상 다음과 같은 locker에 대한 lock 블록 안에서 읽고 쓴다고 하자.
lock (locker) { if (x != 0) y /= x; }
  • 이 블록에서 x와 y는 원자적으로 접근된다. 왜냐하면 다른 어떤 스레드가 이 블록이 실행되는 도중에 x나 y를 변경함으로써 이 블록의 실행 결과를 무효화할 수는 없기 때문이다.
    • 다른 모든 스레드도 항상 locker를 잠근채로 x와 y에 접근하는 한, 이 lock 블록에서 0으로 나누기 오류는 절대 발생하지 않는다.
  • 독점 자물쇠가 제공하는 원자성은 lock 블록 안에서 예외가 발생하면 꺠진다. 예컨대 다음 코드를 생각해 보자.
decimal _savingsBalance, _checkBalance;

void Transfer(decimal amount)
{
  lock (_locker)
  {
    _savingsBalance += amount;
    _checkBalance -= amount + GetBackFee();
  }
}
  • 만일 GetBankFee()가 예외를 던지면 은행은 돈을 잃게 된다. 지금 예에서는 GetBankFee를 더 일찍 호출하면 이 문제를 피할 수 있다.
    • 좀 더 복잡한 경우에는 catch 절이나 finally 절 안에서 롤백(rollback) 논리를 구현하는 것이 해결책이다.
  • 이와 비슷하지만 다른 개념으로 명령 원자성이 있다. 어떤 명령이 원자적이라는 것은 그 명령이 바탕 프로세서에서 더 작은 단위로 나뉘어서 실행되는 일이 없음을 뜻한다.

중첩된 잠금

  • 하나의 스레드가 같은 객체를 여러 번 잠글 수도 있다. 이러한 능력을 재진입(rentrant)이라고 부른다. 재인입은 반드시 다음 예처럼 중첩된 형태이어야 한다.
lock (_locker)
  lock (_locker)
    lock (_locker)
    {
      // 뭔가를 수행한다.
    }
  • 또는 다음과 같이 중첩할 수도 있다.
Monitor.Enter(locker); 
Monitor.Enter(locker);
Monitor.Enter(locker); 
// 뭔가를 수행한다.
Monitor.Exit(locker);
Monitor.Exit(locker);
Monitor.Exit(locker);
  • 이런 식으로 잠긴 객체(지금 예의 locker)는 오직 가장 바깥쪽 lock 문이 종료되어야 또는 Monitor.Exit가 Monitor.Enter와 똑같은 횟수로 호출되어야 풀린다.
  • 중첩된 장금(nested locking)은 한 메서드가 자물쇠를 가진 상태에서 다른 메서드를 호출 할 때 유용하다. 다음이 그러한 예이다.
static readonly object _locker = new object();
static void Main()
{
  lock(_locker)
  {
    AnotherMethod();
    // 여전히 자물쇠를 가진 상태이다(잠금의 재진입이 가능하므로)
  }
}

static void AnotherMethod()
{
  lock(_locker) { Console.WriteLine("다른 메서드"); }
}
  • 하나의 스레드는 오직 첫 번째 잠금(최외곽 lock문)에서만 차단될 수 있다.

교착

  • 교착(deadlock)은 두 스레드가 각자 상대방이 가진 자원을 기다릴 때 발생한다. 그런 경우 두 스레드 모두 상대를 무한히 기다리게 된다.
    • 다음은 두 개의 자물쇠로 이루어진 교착 상태를 보여주는 예이다.
object locker1 = new object();
object locker2 = new object();

new Thread(() => {
  lock(locker1)
  {
    Thread.Sleep(1000);
    lock(locker2);  // 교착
  }
}).Start();

lock (locker2)
{
  Thread.Sleep(1000);
  lock(locker1);  // 교착
}
  • SQL Server의 CLR은 교착을 자동으로 검출하고 관련 스레드 중 하나를 종료해서 교착 상태를 풀지만, 표준 호스팅 환경의 CLR 은 그렇지 않다.
    • 잠금 시 만료 시간을 지정하지 않는 경우, 스레드 교착이 발생하면 관련 스레드들이 무한히 차단된다.
    • 반면 SQL Server CLR 통합 호스트는 교착을 자동으로 검출해서 관련 스레드 중 하나에 대해 잡을 수 있는 예외를 던진다.
  • 교착은 다중 스레드 프로그래밍에서 아주 어려운 문제 중 하나이다. 특히 연관된 객체들이 많으면 문제가 더욱 심해진다.
    • 이 문제를 어렵게 만드는 근본 원인은 현재 메서드를 호출한 코드가 어떤 자물쇠를 획득하고 있는지 확실하게 알아 내기가 불가능하다는 점이다.
  • 예컨대 전용 필드 a와 그 필드를 잠그는 어떤 메서드를 가진 클래스 x가 있다고 가정할 때, 교착은 이런 식으로 일어난다.
    • 한 스레드가 자신의 호출자(또는 그 호출자의 호출자)가 클래스 y의 필드 b를 이미 잠근 상태임을 모르는 상태에서 자신의 필드 a를 잠근다.
    • 한편 다른 어떤 스레드는 a가 잠겨 있음을 모르는 상태에서 클래스 y의 b를 잠근다.
    • 그러면 두 스레드는 상대가 가진 자물쇠가 풀리길 무한히 기다리게 된다.
    • 모순적이게도 (좋은) 객체지향 설계 패턴은 이러한 문제를 더욱 악화한다. 그런 패턴들은 호출 사슬이 실행시점에서야 결정되게 만드는 경향이 있기 때문이다.
  • ‘교착을 피하려면 객체들을 일관된 순서로 잠가야 한다’라는 유명한 조언은 이번 절의 첫 번째 예제에는 도움이 되지만, 방금 설명한 상황에서는 적용하기 힘들다.
    • 더 나은 전략은 객체 자신을 참조하고 있을 수도 있는 다른 어떤 객체의 메서드를 호출하는 코드 주변을 세심하게 잠그는 것이다.
    • 또한 다른 클래스의 메서드를 호출하는 부분을 꼭 잠가야 하는지도 고려해야 한다(스레드 안전성에서 나오듯이 꼭 그래야 하는 경우가 있긴 하지만 다른 대안이 있는 경우도 종종 있다)
    • 그리고 작업 조합기나 연속 작업, 자료 병렬성, 불변이 형식 같은 고수준 동기화 수단들을 좀 더 많이 사용해서 잠금의 필요성을 줄이는 것도 한 방법이다.
  • 이 문제를 자물쇠를 가진 상태에서 다른 객체의 메서드를 호출하면 그 자물쇠의 캡슐화가 미묘하게 새어나간다고 이해할 수도 있을 것이다.
    • 이는 CLR이나 .NET Framework의 잘못이 아니라 일반적인 잠금 기법의 근본 한계이다. 잠금의 문제점을 해결하려는 연구가 다양하게 진행되고 있는데, 그중 하나는 소프트웨어 트랜잭션 메모리(Software Transactional Memory)이다.
  • 자물쇠를 가진 상태에서 Dispatcher.Invoke나 Contorl.Invoke를 호출할 때도 교착이 발생할 수 있다.
    • 하필 UI가 같은 자물쇠가 풀리기를 기다리는 다른 메서드에서 실행되고 있다면 그 지점에서 교착이 발생한다.
    • 이 문제는 그냥 Invoke 대신 BeginInvoke를 호출하는 것으로(또는 동기화 문맥이 존재할 때 이를 암묵적으로 수행하는 비동기 함수들을 사용해서) 간단히 해결되는 경우가 많다.
    • 아니면 Invoke 호출 전에 자물쇠를 풀 수도 있지만, 현재 코드의 호출자가 이미 자물쇠를 가지고 있다면 교착이 해결되지 않는다.

성능

  • 잠금은 빠르다. 경합이 없었다고 할 때 자물쇠 하나를 획득하고 해제하는데 50나노초 미만이 걸린다.(2015년급 컴퓨터 기준)
    • 여러 스레드가 경합하는 경우에는 그에 따른 문맥 전환 때문에 소비 시간이 마이크로초 수준으로 올라가며 운영체제가 스레드에 실제로 실행 시간을 할당하기까지는 더 많은 시간이 걸릴 수 있다.

뮤텍스

  • C#에서 Mutex 클래스로 대표되는 뮤텍스는 독점 자물쇠(lock 문으로 잠그는)와 비슷하되, 프로세스 경계를 넘어서 작동한다는 차이가 있다.
    • 다른 말로 하면 뮤텍스는 응용 프로그램 전역 뿐만 아니라 컴퓨터 전역에서 작동한다.
    • 경합이 없을 때 뮤텍스 하나를 획득하고 해제하는데는 약 1마이크로초가 걸린다. 이는 lock 문보다 약 20배 느린 속도이다.
  • Mutext 클래스는 뮤텍스를 잠그는 WaitOne 메서드와 뮤텍스를 푸는 ReleaseMutex 메서드를 제공한다.
    • lock 문과 마찬가지로 하나의 뮤텍스(Mutex 객체)는 반드시 그것을 획득한 스레드에서 풀어야 한다.
  • 잠긴 뮤텍스에 대해 ReleaseMutex를 호출하지 않고 Close나 Dispose를 호출하면 그 뮤텍스를 기다리던 다른 한 스레드에서 AbandonedMutexException 예외가 발생한다.
  • 여러 프로세스에 걸친 Mutex는 프로그램을 한 번에 한 인스턴스만 실행할 수 있게 하는데 흔히 쓰인다. 다음이 그러한 예이다.
class OnAtATimePlease
{
  static void Main()
  {
    // 이름을 부여한 Mutex 객체는 컴퓨터 전역에서 사용할 수 있다.
    // 독자의 회사와 응용 프로그램에 고유한 이름(이를테면 URL이 포함된)을 부여하는 것이 바람직하다.
    using (var mutex = new Mutex(true, "oreilly.com OneAtATimeDemo"))
    {
      // 프로그램의 다른 인스턴스가 종료 절차를 진행하고 있을 수도 있으므로 경합이 해소될 여유 시간을 둔다.
      if (!mutex.WaitOne(TimeSpan.FromSeconds(3), false))
      {      
        Console.WriteLine("이 앱의 다른 인스턴스가 실행 중이므로 종료합니다.");
        return;
      }
      try { RunProgram(); }
      finally { mutex.ReleaseMutex(); }
    }
  }

  static void RunProgram()
  {
    Console.WriteLine("실행 중 - 종료하려면 Enter 키를 누르세요");
    Console.ReadLine();
  }
}
  • 응용 프로그램을 터미널 서비스 하에서 실행하는 경우, 일반적으로 컴퓨터 전역 Mutex는 같은 터미널 서버 세션에서 실행되는 응용 프로그램들에만 보인다. 모든 터미널 서버 세션에서 보이게 하려면 뮤텍스 이름을 Global\로 시작해야 한다.

잠금과 스레드 안전성

  • 임의의 다중 스레드 상황에서 정확히 작동하는 프로그램이나 메서드를 가리켜 ‘스레드에 안전하다’라고 말한다. 스레드 안전성(thread safety)은 기본적으로 잠금을 통해서 그리고 스레드들의 상호작용 가능성을 줄임으로써 얻을 수 있다.
  • 범용 형식이 그 자체로 스레드에 안전한 경우는 드물다. 그 이유는 다음과 같다.
    • 스레드 안전성을 만족하는데 필요한 개발상의 부담이 클 수 있다. 형식에 필드가 많으면 더욱 그렇다(모든 필드가 임의의 다중 스레드 문맥에서 접근될 가능성이 있으므로)
    • 스레드 안전성을 확보하면 성능이 나빠질 수 있다(게다가 형식이 실제로 다중 스레드 상황에서 쓰이지 않는다고 해도 스레드 안전성 확보에 의한 비용이 부분적이나마 발생할 수 있다.)
    • 형식을 스레드에 안전하게 만든다고 해도 그 형식을 사용하는 프로그램이 저절로 스레드에 안전해지지는 않는다. 프로그램의 스레드 안전성을 확보하는데 들이는 노력이 형식의 스레드 안전성 관련 노력과 겹치는 경우도 많다.
  • 따라서 일반적으로 스레드 안전성은 구체적인 다중 스레드 시나리오를 처리하는 목적으로 필요한 곳에서 필요한 만큼만 확보한다.
  • 그렇긴 하지만 크고 복잡한 클래스를 임의의 다중 스레드 환경에서 안전하게 실행되게 만드는 ‘요령’이 몇 가지 있긴 하다.
    • 그중 하나는 세밀한 잠금을 포기하고 대신 코드의 커다른 부분을 하나의 독점 자물쇠로 감싸서(심지어는 객체 전체에 대한 접근을 그런 식으로 잠그기도 하다) 고수준에서 접근을 직렬화하는 것이다.
    • 사실, 스레드에 안전하지 않은 서드파티 코드(대부분의 .NET Framework 형식들도 여기에 속한다)를 다중 스레드 문맥에서 사용하려는 경우에는 이 접근방식이 필수이다.
    • 이때 핵심은 스레드 비안전 객체의 모든 속성, 메서드, 필드에 대한 저븐을 동일한 독점 자물쇠로 보호하는 것이다.
    • 이 해법은 객체의 메서드들이 모두 짧게만 실행되는 경우에 효과적이다(그렇지 않으면 차단이 많이 발생한다)
    • 기본 내장 형식들을 제외할 때, .NET Framework의 형식들의 인스턴스는 동시적인 읽기 전용 접근을 제외한 모든 접근에서 스레드에 안전하지 않다. 기본적으로 스레드 안전성 확보(주로 독점 자물쇠를 이용한)는 전적으로 프로그래머의 몫이다.
  • 또 다른 요령은 공유 자료를 최소화해서 스레드들의 항호작용을 최소화하는 것이다.
    • 이는 훌륭한 접근방식이며 여러 ‘상태 없는’ 중간층 응용 프로그램과 웹 페이지 서버가 암묵적으로 이 접근방식을 사용한다.
    • 그런 서버들은 흔히 클라이언트들의 동시적은 요청을 다수의 스레드로 처리하므로 요청을 처리하는 서버 메서드들은 반드시 스레드에 안전해야 한다.
    • 상태 없는 설계(규모가변성이 좋아서 인기 있다)에서는 클래스들이 요청들 사이에서 자료를 유지하지 않으며, 따라서 상호작용 가능성이 애초에 제한된다.
    • 따라서 스레드 상호작용은 그냥 필요한 정적 필드를 공유하는 정도로만 일어난다.
    • 이를테면 서버의 클래스들은 자주 쓰이는 자료를 메모리에 담아 두거나 인증 및 감사 같은 기반 서비스를 제공하는 목적으로 정적 필드들을 둘 수 있다.
  • 상태 있는 리치-클라이언트 응용 프로그램에 적용할만한 또 다른 해법은 공유 상태에 접근하는 코드를 UI 스레드에서 실행하는 것이다. 14장에서 보았듯이 비동기 함수들을 이용하면 이러한 해법을 손쉽게 적용할 수 있다.
  • 스레드 안전성을 확보하는 마지막 접근 방식은 자동 잠금 체제(automatic locking regime)를 사용하는 것이다.
    • ContextBoundObject의 파생 클래스를 만들어서 거기에 Synchronization 특성을 부여할 때 .NET Framework가 적용하는 것이 바로 이 방법이다.
    • 그러한 클래스의 객체에 대해 어떤 메서드나 속성이 호출되면 자동으로 객체 전역 자물쇠가 잠기며, 메서드나 속성의 실행이 끝나면 자물쇠가 풀린다.
    • 이 방법은 프로그래머의 스레드 안전성 확보 부담을 줄여주지만, 대신 다른 여러 문제점을 가지고 있다.
    • 바로 다른 방법에서는 발생하지 않았을 교착이 발생할 수 있다는 점과 동시성 수준이 낮아진다는 점, 그리고 의도치 않은 재진입성이 생긴다는 점이다.
    • 이러한 이유로 일반적으로는 프로그래머가 직접 잠금을 적용하는 방식이 더 낫다. 적어도 덜 단순한 자동 잠금 체제가 .NET Framework에 도입되기 전까지는 그렇다.

스레드 안전성과 .NET Framework의 형식들

  • 잠금을 적절히 적용하면 스레드 비안전 코드를 스레드에 안전한 코드로 바꿀 수 있다. 이를 적용하기에 적합한 대상이 바로 .NET Framework 자체이다.
    • 기본 내장 형식이 아닌 거의 대부분의 .NET Framework 형식들은 그 인스턴스가 스레드에 안전하지 않다(읽기 전용 접근 이상의 모든 접근에 대해)
    • 그렇긴 하지만 주어진 개게에 대한 모든 접근을 하나의 자물쇠로 보호한다면 그런 형식들도 다중 스레드 코드에서 사용할 수 있다.
    • 한 예로 다음은 두 스레드가 하나의 목록(List 형식의 컬렉션)에 동시에 항목을 추가한 후 목록을 열거하는 코드이다.
class ThreadSafe
{
  static List<string> _list = new List<string>();

  static void Main()
  {
    new Thread(AddItem).Start();
    new Thread(AddItem).Start();
  }

  static void AddItem()
  {
    lock(_list) _list.Add("Item " + _list.Count);

    string[] items;
    lock (_list) items = _list.ToArray();
    foreach (string s in items) Console.WriteLine(s);
  }
}
  • 이 예제는 _list 객체 자체를 자물쇠로 사용한다. 만일 서로 연관된 두 개의 목록을 보호해야 했다면 둘에 공통인 어떤 객체를 잠가야 했을 것이다. (두 목록 중 하나를 사용할 수도 있지만, 개별적인 필드를 사용하는 것이 더 낫나)
  • .NET 컬렉션의 열거 역시, 열거 도중 컬렉션이 수정되면 예외가 발생한다는 점에서 스레드에 안전하지 않은 연산이다.
    • 이 예제는 열거의 시작에서 끝까지 잠금을 유지하는 대신, 목록의 항목들을 배열에 복하는 동안만 잠금을 유지하고 그 배열을 열거한다.
    • 컬렉션을 열겋면서 시간이 많이 걸리는 작업을 수행해야 하는 경우, 이렇게 하면 자물쇠를 필요 이상으로 오래 잠가둘 필요가 없다.

스레드 안전 객체에 대한 접근의 잠금

  • 스레드 안전 객체에 대한 접근을 잠가야 하는 경우도 있다.
    • 이해를 돕는 예로 .NET Framework의 List 클래스가 스레드에 안전하다고 가정하고 목록에 항목을 하나 추가한다고 하자.
if (!list_Contains(newItem)) _list.Add(newItem);
  • 목록 자체가 실제로 스레드에 안전하다고 해도 이 문장은 전혀 스레드에 안전하지 않다. 항목 존재 여부를 판정하는 부분과 새 항목을 추가하는 부분 사이에서 스레드 선점이 일어날 수 있기 때문이다.
    • 이를 피하려면 문장 전체를 자물쇠로 잠가야 하며, 이 목록을 수정하는 모든 코드에서 그 자물쇠를 사용해야 한다.
    • 예컨대 앞의 문장이 선점되지 않게 하려면 다음 문장도 같은 자물쇠로 보호해야 한다.
_list.Clear();
  • 다른 말로 하면 스레드에 안전한 컬렉션이라도 스레드 비안전 컬렉션과 정확히 동일한 방식으로 잠금을 적용할 필요가 있다. (따라서 List 클래스 자체를 스레드에 안전하게 만드는 것은 노력의 중복일 뿐이다)
  • 동시성 수준이 높은 환경에서는 컬렉션 접근 코드를 잠그면 차단이 과도하게 일어날 수 있다. 이 문제 때문에 .NET Framework 4.0은 스레드에 안전한 대기열과 스택, 사전을 제공하는데 이들은 23장에서 논의한다.

정적 멤버

  • 한 객체에 대한 접근을 커스텀 자물쇠로 감싸는 것은 관련된 모든 동시적 스레드가 그 자물쇠를 알고 있으며 실제로 사용하는 경우에만 효과가 있다.
    • 그런데 객체의 범위가 아주 넓다면 그러한 규칙을 일관되게 적용하기가 힘들다. 최악의 경우는 공용 형식의 정적 멤버이다.
    • 예컨대 DateTime 구조체의 한 정적 속성인 DateTime.Now가 스레드에 안전하지 않다고 상상해보자. 그러면 동시적인 두 호출 때문에 결과가 엉망이 되거나 예외가 발생할 것이다.
    • 이를 해결하는 유일한 방법은 외부 자물쇠로 형식 자체를 잠근 후에 DateTime.Now를 호출하는 것, 즉 lock(typeof(DateTime)) 블록 안에서 DateTime.Now에 접근하는 것이다.
    • 단 이는 한 응용 프로그램을 개발하는 모든 프로그래머가 그런 규칙을 철저히 지킬 때만(사실 그럴 가능성은 작다) 효과가 있다. 더 나아가서 형식을 잠그는 것 자체에도 나름의 문제점이 있다.
  • 그래서 DateTime 구조체의 정적 멤버들은 애초에 스레드에 안전하도록 세심하게 구현되어 있다.
    • 정적 멤버는 스레드 안전, 인스턴스 멤버는 스레드 비안전이라는 패턴은 .NET Framework 전반에서 흔히 볼 수 있다.
    • 공개적으로 쓰일 형식을 독자가 직접 만들 때에도 스레드 안전과 관련해서 해결 불가능한 골칫거리를 만들어 내기 싫다면 이 패턴을 따르는 것이 바람직하다.
    • 다른 말로 하면 정적 메서드를 스레드에 안전하게 만든다는 것은 그 형식의 소비자가 스레드 안전성을 좀 더 쉽게 확보할 수 있도록 돕는 것에 해당한다.
  • 정적 메서드의 스레드 안전성은 반드시 명시적으로 코딩해야 할 과제이다. 메서드를 정적으로 지정한다고 해서 저절로 스레드에 안전해지는 것이 아니다.

읽기 전용 스레드 안전성

  • 커스텀 형식을 만들 때, 동시적인 읽기 전용 접근에 대한 스레드 안전성을 갖추는 것은 그 형식을 사용하는 코드에서 과도한 잠금을 피할 수 있다는 점에서 바람직한 일이다.
    • 실제로 .NET Framework의 여러 형식이 그러한 원칙을 따른다. 예컨대 컬렉션들은 동시적인 판독자들에 대해 스레드 안전이다.
  • 독자가 이 원칙을 따르는 것도 어렵지 않다. 동시적인 읽기 전용 접근에 대한 스레드 안전성을 형식에 부여하려면, 사용자가 읽기 전용이라고 시대할만한 메서드에서는 필드의 값을 변경하지 말아야(또는 필드 변경 주변을 잠가야) 한다.
    • 한 예로 어떤 컬렉션 클래스의 ToArray 메서드는 배열을 출력하기 전에 먼저 컬렉션의 내부 자료구조를 정리하는 과정이 필요할 수 있다.
    • 그러나 그렇게 하면 이 메서드가 읽기 전용이라고 기대하는 소비자가 작성한 코드는 스레드에 안전하지 않을 것이다.
  • 읽기 전용 스레드 안전성은 .NET Framwork에서 열거 가능 형식과 열거자(enumerator)가 분리된 이유 중 하나이다.
    • 두 스레드가 하나의 컬렉션을 동시에 열거할 수 있는 것은, 각자 개별적인 열거자 객체를 사용하기 때문이다.
  • 문서화가 없는 상황에서는 주어진 메서드가 읽기 전용인지 아닌지를 추측할 수 밖에 없는데, 이때 최대한 조심할 필요가 있다.
    • 좋은 예가 Random 클래스이다. Random.Next는 읽기 전용일 것 같지만, 사실 이 메서드를 호출하면 난수 발생 알고리즘에 쓰이는 어떤 전용 필드가 변한다.
    • 따라서 Random 개게를 사용하는 부분을 자물쇠로 잠그거나 스레드마다 개별적인 인스턴스를 둘 필요가 있다.

응용 프로그램 서버의 스레드 안전성

  • 응용 프로그램 서버가 여러 클라이언트의 요청을 동시에 처리하려면 여러 개의 스레드를 돌려야 한다.
    • WCF, ASP.NET, Web Services 응용 프로그램은 암묵적으로 다중 스레드이다. TCP나 HTTP 같은 네트워크 채널을 사용하는 Remoting 서버 승용 프로그램 역시 마찬가지다.
    • 따라서 서버 쪽에서 실행되는 코드를 작성할 때 만일 클라이언트 요청들을 처리하는 스레드들 사이의 상호작용이 발생할 가능성이 있다면 반드시 스레드 안전성을 고려해야 한다.
    • 다행히 그런 상호작용이 발생하는 부분은 흔치 않다. 전형적인 서버 쪽 클래스는 상태가 없거나(즉, 필드가 하나도 없거나) 각 클라이언트 또는 각 요청에 대해 개별적인 객체 인스턴스를 생성하는 활성화 모형을 사용한다.
    • 상호작용은 오직 정적 필드들(이를테면 데이터베이스의 자료 일부를 메모리에 캐싱하거나 성능 개선을 위해 쓰이는)을 통해서만 일어난다.
  • 예컨대 데이터베이스 질의를 수행하는 RetrieveUser 라는 메서드가 있다고 하자.
// User 는 사용자 자료를 담은 필드들을 가진 커스텀 클래스
internal User RetrieveUser(int id) { ... }
  • 이 메서드가 자주 호출된다면 호출 결과들을 정적 Dictionary에 캐싱해서 성능을 향상할 수 있을 것이다. 다음은 스레드 안전성을 고려한 캐싱 구현이다.
static class UserCache
{
  static Dictionary<int, User> _users = new Dictionary<int, User>();

  internal static User GetUser(int id)
  {
    User u = null;
    lock(_users)
      if (_users.TryGetValue(id, out u))
        return u;

    u = RetrieveUser(id);  // 데이터베이스에서 사용자를 조회
    lock (_users) _users[id] = u;
    return u;
  }
}
  • 스레드 안전성을 보장하려면 적어도 사전을 읽고 갱신하는 부분은 자물쇠로 보호해야 한다. 지금 예에서는 단순함과 잠금 성능 사이의 현실적인 타협점을 선택했다.
    • 사실 이 설계에는 약간의 비효율성이 존재한다(아주 드물게만 발생하겠지만). 만일 두 스레드가 동시에 이 메서드를 호출한다면, 그리고 두 id 모두 이전에 조회된 적이 없다면, RetrieveUser가 두 번 호출된다. 그리고 사진이 쓸데 없이 갱신된다.
    • 메서드 전체를 잠그면 이러한 비효율성이 방지되겠지만, 그러면 더 큰 비효율성이 생긴다. RetrieveUser 호출이 진행되는 동안 캐시 전체가 잠기며, 그 동안 다른 스레드는 그 어떤 사용자도 조회할 수 없다.

불변이 객체

  • 불변이 객체(immutable object)란 내부에서나 외부에서나 객체의 상태를 변경할 수 없는 객체를 말한다. 보통의 경우 불변이 객체의 필드들은 읽기 전용으로 선언되며, 필드들의 값은 전적으로 객체 생성과정에서 결정된다.
  • 불변이성은 함수형 프로그래밍의 주요 특징이다. 함수형 프로그램이(functional programming)에서는 기존 객체를 변이(mutating)하는 대신, 다른 속성들로 새 객체를 생성한다.
    • LINQ도 이 패러다임을 따른다. 공유 쓰기 가능 상태의 문제를 피할 수 있다는 점에서, 불변이성은 다중 스레드 상황에서도 가치가 있다.
    • 불변이성은 애초에 ‘쓰기 가능’을 제거(또는 최소화)한다.
  • 불변이 객체의 한 가지 응용 패턴은 잠금이 유지되는 동안 관련 필드들의 그룹을 캡슐화하는 것이다.
    • 간단한 예로 어떤 클래스에 다음과 같은 두 필드가 있으며 이들을 원자적으로 읽고 써야 한다고 하자.
int _percentComplete;
string _statusMessage;
  • 이들에 대한 접근을 자물쇠로 잠그는 대신, 다음과 같은 불변이 클래스를 정의한다.
class ProgressStatus  // 어떤 활동의 진행 정도를 나타내는 클래스
{
  public readonly int PercentComplete;
  public readonly string StatusMessage;

  // 이외에 여러 필드가 있을 수 있다.

  public ProgressStatus (int percentComplete, string statusMessage)
  {
    PercentComplete = percentComplete;
    StatusMessage = statusMessage;
  }
}
  • 다시 원래의 클래스로 돌아가서 이제 이 ProgressStatus 형식의 필드 하나를 잠금용 객체와 함께 정의한다.
readonly object _statusLocker = new object();
ProgressStatus _status;
  • 이제는 배정문 하나만 잠금 채로 이 형식의 값들을 읽고 쓸 수 있다.
var status = new ProgressStatus(50, "작업 중");
// 여기서 다른 여러 필드를 배정한다.
lock(_statusLocker) _status = status; // 아주 짧은 잠금
  • 객체를 읽을 때는 우선 잠금 상태에서 객체 참조의 복사본을 얻는다. 그런 다음에는 잠금을 푼 상태에서 얼마든지 값들을 읽을 수 있다.
ProgressStatus status;
lock(_statusLocker) status = _status; // 역시 아주 짧은 잠금
int pc = status.PercentComplete;
string msg = status.StatusMessage;
...

비독점 잠금

세마포

  • 세마포(semaphore)는 나이트클럽과 비슷하다. 수용할 수 있는 사람의 수가 유한하고 입구에서 경비원이 입장을 통제한다.
    • 자리가 다 차면 더 이상의 입장이 불허되며, 입자을 원하는 사람들은 문밖에 줄을 서야 한다. 한 사람이 나오면 대기열의 한 사람이 들어간다.
    • 따라서 나이트클럽을 나타내는 클래스의 생성자는 적어도 두 개의 인수, 즉 더 받을 수 있는 인원수를 설정하는 인수와 클럽의 최대 수용 인원수를 설정하는 인수를 받아야 할 것이다.
  • 수용량(더 받을 수 있는 인원수)이 1인 세마포는 Mutex나 lock과 비슷하다. 단 세마포에는 ‘소유자’라는 것이 없다. 즉, 세마포는 스레드를 구분하지 않는다.
    • 그 어떤 스레드도 Semaphore 객체에 대해 Release를 호출할 수 있다. 반면 Mutex나 lock에서는 오직 자물쇠를 획득한 스레드만 그 자물쇠를 풀 수 있다.
  • .NET Framework에는 세마포를 대표하는 비슷한 기능을 가진 클래스가 두 개 있다. Semaphore와 SemaphoreSlim이 바로 그것이다. 후자는 .NET Framework에서 도입된 것으로, 병렬 프로그래밍의 낮은 잠복지연 요구를 만족하도록 최적화되었다.
    • SemaphoreSlim은 전통적인 다중 스레드 적용에도 유용하다. 대기시 취소 토큰을 지정할 수 있고, 비동기 프로그래밍을 위한 WaitAsync 메서드도 제공하기 때문이다. 그러나 프로세스 간 신호전달에는 사용할 수 없다.
    • Semaphore는 WaitOne과 Release 호출에 약 1마이크로초를 소비한다. SemaphoreSlim의 소비시간은 그것의 10분의 1정도이다.
  • 동시성을 제한하려 할 때, 즉 특정 코드 조각을 한 번에 너무 많은 스레드가 실행하는 일을 방지하려 할 때 세마포가 유용할 수 있다.
    • 다음은 다섯 개의 스레드가 나이트클럽에 입장하려 하지만 한 번에 세 스레드만 입장하도록 제한하는 예이다.
class TheClub  // 입구 목록이 없음을 주목
{
  static SemaphoreSlim _sem = new SemaphoreSlim(3);  // 수용량 3

  static void Enter(object id)
  {
    Console.WriteLine(id + "번 손님 입장 원함");
    _sem.Wait();
    Console.WriteLine(id + "번 입장했음!");
    Thread.Sleep(1000 * (int)id);
    Console.WriteLine(id + "번 나감");
    _sem.Release();
  }
}

// 출력
// 1번 손님 입장 원함
// 1번 입장했음!
// 2번 손님 입장 원함
// 2번 입장했음!
// 3번 손님 입장 원함
// 3번 입장했음!
// 4번 손님 입장 원함
// 5번 손님 입장 원함
// 1번 나감
// 4번 입장했음!
// 2번 나감
// 5번 입장했음!
  • Metex처럼 이름을 지정해서 생성한 Semaphore 객체는 여러 프로세스에서 사용할 수 있다.

읽기/쓰기 자물쇠

  • 한 형식의 인스턴스가 동시적 읽기 연산에 대해서는 스레드에 안전하지만 동시적 갱신에 대해서는(그리고 동시적 읽기 및 갱신에 대해서도) 안전하지 않은 경우가 아주 많다. 파일 같은 자원들도 마찬가지다.
    • 그런 형식의 인스턴스에 대한 모든 종류의 접근을 간단한 독점 자물쇠로 보호하는 방법도 잘 통하지만, 읽기 연산은 자주 일어나지만 갱신은 드물게 일어나는 경우에는 동시성이 필요 이상으로 제한된다.
    • 빠른 조회를 위해 자주 쓰이는 자료를 정적 필드들에 담아두는 업무용 응용 프로그램 서버가 그런 경우에 해당한다. 바로 그런 상황에서 잠금을 최소화 하는 목적으로 만들어진 것이 ReaderWriterLockSlim 클래스이다.
  • ReaderWriterLockSlim은 기존의 ‘뚱뚱한’ ReaderWriterLock 클래스를 대체하려는 목적으로 .NET Framework 3.5에서 도입되었다.
    • ReaderWriterLock 클래스도 이 클래스와 비슷한 기능을 제공하지만, 속도가 몇 배 느릴 뿐만 아니라 자물쇠 업그레이드를 처리하는 메커니즘의 설계에 본질적인 결함이 존재한다.
    • 그러나 ReaderWriterLockSlim도 보통의 lock문(Monitor.Enter/Exit 조합)보다는 느리다. (두 배 정도) 대신 경합이 적다는 장점이 있다(읽기가 많고 쓰기는 아주 적은 경우)
  • 두 클래스 모두 기본적으로 두 종류의 자물쇠를 하나씩 사용한다. 바로 읽기 자물쇠와 쓰기 자물쇠이다.
    • 쓰기 자물쇠는 응용 프로그램 전역에서 독점적이다.
    • 읽기 자물쇠는 다른 읽기 자물쇠와 호환된다.
  • 좀 더 구체적으로 말하면 한 스레드가 쓰기 자물쇠를 잠그고 있는 상황에서 다른 스레드들은 그 쓰기 자물쇠는 물론이고 읽기 자물쇠를 얻지 못한다.
    • 그러나 쓰기 자물쇠가 잠겨 있지 않으면 임의의 개수의 스레드가 동시에 읽기 자물쇠를 획득할 수 있다.
  • 읽기 자물쇠와 쓰기 자물쇠를 획득하거나 해제하는 ReaderWriterLockSlim의 메서드들은 다음과 같다.
public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();
  • 또한 모든 EnterXXX에는 이름 앞에 Try가 붙은 버전이 있다. 이전에 살펴본 Monitor.TryEnter처럼 그런 버전들은 만료 시간 인수를 추가로 받는다. (자원에 대한 경합이 심하면 시간 만료가 상당히 쉽게 일어날 수 있다.)
    • ReaderWriterLock도 비슷한 메서드들을 제공하는데, 이름이 AcquireXXX와 ReleaseXXX이다.
    • 이들은 시간이 만료되면 false를 돌려주는 것이 아니라 ApplicationException을 던진다.
  • 다음은 ReaderWriterLockSlim의 사용법을 보여주는 프로그램이다. 스레드 세 개가 하나의 목록을 계속해서 열거하는 동안, 다른 두 스레드가 100ms마다 난수를 목록에 추가한다.
    • 판독자(reader) 즉 목록을 읽는 스레드들의 연산을 읽기 자물쇠를 이용해서 보호하고 목록 기록자(writer)들의 연산은 쓰기 자물쇠를 이용해서 보호한다.
class SlimDemo
{
  static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
  static List<int> _items = new List<int>();
  static Random _rand = new Random();

  static void Main()
  {
    new Thread(Read).Start();
    new Thread(Read).Start();
    new Thread(Read).Start();

    new Thread(Write).Start("A");
    new Thread(Write).Start("B");
  }

  static void Read()
  {
    while (true)
    {
      _rw.EnterReadLock();
      foreach (int i in _items) Thread.Sleep(10);
      _rw.ExitReadLock();
    }
  }

  static void Write(object threadID)
  {
    while(true)
    {
      int newNumber = GetRandNum(100);
      _rw.EnterWriteLock();
      _items.Add(newNumber);
      _rw.ExitWriteLock();
      Console.WriteLine("Thread " + threadID + " added " + newNumber);
      Thread.Sleep(100);
    }
  }

  static int GetRandNum(int max) { lock(_rand) return _rand.Next(max); }
}
  • 실제 코드에서는 try/finally를 이용해서 잠금 도중 예외가 발생해도 자물쇠들이 확실히 해제되게 해야 할 것이다.
  • 다음은 이 프로그램의 출력 예이다.
Thread B added 61
Thread A added 83
Thread B added 55
Thread A added 33
...
  • ReaderWriterLockSlim을 사용하면 보통의 자물쇠를 사용할 때보다 더 많은 스레드가 Read 연산을 동시에 수행할 수 있다. 동시 판독자 수를 확인하기 위해, Write 메서드의 while 루프 시작 부분에 다음 줄을 추가해 보자.
Console.WriteLine(_rw.CurrentReadCount + " concurrent readers");
  • 프로그램을 실행하면 거의 항상 ‘3 concurrent readers’가 출력될 것이다(Read 메서드는 대부분의 시간을 foreach 루프에서 소비한다)
    • CurrentReadCount 외에도 ReaderWriterLockSlim 은 다음과 같이 자물쇠들을 감시하는데 유용한 여러 속성을 제공한다.
public bool IsReadLockHeld { get; }
public bool IsUpgradeableReadLockHeld { get; }
public bool IsWriteLockHeld { get; }

public bool WaitingReadCount { get; }
public bool WaitingUpgradeCount { get; }
public bool WaitingWriteCount { get; }

public bool RecursiveReadCount { get; }
public bool RecursiveUpgradeCount { get; }
public bool RecursiveWriteCount { get; }

업그레이드 가능 자물쇠

  • 하나의 원자적 연산에서 읽기 자물쇠와 쓰기 자물쇠를 교환하는 것이 유용할 때가 있다.
  • 예컨대 주어진 항목이 목록에 아직 없을 때만 그것을 목록에 추가하는 메서드를 작성한다고 하자. 이상적으로는 (독점) 쓰기 자물쇠를 잠그는 시간을 최소화하는 것이 바람직하므로 메서드를 다음과 같이 진행하면 될 것이다.
    1. 읽기 자물쇠를 얻는다.
    2. 주어진 항목이 목록에 있는지 점검해서, 있다면 자물쇠를 풀고 반환한다. (return 문)
    3. 읽기 자물쇠를 푼다.
    4. 쓰기 자물쇠를 얻는다.
    5. 항목을 추가한다.
  • 그런데 이러한 과정에는 심각한 문제가 있다. 바로 단계 3과 4사이에서 다른 어떤 스레드가 끼어들어서 목록을 수정(이를테면 같은 항목 추가)할 수 있다는 점이다.
    • 이 문제를 해결하기 위해 ReaderWriterLockSlim은 업그레이드 가능 자물쇠(upgradeable lock)라는 제 3의 자물쇠를 제공한다.
    • 업그레이드 가능 자물쇠는 읽기 자물쇠와 비슷하되, 나중에 하나의 원자적인 연산으로 쓰기 자물쇠로 승격할 수 있다는 차이가 있다. 다음은 이 자물쇠를 사용하는 과정이다.
      1. EnterUpgradeableReadLock을 호출한다.
      2. 읽기 기반 활동을 수행한다(이를테면 주어진 항목이 목록에 있는지 판정하는 등)
      3. EnterWriteLock을 호출한다(업그레이드 가능 자물쇠가 쓰기 자물쇠로 바뀐다)
      4. 쓰기 기반 활동을 수행한다(이를테면 항목에 목록을 추가하는 등)
      5. ExitWriteLock을 호출한다(쓰기 자물쇠가 다시 업그레이드 가능 자물쇠로 바뀐다)
      6. 다른 읽기 기반 활동을 수행한다.
      7. ExitUpgradeableReadLock을 호출한다.
  • 호출자의 관점에서 보면 이는 중첩된 잠금 또는 재귀적 잠금과 꽤 비슷하다.
    • 그러나 기능면에서 보면 단계 3에서 ReaderWriterLockSlim은 읽기 자물쇠를 해제하고 완전히 새로운 쓰기 자물쇠를 얻는 과정을 원자적으로 수행한다.
  • 업그레이드 가능 자물쇠와 읽기 자물쇠의 또 다른 중요한 차이점은 업그레이드 가능 자물쇠는 임의의 개수의 읽기 자물쇠와 공존할 수 있지만, 업그레이드 가능 자물쇠 자체는 한 번에 한 스레드만 획득할 수 있다는 점이다.
    • 이는 서로 경합하는 자물쇠 변환들을 직렬화하는 과정에서 변환이 교착되는 상황을 방지하기 위한 것으로 SQL Server의 갱신 자물쇠(update lock)와 동일한 작동 방식이다.
SQL Server ReaderWriterLockSlim
공유 자물쇠 읽기 자물쇠
독점 자물쇠 쓰기 자물쇠
갱신 자물쇠 업그레이드 가능 자물쇠

 

  • 업그레이드 가능 자물쇠의 활용 방법을 보여주는 예로 다음은 이전 예제의 Write 메서드를 주어진 수가 목록에 없는 경우에만 목록에 추가하도록 고친 것이다.
while(true)
{
   int newNumber = GetRandNum(100);
  _rw.EnterUpgradeableReadLock();
  if (!_items.Contains(newNumber))
  {
    _rw.EnterWriteLock();
    _items.Add(newNumber);
    _rw.ExitWriteLock();
    Console.WriteLine("Thread " + threadID + " added " + newNumber);
  }
  _rw.ExitUpgradeableReadLock();
  Thread.Sleep(100);
}
  • ReaderWriterLock도 자물쇠 변환을 수행할 수 있지만, 업그레이드 가능 자물쇠라는 개념을 지원하지 않으므로 신뢰성이 없다. ReaderWriterLockSlim의 설계자들이 애초에 새로운 클래스를 만든 것은 이 때문이다.

재귀적 잠금

  • 보통의 경우 ReaderWriterLockSlim은 재귀적 잠금(recursive locking) 또는 중첩된 잠금을 허용하지 않는다. 예컨대 다음 코드는 예외를 던진다.
var rw = new ReaderWriterLockSlim();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();
  • 그러나 ReaderWriterLockSlim 인스턴스를 다음과 같이 생성하면 위의 재귀적 잠금이 오류 없이 작동한다.
var rw = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
  • 이는 프로그래머가 실제로 재귀적 잠금을 원하는 경우에만 재귀적 잠금을 활성화하는 장치라 할 수 있다.
    • 재귀적 잠금에서는 다음과 같이 종류가 다른 자물쇠들이 동시에 잠길 수 있으며, 그러면 코드가 필요 이상으로 복잡해질 여지가 있다.
rw.EnterWriteLock();
rw.EnterReadLock();
Console.WriteLine(rw.IsReadLockHeld);  // true
Console.WriteLine(rw.IsWriteLockHeld);  // true
rw.ExitReadLock();
rw.ExitWriteLock();
  • 재귀적 잠금의 기본적인 규칙은, 어떤 자물쇠를 획득한 상태에서는 그보다 작은 자물쇠만 획득할 수 있다는 것이다. 자물쇠들의 대소 관계는 다음과 같다.
    • 읽기 자물쇠 < 업그레이드 가능 자물쇠 < 쓰기 자물쇠
  • 단 업그레이드 가능 자물쇠에서 쓰기 자물쇠로의 승격 요청은 항상 적법하다.

이벤트 대기 핸들을 이용한 신호 전달

  • 가장 간단한 신호 전달(signaling) 수단은 이벤트 대기 핸들(event wait handle)이다. (여기서 말하는 이벤트는 C#의 함수 멤버의 일종인 이벤트와는 다른 것이다)
    • 이벤트 대기 핸들은 AutoResetEvent, ManualResetEvent(Slim), CountdownEvent 세 종류이다. 처음 둘은 EventWaitHandle 클래스의 파생 클래스들로 그 클래스의 모든 기능성을 상속한다.

AutoResetEvent

  • Windows의 자동 재설정 이벤트(auto reset event)를 대표하는 AutoResetEvent는 지하철에서 볼 수 있는 자동 개폐식 개찰구와 비슷하다. 자동 재설정 이벤트의 ‘자동’은 유효한 티켓을 제시하면 출입구가 자동으로 열림을 뜻하고, ‘재설정’은 승객이 개찰구를 통과하면 문이 원래의 닫힌 상태로 되돌아 감을 뜻한다.
    • 스레드가 AutoResetEvent에 대해 WaitOne을 호출하는 것은 개찰구 뒤에 줄을 서는 것에 해당한다. 여러 스레드가 WaitOne을 호출하면 개찰구 뒤에 줄(대기열)이 생긴다.
    • 일단 WiatOne 호출의 차단이 풀린, 다시 말해 개찰구 바로 앞에 도달한 스레드는 Set을 호출해서 개찰구에 티켓을 넣는다. 그 어떤 스레드도 티켓을 제시할 수 있다.
    • 다른 말로 하면 AutoResetEvnet 객체에 접근할 수 있는 모든 (차단되지 않은) 스레드는 Set을 호출할 수 있다. 그러면 WaitOne 호출이 차단된 한 스레드의 차단이 풀린다.
  • AutoResetEvnet 객체를 생성하는 방법은 두 가지이다. 첫째는 다음처럼 생성자를 사용하는 것이다.
// 생성자에 true를 지정하는 것은 객체를 생성한 즉시 Set을 호출하는 것과 같다.
var auto = new AutoResetEvent(false);
  • 둘째 방법은 다음과 같다.
var auto = new EventWaitHandle(false, EventResetMode.AutoReset);
  • 다음 예제는 두 개의 스레드를 실행한다. 한 스레드는 그냥 다른 스레드가 신호하길 기다리기만 한다.
class BasicWaitHandle
{
  static EventWaitHandle _waitHandle = new AutoResetEvent(false);

  static void Main()
  {
    new Thead(Waiter).Start();
    Thread.Sleep(1000);  // 1초 지연
    _waitHandle.Set();  // Waiter를 깨운다.
  }

  static void Waiter()
  {
    Console.WriteLine("대기 중...");
    _waitHandle.WaitOne();  // 통지를 기다린다.
    Console.WriteLine("통지 되었음");
  }
}

// 출력
// 대기 중... (잠시 지연) 통지 되었음.

  • 대기 중인 스레드가 하나도 없는 상태에서 Set이 호출되면 해당 이벤트 대기 핸들은 어떤 스레드가 WaitOne을 호출할 때까지 열린 상태를 유지한다. 이러한 행동 방식은 개찰구에 다가가는 스레드와 개찰구에 티켓을 넣는 스레드 사이의 경쟁 조건을 피하는데 도움이 된다.(한 스레드가 개찰구에 다가가는 사이에 다른 스레드가 티켓을 넣어 버리면 전자의 스레드는 무한히 대기하게 된다.)
    • 그런데 대기 중인 스레드가 하나도 없는 상태에서 Set을 여러번 호출한다고 해서 나중에 도달한 스레드들이 한꺼번에 개찰구를 통과하게 되지는 않는다. 오직 바로 다음 스레드 하나만 개찰구를 통과하며 그 외의 스레드들은 ‘티켓을 날린’ 결과가 된다.
  • AutoResetEvent 객체에 대해 Reset을 호출하면 대기 또는 차단 없이 바로 개찰구가 닫힌다(열려 있었던 경우)
  • WaitOne을 호출할 때 만료 시간을 지정할 수도 있다. 만일 신호를 받아서가 아니라 시간이 만료되어서 대기가 끝났다면 WaitOne은 false를 돌려준다.
  • 만료 시간을 0으로 해서 WaitOne을 호출하면 해당 대기 핸들이 ‘열려 있는지’의 여부를 차단 없이 판정할 수 있다. 그런데 그러한 호출이 AutoResetEvent를 재설정한다는(즉, 개찰구가 열려 있었다면 그것을 닫는다는) 점을 주의해야 한다.

대기 핸들의 처분

  • 대기 핸들을 다 사용한 후 관련 자원을 운영체제에 돌려주고 싶다면 해당 객체에 대해 Close 메서드를 호출하면 된다. 아니면 그냥 대기 핸들에 대한 모든 참조가 범위를 벗어난 후 쓰레기 수거기가 자원을 해제하길 기다릴 수도 있다. (대기 핸들은 종료자가 Close를 호출하는 처분 의미론을 구현한다)
    • 대기 핸들은 운영체제의 자원을 조금만 사용한다는 점에서 이처럼 자원 정리를 쓰레기 수거기에 맡겨도 큰 문제가 없다(논의의 여지가 있긴 하지만) 이는 흔치 않는 경우이다.
  • 처분되지 않은 대기 핸들들은 응용 프로그램 도메인이 메모리에서 제거될 때 자동으로 해제된다.

양방향 신호 전달

  • 주 스레드가 일꾼 스레드(worker thread)에게 연달아 세 번 신호해야 한다고 하자. 일꾼 스레드가 신호를 처리하는데는 어느 정도 시간이 걸릴 것이므로, 주 스레드가 그냥 대기 핸들에 대해 Set을 쉬지 않고 여러 번 호출하면 일꾼 스레드가 두 번째나 세 번째 신호를 놓칠 위험이 있다.
  • 이에 대한 해결책은 주 스레드가 일꾼 스레드가 신호를 받을 준비가 될 때까지 기다렸다가 신호를 보내는 것이다. 다음은 이를 두 개의 AutoResetEvent를 이용해서 구현한 예이다.
class TwoWaySignaling
{
  static EventWaitHandle _ready = new AutoResetEvent(false);
  static EventWaitHandle _go = new AutoResetEvent(false);
  static readonly _locker = new object();
  static string _message;

  static void Main()
  {
    new Thead(Work).Start();

    _ready.WaitOne();  // 우선 일꾼이 준비되길 기다린다.
    lock(_locker) _message = "ooo";
    _go.Set();

    _ready.WaitOne();  
    lock(_locker) _message = "ahhh";  // 일꾼에게 또 다른 메시지를 보낸다.
    _go.Set();

    _ready.WaitOne();
    lock(_locker) _message = null;  // 일꾼에게 종료 신호를 보낸다.
    _go.Set();
  }

  static void Work()
  {
    while (true)
    {
      _ready.Set();  // 신호 받을 준비가 되었음을 알리고
      _go.WaitOne();  // 신호를 기다린다.
      lock(_locker)
      {
        if (_message == null) return;  // 매끄럽게 종료한다.
        Console.WriteLine(_message);
      }
    }
  }
}

// 출력
// ooo
// ahhh

  • 이 예제는 널 메시지를 일꾼에게 작업을 마치라고 알려주는 의미로 사용한다. 무한히 실행되는 스레드에는 이런 ‘출구 전략’을 갖추는 것이 중요하다.

ManualResetEvent

  • 14장에서 설명했듯이, 수동 재설정 이벤트에 해당하는 ManualResetEvent는 단순한 관문처럼 작동한다. Set을 호출하면 문이 열리며, 그러면 임의의 개수의 스레드들이 WaitOne을 호출해서 관문을 통과할 수 있다. Reset을 호출하면 문이 닫힌다.
    • 스레드가 닫힌 문에 대해 WaitOne을 호출하면 실행이 차단된다. 나중에 문이 열리면 차단 되었던 모든 스레드가 한꺼번에 문을 통과한다. 이러한 차이점들을 제외하면 ManualResetEvent는 AutoResetEvent와 같은 방식으로 작동한다.
  • AutoResetEvent처럼 ManualEvent도 객체 생성 방법이 두 가지이다.
var manual1 = new ManuralResetEvent(false);
var manual2 = new EventWaitHandle(false, EventResetMode.ManualReset);
  • .NET Framework 4.0부터는 ManualResetEventSlim이라는 또 다른 수동 재설정 이벤트 클래스가 생겼다.
    • 이 클래스는 대기 시간을 줄이도록 최적화되었으며, 필요하다면 일정 횟수만큼 반복해서 이벤트의 설정을 시도하는 회전(spinning) 기능도 제공한다.
    • 또한 관리되는 구현이 좀 더 효율적이며, CancellationToken을 이용해서 Wait를 취소하는 기능도 있다. 그러나 프로세스 간 신호 전달에는 사용할 수 없다.
    • ManualResetEventSlim은 WaitHandle 기반 객체(전통적인 대기 핸들에 상응하는 성능을 내는)를 돌려주는 WaitHandle 속성을 제공한다.
  • ManualResetEvent는 한 스레드가 다른 여러 스레드를 차단할 필요가 있을 때 유용하다. 그 반대의 경우에 유용한 것은 CountdownEvent이다.

신호 전달 수단들의 성능 비교

  • AutoResetEvent나 ManualResetEvent의 대기나 신호 전달에는 약 1마이크로초가 걸린다(차단이 없다고 할 때)
    • 대기 시간이 짧은 시나리오에서는 ManualResetEventSlim과 CountdownEvent가 50배까지 빠를 수 있다. 운영체제에 의존하지 않으며 회전 기능을 적절히 사용하기 때문에 그러한 속도가 나온다.
    • 그러나 어차피 신호 전달 클래스 자체의 추가부담 때문에 병목이 생기는 경우는 드물기 때문에 이러한 속도 비교가 큰 의미가 있는 것은 아니다.

CountdownEvent

  • CountdownEvent를 이용하면 한 스레드가 여러 스레드의 신호를 기다릴 수 있다. 이 클래스는 .NET Framework 4.0에서 도입되었으며, 관리되는 코드로만 이루어진 구현이 효율적이다.
    • 이 클래스를 사용하려면 우선 기다리려는 스레드들의 개수를 지정해서 객체를 생성해야 한다.
    • 이 개수는 이벤트가 설정되기까지 남은 신호 횟수(coutn)에 해당한다.
var countdown = new CountdownEvent(3);  // 개수를 3으로 해서 객체를 생성
  • Signal을 호출하면 남은 신호 횟수가 하나 감소한다. 그리고 Wait 호출은 그 횟수가 0이 될때까지 차단된다.
static CountdownEvent _countdown = new CountDownEvent(3);

static void Main()
{
  new Thread(SaySomething).Start("저는 스레드 1입니다");
  new Thread(SaySomething).Start("저는 스레드 2입니다");
  new Thread(SaySomething).Start("저는 스레드 3입니다");
  _countdown.Wait();  // Signal이 세 번 호출될 때까지 차단된다.
  Console.WriteLine("모든 스레드가 발언을 마쳤음!");
}

static void SaySomething(object thing)
{
  Thread.Sleep(1000);
  Console.WriteLine(thing);
  _countdown.Signal();
}
  • CountdownEvent의 횟수를 AddCount를 호출해서 다시 증가할 수 있다. 그러나 이미 횟수가 0이 된 상태에서 이 메서드를 호출하면 예외가 발생한다.
    • 즉, 이미 설정된 CountdownEvent를 AddCount를 호출해서 설정되지 않은 상태로 만들 수는 없다. 예외 발생을 피하고 싶다면 AddCount 대신 TryAddCount를 호출해야 한다.
    • 만일 이 메서드가 false 를 돌려준다면 남은 신호 횟수가 이미 0에 도달한 것이다.
  • CountdownEvent 객체를 신호되지 않은 상태로 재설정하려면 Reset을 호출한다. 그러면 객체가 신호되지 않은 상태로 재설정될 뿐만 아니라 남은 신호 횟수도 원래 값으로 되돌아간다.
  • ManualResetEventSlim처럼 CountdownEvent도 WaitHandle 속성을 제공한다. WaitHandle 파생 형식의 객체를 요구하는 다른 클래스나 메서드가 있다면 이 속성을 사용하면 된다.

프로세스 간 EventWaitHandle 생성

  • EventWaitHandle 객체를 생성할 때 셋째 인수로 대기 핸들의 이름을 지정할 수 있으며, 그러면 대기 핸들을 여러 프로세스에 걸쳐서 사용할 수 있다.
    • 대기 핸들 이름은 그냥 임의의 문자열이다. 그 이름이 의도치 않게 다른 누군가의 대기 핸들 이름과 겹칠 수도 있음을 주의해야 한다.
    • 만일 현재 컴퓨터에서 이미 쓰이는 이름을 지정하면 생성자는 해당 대기 핸들의 참조를 돌려주고, 새로운 이름이면 운영체제가 새로 생성한 핸들을 돌려준다.
EventWaitHandle wh = new EventWaitHandle(false, EventResetMode.AutoReset, "MyCompany.MyApp.SomeName");
  • 같은 컴퓨터에서 두 응용 프로그램이 이 코드를 실행하면, 이 이벤트 대기 핸들을 이용해서 서로 신호를 보낼 수 있다. 명명된 대기 핸들은 두 프로세스의 모든 스레드에 걸쳐서 작동한다.

대기 핸들과 연속

  • 어떤 조건이 만족하길 기다렸다가 뭔가를 실행하려 할 때, 앞에서처럼 이벤트 대기 핸들을 기다리는(따라서 스레드가 차단되는) 대신 ThreadPool.RegisterWaitForSingleObject를 호출해서 ‘연속 작업’을 등록하는 방법도 있다.
    • 호출 시 지정한 대리자는 이후 대기 핸들이 신호를 받으면 실행된다.
static ManualResetEvent _starter = new ManualResetEvent(false);

public static void Main()
{
  RegisteredWaithandle reg = ThreadPool.RegisterWaitForSingleObject(_starter, Go, "어떤 자료", -1, true);
  Thread.Sleep(5000);
  Console.WriteLine("일꾼에게 신호 중...");
  _starter.Set();
  Console.ReadLine();
  reg.Unregister(_starter);  // 다 마쳤으면 정리한다.
}

public static void Go(object data, bool timedOut)
{
  Console.WriteLine("시작됨 - " + data);
  // 작업을 수행한다...
}

// 출력
// (5초 지연 후)
// 일꾼에세 신호 중...
// 시작 됨 - 어떤 자료
  • 대기 핸들이 신호되면(또는 시간이 만료되면) 스레드 풀에서 얻은 스레드에서 대리자가 실행된다. 그런 다음에는 Unregister를 호출해서 콜백에 대한 비관리 핸들을 해제하는 것이 바람직하다.
  • RegisterWaitForSingleObject 메서드는 대기 핸들과 대리자 외에 하나의 ‘블랙박스’ 객체를 받는다. 그 객체는 이후 대리자 메서드에 전달된다.(ParameterizedThreadStart 대리자와 비슷한 방식이다)
    • 이 메서드는 또한 밀리초 단위의 만료 시간(시간 만료 기능을 원하지 않으면 -1을 지정하면 된다)과 대리자를 한 번만 실행할 것인지 아니면 신호될 때마다 거듭 실행할 것인지 결정하는 부울 플래그도 있다.

대기 핸들을 작업 객체로 변환

  • 그런데 실제 응용에서 ThreadPool.RegisterWaitForSingleObject를 직접 사용하려면 다소 어색한 상황을 만나게 된다. 보통은 Unregister를 콜백 메서드 자체에서 호출하고 싶겠지만, 그러려면 등록 토큰이 마련되길 기다리는 장치가 필요하다.
    • 그런 이유로 다음처럼 대기 핸들을 Task 객체로 변환해서 await를 적용하는 확장 메서드를 만들어서 사용하는 것이 바람직하다.
public static Task<bool> ToTask(this WaitHandle watiHandle, int timeout = -1)
{
  var tcs = new TaskCompletionSource<bool>();
  RegisteredWaitHandle token = null;
  var tokenReady = new ManualResetEventSlim();
  token = ThreadPool.RegisterWaitForSingleObject(
    waitHandle,
    (start, timedOut) => 
    {
      tokenReady.Wait();
      tokenReady.Dispose();
      token.Unregister(waitHandle);
      tcs.SetResult(!timedOut);
    },
    null,
    timeout,
    true);
  tokenRead.Set();
  return tcs.Task;
}
  • 이런 메서드가 있으면 대기 핸들에 연속 작업을 다음처럼 부착할 수 있다.
myWaitHandle.ToTask().ContinuWith(...)
  • 또는 다음처럼 대기 핸들을 기다리거나
await myWaitHandle.ToTask();
  • 다음처럼 만료 시간을 지정할 수도 있다.
if (!await (myWaitHandle.ToTask(5000)))
  Console.WriteLine("시간 만료");
  • ToTask 구현에 또 다른 대기 핸들(ManualResetEventSlim)이 쓰였음을 주목하기 바란다. 이것은 등록 토큰이 token 변수에 배정되기 전에 콜백이 실행되는 경쟁 조건을 피하기 위한 것이다.

WaitAny, WaitAll, SignalAndWait

  • WaitHandle 클래스에는 Set과 WaitOne, Reset 메서드 외에 좀 더 복잡한 동기화 문제를 해결하는데 도움이 되는 여러 정적 메서드가 있다. WaitAny, WaitAll, SignalAndWait 메서드는 여러 개의 핸들에 대한 대기 연산과 신호 연산을 수행한다. 이 메서드들은 형식이 다른 대기 핸들들에 대해서도 작동한다.
    • Mutex와 Semphore도 추상 WaitHandle 클래스를 상속하므로 이 메서드들의 적용 대상에 속한다.
    • 그리고 ManualResetEventSlim과 CountdownEvent도 가능하다. 해당 객체의 WaitHandle 속성을 거치면 된다.
  • WaitAll과 SignalAndWait는 구식 Com 구조와 기묘하게 연계되어 있다. 이 메서드들은 반드시 호출자가 Com의 다중 스레드 아파트먼트(multithreaded apartment)에서 호출해야 한다.
    • 그런데 다중 스레드 아파트먼트 모형은 상호운용성에는 몹시 나쁜 모형이다. 예컨대 이 모형에서 WPF나 Windows Form 응용 프로그램은 클립보드와 상호작용하지 못한다.
  • WaitHandle.WaitAny는 배열로 지정된 여러 대기 핸들 중 임의의 하나가 신호될 때까지만 기다리고, WaitHandle.WaitAll은 대기 핸들들이 모두 신호될 때까지 기다린다. 예컨대 두 AutoResetEvents를 기다린다고 할 때
    • WaitAny가 두 이벤트 모두 ‘빗장을 거는’ 결과를 낼 수는 없으며
    • WaitAll이 두 이벤트 중 하나만 ‘빗장을 거는’ 결과를 낼 수도 없다.
  • SignalAndWait는 한 WaitHandle 호출에 대해 Set을 호출한 후 다른 WaitHandle에 대해 WaitOne을 호출한다. AutoResetEvent와 ManualResetEvent 모두 대기 핸들로 사용할 수 있다.
    • 이 메서드는 첫 핸들에 신호를 전달한 후 둘째 핸들을 기다리는 대기열의 제일 앞자리로 건너뛴다. 따라서 그 핸들을 획득할 가능성이 높아진다(단 이 연산이 진정으로 원자적인 것은 아니다)
    • 이 메서드의 한 가지 용도는 두 스레드가 두 핸들을 시간상의 한 지점에 집결하게, 즉 ‘만나게’ 하는 것이다. 그런 경우 두 스레드가 각자 핸들들의 순서를 반대로 해서 이 메서드를 호출한다.
    • 즉 첫 스레드는 다음과 같이 호출하고
WaitHandle.SignalAndWait(wh1, wh2);
  • 둘째 스레드는 다음과 같이 호출한다.
WaitHandle.SignalAndWait(wh2, wh1);

WaitAll과 SignalAndWait의 대안

  • 단일 스레드 아파트먼트에서는 WaitAll과 SignalAndWait가 작동하지 않는다. 다행히 이들 대신 쓸 수 있는 수단들이 있다.
    • 우선 SignalAndWait를 보면 사실 이 메서드의 대기열 ‘새치기’ 의미론이 실제로 필요한 경우는 드물다. 앞서 본 스레드 집결 예의 경우 그냥 첫 스레드는 첫 대기 핸들에 대해 Set을 호출하고 둘째 스레드는 둘째 핸들에 대해 WaitOne을 호출하는 식으로 구현해도 된다(그 대기 핸들들이 전적으로 해당 스레드 집결에만 쓰인다고 할 때)
  • WaitAll(그리고 WaitAny)로 넘어가서 만일 원자성이 필요하지 않다면 이전 절의 예제에서처럼 대기 핸들을 작업 객체로 변환한 후 Task.WhenAny와 Task.WhenAll을 적용하는 방법이 있다.
  • 원자성이 필요하다면 저수준 신호 전달 접근방식을 취해서, Monitor의 Wait와 Pulse 메서드를 이용해서 대기 논리를 직접 작성해야 할 것이다.

Barrier 클래스

  • Barrier 클래스는 다수의 스레드가 시간상의 한 지점에서 집결하게 하는 스레드 실행 장벽(thread execution barrier)를 구현한다.
    • 내부적으로 Wait와 Pulse, 그리고 회전 자물쇠(spinlock)를 사용하는 이 클래스는 아주 빠르고 효율적이다.
  • 이 클래스의 사용법은 다음과 같다.
    1. 먼저 집결할 스레드 개수를 지정해서 Barrier 객체를 생성한다(이 개수를 나중에 AddParticipants/RemoveParticipants로 변경할 수도 있다)
    2. 각 스레드에서 그 Barrier 객체에 대해 SignalAndWait를 호출한다(집결할 때가 되었을 때)
  • 3을 인수로 해서 Barrier 객체를 생성하면, 한 스레드의 SignalAndWait 호출은 다른 두 스레드가 SignalAndWait를 호출할 때까지, 즉 전체적인 호출 횟수가 3이 될 때까지 차단된다. 일단 차단이 풀리면 횟수는 다시 0으로 초기화 된다.
    • 즉, 이 메서드를 다시 호출하면 전체적으로 3회 호출될 때까지 차단된다. 모든 스레드가 이런 식으로 이 메서드를 호출한다면, 각 스레드가 다른 모든 스레드와 ‘발을 맞추어’ 작업을 진행하는 효과가 생긴다.
  • 한 예로 다음 프로그램은 세 스레드가 각자 다른 스레드들과 발을 맞추어서 0에서 4까지의 숫자를 출력한다.
static Barrier _barrier = new Barrier(3);

static void Main()
{
  new Thread(Speak).Start();
  new Thread(Speak).Start();
  new Thread(Speak).Start();
}

static void Speak()
{
  for (int i = 0; i < 5; i++)
  {
    Console.Write(i + " " );
    _barrier.SignalAndWait();
  }
}

// 출력
// 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4
  • Barrier의 진정으로 유용한 특징은, 객체를 생성할 때 페이즈 후 동작(post-phase action)을 지정할 수 있다는 것이다.
    • 페이즈 후 동작은 SignalAndWait가 n번 호출된 후에, 그러나 스레드들의 차단이 아직 풀리기 전에 (아래 그림의 회색 영역) 실행하는 대리자이다.

  • 앞의 예에서 만일 단일 스레드 실행 장벽을 다음과 같이 생성한다면
static Barrier _barrier = new Barrier(3, barrier => Console.WriteLine());
  • 출력이 다음과 같이 바뀔 것이다.
000
111
222
333
444
  • 페이즈 후 동작은 예컨대 각 일꾼 스레드가 산출한 자료를 하나로 합치려 할 때 유용하다. 이 동작이 수행되는 동안에는 모든 일꾼 스레드가 여전히 차단된 상태이므로 스레드 선점을 걱정할 필요가 없다.

게으른 초기화

  • 다중 스레드를 적용할 때는 공유 필드의 ‘게으른 초기화(lazy initialization)’를 스레드에 안전한 방식으로 수행하는 문제를 흔히 만나게 된다. 필드의 게으른 초기화란 해당 필드가 실제로 필요해질 때까지 초기화를 미루는 것을 말한다 (그래서 ‘초기화 지연’이라고 부르기도 한다)
    • 생성 비용이 높은 형식의 필드에서는 이러한 게으른 초기화가 필요하다.
    • 다음 예를 보자.
class Foo
{
  public readonly Expensive Expensive = new Expensive();
  ..
}

class Expensive { /* 생성 비용이 높은 클래스라고 가정 */ }
  • 이 코드의 문제는 Foo 객체를 생성하면 자동으로 값비싼 Expensive 객체가 생성되며, Expensive 필드가 실제로 접근되지 않는다고 해도 그 생성 비용을 물어야 한다는 것이다. 이에 대한 자명한 해결책은 다음처럼 해당 인스턴스를 요구에 따라 생성하는 것이다.
class Foo
{
  Expensive _expensive;
  public Expensive Expensive  // Expensive를 게으르게 초기화한다.
  {
    get
    {
      if (_expensive == null) _expensive = new Expensive();
      return _expensive;
    }
  }
  ...
}
  • 그런데 문제는 이것이 ‘스레드에 안전한가?’이다. 잠금 바깥에서 메모리 장벽없이 _expensive에 접근한다는 점은 일단 차치하고, 두 스레드가 이 속성에 동시에 접근하면 어떤 일이 생기는지 생각해 보자.
    • 두 스레드 모두 if 문의 조건을 만족할 것이며, 따라서 각 스레드는 Expensive의 서로 다른 인스턴스를 얻게 된다.
    • 그러면 미묘한 오류들이 발생할 것이며, 따라서 일반적으로 이 코드는 스레드에 안전하지 않다고 말할 수 있다.
  • 이 문제의 해결책은 객체의 점검과 초기화 코드를 자물쇠로 잠그는 것이다.
Expensive _expensive;
readonly object _expenseLock = new object();

public Expensive Expensive
{
  get
  {
    lock(_expenseLock)
    {
      if (_expensive == null) _expensive = new Expensive();
      return _expensive;
    }
  }
}

Lazy<T> 클래스

  • .NET Framework 4.0에는 게으른 초기화를 돕는 Lazy<T>라는 클래스가 도입되었다. 생성 시 true를 지정하면, 방금 설명한 스레드 안전 초기화 패턴을 사용하는 게으른 초기화 객체가 만들어진다.
  • Lazy<T>는 사실 이 패턴을 세밀하게 최적화한, 이중 점검 잠금(double-checked locking) 패턴을 구현한다. 이중 점검 잠금에서는 객체가 이미 초기화되었을 때 자물쇠를 획득하는 비용을 피하기 위해 추가적으로 volatile 변수를 점검한다.
  • Lazy<T>의 사용법은 간단하다. 새 값을 초기화하는 방법을 알려주는 값 팩토리 대리자와 true를 지정해서 Lazy<T> 객체를 생성하고 게으른 초기화를 적용할 속성의 구현에서 Lazy<T> 객체의 Value 속성을 사용하면 도니다.
Lazy<Expensive> _expensive = new Lazy<Expensive>(() => new Expensive(), true);
public Expensive Expensive { get { return _expensive.Value; } }
  • Lazy<T> 생성자의 둘째 인수에 false를 지정하면 이번 절의 시작에서 말한 스레드에 안전하지 않은 게으른 초기화 패턴이 적용된다. Lazy<T>를 단일 스레드 문맥에서 사용하는 경우라면 그렇게 하는 것이 합당하다.

LazyInitializer 클래스

  • LazyInitializer는 다음 두 가지만 제외하면 Lazy<T>와 정확히 동일하게 작동하는 정적 클래스이다.
    • 독자가 원하는 형식의 필드에 직접 작용하는 정적 메서드를 통해서 게으른 초기화 기능을 제공한다.
    • 여러 스레드가 초기화를 두고 경쟁할 수 있는 또 다른 초기화 모드를 제공한다.
  • LazyInitializer를 사용할 떄는 필드에 접근하기 전에 EnsureIntialized를 호출해야 한다. 이때 필드에 대한 참조와 팩토리 대리자를 지정한다.
Expensive _expensive;

public Expensive Expensive
{
  get
  {
    LazyInitializer.EnsureIntiailized(ref _expensive, () => new Expensive());
    return _expensive;
  }
}
  • 생성 시 또 다른 인수를 지정해서, 여러 스레드가 초기화를 두고 경쟁(race)하게 만들 수도 있다. 이는 이번 절 처음의 스레드 비안전 예와 비슷해 보이지만, 값을 제일 먼저 초기화한 스레드가 항상 승리한다는 점이 다르다. 결과적으로 모든 스레드가 동일한 하나의 인스턴스를 가지게 된다.
    • 이 기법의 장점은 이중 점검 잠금보다도 빠르다는 것이다(다중 코어 환경에서) 이는 이 기법이 이번 장의 ‘비차단 동기화’와 웹의 ‘Lazy Initialization’에서 이야기하는 고급 기법을 이용해서 자물쇠를 전혀 쓰지 않고 스레드들을 관리하기 때문이다.
  • 이러한 극단적인(그리고 필요한 경우가 드문) 최적화에는 다음과 같은 대가가 따른다.
    • 코어 수보다 많은 스레드가 경쟁할 때는 다른 기법보다 더 느리다.
    • 초기화를 중복해서 수행하느라 CPU 자원을 낭비할 여지가 있다.
    • 초기화 논리가 반드시 스레드에 안전해야 한다
    • 초기화 절에서 나중에(다 사용한 후에) 처분이 필요한 어떤 객체가 생성되는 경우, 추가적인 처리를 해주지 않는 한 그 객체는 다 사용된 후에도 처분되지 않는다.

스레드 지역 저장소

  • 지금까지는 주로 동기화 수단들에, 그리고 여러 스레드가 같은 자료에 동시에 접근할 때 생기는 문제점에 초점을 두었다. 그러나 스레드마다 개별적인 복사본을 두어서 각자 다른 자료를 사용하게 하고 싶을 때도 있다. 지역 변수로도 그런 효과를 낼 수 있지만, 지역 변수는 오직 일시적인 자료에만 유용하다는 문제가 있다.
  • 이에 대한 해결책은 스레드 지역 저장소(thread-local storage)를 사용하는 것이다.
    • 스레드 지역 저장소는 주로 ‘대역 밖(out-of-band)’ 자료를 담는데 쓰인다. 대역 밖 자료란 실행 경로의 기반구조를 지지하는데 쓰이는 자료로, 이를테면 메시징이나 트랜잭션, 보안 토큰을 위한 자료가 이에 속한다.
    • 그런 자료를 메서드 매개변수 형태로 전달하려면 메서드 서명이 복잡해져서 코드가 지저분해진다. 이는 요즘 인기 있는 코딩 방식이 아니다.
    • 한편 그런 자료를 보통의 정적 필드들에 저장한다면 모든 스레드가 공유하게 되므로 원래의 의도에서 벗어난다.
  • 스레드 지역 저장소는 병렬 코드의 최적화에도 유용할 수 있다. 스레드 비안전 객체의 개별 복사본을 각 스레드의 스레드 지역 저장소에 담아 두면, 결과적으로 모든 스레드가 자물쇠 없이도 그 객체에 독점적으로 접근하는 결과가 된다. 또한 메서드 호출들 사이에서 객체를 재구축할 필요도 없다.
    • 그러나 이러한 기법은 비동기 코드와는 잘 맞지 않는다 연속 작업이 이전의 스레드와는 다른 스레드에서 실행될 수도 있기 때문이다.
  • 스레드 지역 저장소를 마련하는 방법은 크게 세가지이다.

[ThreadStatic] 특성

  • 스레드 지역 저장소를 마련하는 가장 쉬운 방법은 정적 필드에 [ThreadStatic] 특성을 지정하는 것이다.
[ThreadStatic] static int _x;
  • 이렇게 하면 각 스레드가 _x의 개별적인 복사본을 보게 된다.
  • 안타깝게도 인스턴스 필드에는 [ThreadStatic]이 통하지 않는다(지정해도 그냥 아무 일도 하지 않는다) 또한 이 특성은 필드 초기치와도 잘 맞지 않는다.
    • 필드 초기치 절은 정적 생성자가 호출될 때 실행 중이던 스레드에서 단 한 번만 실행된다.
    • 인스턴스 필드를 사용해야 한다면 또는 정적 필드를 기본값 이외의 값으로 초기화하고 싶다면 ThreadLocal<T>가 더 나은 선택이다.

ThreadLocal<T> 클래스

  • ThreadLocal<T>는 .NET Framework에 새로 추가된 클래스이다. 이 클래스를 이용하면 정적 필드 뿐만 아니라 인스턴스 필드도 스레드 지역 저장소에 저장할 수 있으며, 또한 필드의 초기치를 임의로 지정할 수 있다.
  • 다음은 세 개의 스레드에 각각에 초기 값이 3인 ThreadLocal<int> 객체를 생성하는 예이다.
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
  • 이제부터 각 스레드는 _x의 Value 속성에 자신만의 스레드 지역 정수 값을 저장할 수 있다.
    • ThreadLocal을 사용하면 해당 값이 게으르게 평가된다는 장점도 생긴다. 생성시 지정한 팩토리 함수는 첫 접근시 실행된다(스레드마다 따로)

ThreadLocal<T>와 인스턴스 필드

  • ThreadLocal<T>는 인스턴스 필드와 갈무리된 지역 변수에도 유용하다.
    • 예컨대 다중 스레드 환경에서 난수를 발생하는 문제를 생각해 보자. Random 클래스는 스레드에 안전하지 않으므로, Random 객체를 사용하는 코드를 일일이 자물쇠로 감싸거나(그러면 동시성이 제한된다) 아니면 스레드마다 개별적인 Random 객체를 두어야 한다. ThreadLocal<T>를 이용하면 후자가 좀 더 쉬워진다.
var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine(localRandom.Value.Next());
  • 그런데 Random 객체를 생성하는 팩토리 함수를 이 예제보다는 좀 더 정교하게 만들 필요가 있다. Random의 매개변수 없는 생성자는 시스템 클록을 이용해서 난수 종잣값을 결정하기 때문에, 만일 두 Random이 ~10ms 이내로 연달아 생성되면 둘의 종잣값이 같을 수도 있다.
    • 다음은 이를 해결하는 한 방법을 보여준다.
var localRandom = new ThreadLocal<Random>(() => new Random(Guid.NewGuid().GetHashCode()));

GetData와 SetData

  • 스레드 지역 저장소를 마련하는 세 번째 접근방식은 Thread 클래스의 두 메서드 GetData와 SetData를 사용하는 것이다. 이 메서드들은 각 스레드에 고유한 슬롯(slot)들을 자료 저장소로 사용한다.
    • Thread.GetData는 스레드 고유의 자료 저장소에서 자료를 조회하고, Thread.SetData는 거기에 자료를 기록한다.
    • 두 메서드 모두 자료 저장소의 특정 슬롯을 지정하는 LocalDataStoreSlot 객체를 받는다.
    • 하나의 슬롯 객체를 모든 스레드에서 사용할 수 있다. 같은 슬롯이라고 해도 실제 저장 위치는 스레드마다 다르므로 각자 다른 값을 유지한다.
    • 다음 예를 보자.
class Test
{
  // 같은 LocalDataStoreSlot 객체를 모든 스레드에서 사용할 수 있다.
  LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot("securityLevel");

  // 이 속성은 스레드마다 개별적인 값을 가진다.
  int SecurityLevel
  {
    get
    {
      object data = Thread.GetData(_secSlot);
      return data == null ? 0 : (int) data;  // null이면 아직 초기화되지 않은 것이다.
    }
    set { Thread.SetData(_secSlot, value); }
  }
  ...
}
  • 이 예제는 Thread.GetNamedDataSlot을 호출해서 명명된 슬롯을 생성한다. 이처럼 슬롯에 이름을 붙이면 응용 프로그램 전반에서 슬롯을 공유할 수 있다. 슬롯을 특정 범위로 제한해서 독자가 직접 관리하려는 경우에는 Thread.AllocatedDataSlot을 호출해서 이름 없는 슬롯을 얻으면 된다.
class Test
{
  LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
  ...
  • 명명된 슬롯에 대해 Thread.FreeNamedDataSlot을 호출하면 그 슬롯은 모든 스레드에서 해제 대상이 된다. 그 LocalDataStoreSlot 객체에 대한 모든 참조가 범위를 벗어나면, 그래서 쓰레기 수서기가 거둬 가면 슬롯이 실제로 해제된다.
    • 다른말로 하면 다른 모든 스레드가 슬롯을 버린다고 해도, 슬롯을 더 사용하고자 하는 스레드는 얼마든지 슬롯을 유지할 수 있다. (해당 LocalDataStoreSlot 객체에 대한 참조를 유지하면 된다.)

Interrupt 메서드와 Abort 메서드

  • Interrupt 메서드와 Abort 메서드는 다른 스레드에 대해 선점적으로 작동한다. Interrupt는 사실상 쓸모가 없지만, Abort는 나름의 용도가 있다.
  • Interrupt는 대기 중인 스레드의 차단을 강제로 풀고 그 스레드에 ThreadInterruptedException을 던진다.
    • 차단되지 않은 스레드에 대해 이 메서드를 호출하면, 다음번에 스레드가 차단될 때 ThreadInterruptedException을 던진다.
    • 그러나 Interrupt로 풀 수 있는 문제는 신호 전달 수단과 취소 토큰으로(또는 Abort 메서드로) 더 잘 풀 수 있기 때문에, Interrupt는 사실상 쓸모가 없다.
    • 게다가 애초에 이 메서드는 위험하다. 코드의 어느 지점에서 스레드의 차단이 강제로 풀릴지 확신할 수 없기 때문이다(이를테면 .NET Framework 내부에서 차단이 풀릴 수도 있다)
  • Abort는 다른 스레드를 강제로 끝내려 한다. 이 메서드는 스레드의 현재 실행 지점에서 즉시 ThreadAbortException을 던져지게 만든다(단 비관리 코드는 제외)
    • ThreadAbortException 예외의 한 가지 특징은, 이 예외를 잡을 수는 있지만 해당 catch 블록 안에서 Thread.ResetAbort를 호출하지 않으면 그 블록의 끝에서 다시 던져진다는(스레드를 확실히 종료하기 위해) 점이다. (그리고 그 사이에서 스레드의 상태(ThreadState)는 스레드 종료가 요청되었음을 뜻하는 AbortRequested 이다
  • 예외를 처리하지 않아도 응용 프로그램이 종료되지 않는 예외 형식은 딱 두 가지인데, 그중 하나가 ThreadAbortException이다(다른 하나는 AppDomainUnloadException)
  • 응용 프로그램 도메인의 무결성이 깨지지 않으려면 스레드 실행 취소(종료) 시 모든 finally 블록이 제대로 실행되어야 하며, 정적 생성자가 실행 도중에 종료되는 일도 없어야 한다.
    • Abort는 이러한 규칙을 모두 지킨다. 그래도 Abort가 범용적인 취소 수단으로 적합한 것은 아니다. 종료된 스레드가 문제를 일으켜서 응용 프로그램 도메인을(또는 심지어 프로세스를) 오염시킬 여지가 여전히 존재하기 때문이다.
    • 예컨대 어떤 형식의 인스턴스 생성자에서 비관리 자원(이를테면 파일 핸들)을 얻으며, 그것을 Dispose 메서드에서 해제한다고 하자. 만일 그 생성자의 실행이 완료되기 전에 스레드의 실행이 취소되면 객체가 완성되지 못한다.
    • 부분적으로만 생성된 객체는 처분될 수 없으며, 따라서 비관리 자원의 누수가 일어난다.(종료자가 있다면 실행되겠지만, 그 실행시점은 나중에 쓰레기 수거기가 작동할 떄이다)
    • 이러한 취약점은 FileStream을 비롯한 기본 .NET Framework 형식들에 적용되기 때문에, Abort를 안전하게 사용할 수 있는 상황은 아주 적다.
  • Abort를 완전히 대체하는 대안은 없지만, 잠재적인 피해를 대부분 덜어내는 방법은 있다. 바로 스레드를 다른 응용 프로그램 도메인에서 실행하고, 만일 어떠한 이유로 그 스레드의 실행을 취소했다면 해당 응용 프로그램 도메인을 다시 생성하는 것이다.
  • 일부러 문제가 될만한 지점에서 호출하는 것이 아닌 한, 스레드가 자신에 대해 Abort를 호출하는 것은 완전히 유효하고 안전하다.
    • 각 catch 블록의 끝에서 예외가 다시 던져지게 하고 싶을 때 이러한 기법이 유용한 경우가 종종 있다. 실제로 Redirect 호출시 ASP.NET이 이 기법을 사용한다.

Suspend 메서드와 Resume 메서드

  • Suspend와 Resume은 다른 스레드의 실행을 일시적으로 멈추거나 재개한다. 전자를 스레드를 얼린다(freeze), 후자를 녹인다(unfreeze)라고 표현하기도 한다.
    • 언 스레드는 겉으로 보기에는 호출이 차단된 경우와 다를바 없지만, 적어도 ThreadState 속성을 놓고 보면 일시 정지는 차단과 다른 어떤 상태이다.
    • Interrupt처럼 Suspend와 Resume은 사실상 쓸모가 없고 잠재적으로 위험하다. 만일 자물쇠를 가진 스레드에 대해 Suspend를 호출하면 다른 모든 스레드는 그 자물쇠를 얻을 수 없다. 게다가 언 스레드가 자신을 녹이지는 못한다. 따라서 프로그램이 교착 상태에 빠질 수 있다.
    • 그래서 Suspend와 Resume은 .NET Framework 2.0에서 폐기 예저응로 분류 되었다.
  • (이하 Suspend와 Resume 설명 생략)

타이머

  • 어떤 메서드를 일정 간격으로 거듭 실행하는 가장 쉬운 방법은 타이머를 사용하는 것이다. 다음과 같은 기법에 비해 타이머는 사용하기 편할 뿐만 아니라 메모리와 자원 사용도 효율적이다.
new Thread(delegate() {
  while (enabled)
  {
    DoSomeAction();
    Thread.Sleep(TimeSpan.FromHours(24));
  }
}).Start();
  • 이런 코드는 스레드 자원을 영구히 묶어 둘 뿐만 안리ㅏ, 추가적인 처리가 없는 한 DoSomeAction의 실행시점이 매일 조금씩 늦어진다. 타이머는 이런 문제점들을 모두 해결한다.
  • .NET Framework는 네 종류의 타이머를 제공한다. 이 중 다음 둘은 범용 다중 스레드 타이머이다.
    • System.Threading.Timer
    • System.Timer.Timer
  • 나머지 둘은 특별한 용도로만 쓰이는 단일 스레드 타이머이다.
    • System.Windows.Forms.Timer (Windows Forms 타이머)
    • System.Windows.Threading.DispatcherTimer (WPF 타이머)
  • 범용 다중 스레드 타이머들이 더 강력하고, 정확하고, 유연하다. 단일 스레드 타이머는 Windows Forms 컨트롤이나 WPF 요소들을 갱신하는 간단한 작업을 실행할 때 좀 더 안전하고 편리하다.

다중 스레드 타이머

  • 두 다중 스레드 타이머 중 System.Threading.Timer가 더 간단하다. 이 클래스에는 생성자 하나와 메서드 두 개밖에 없다.
    • 다음 예제는 타이머를 이용해서 Tick 메서드를 5초 후에 한 번 호출한 후 1초마다 한 번씩 호출한다. 호출된 Tick 메서드는 매번 “tick…”을 출력한다. 사용자가 Enter 키를 누르면 반복 호출이 끝난다.
static void Main()
{
  // 첫 간격은 5000ms 이후 간격들은 1000ms
  Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
  Console.ReadLine();
  tmr.Dispose();  // 타이머를 중지하고 자원들을 정리한다.
}

static void Tick(object data)
{
  // 이 코드는 풀 스레드에서 실행된다.
  Console.WriteLine(data);  // tick...을 출력한다.
}
  • 타이머 객체를 생성한 후에 타이머 발동 간격을 변경하고 싶으면 Change 메서드를 호출하면 된다. 타이머가 한 번만 발동되게 하려면 생성자의 마지막 매개변수에 Timeout.Infinite를 지정하면 된다.
  • .NET Framework의 System.Timers 이름공간에도 같은 이름(Timer)의 타이머 클래스가 있다. 이 클래스는 그냥 System.Threading.Timer를 감싸고 추가적인 편의 수단을 제공하는 클래스로 내부 타이머 엔진은 동일하다. 다음은 이 클래스가 제공하는 추가 기능이다.
    • IComponent를 구현하므로 Visual Studio 디자이너의 구성요소 모음에 둘 수 있다.
    • Change 메서드 대신 Interval 속성을 이용해서 발동 간격을 변경할 수 있다.
    • 콜백 대리자 대신 Elapsed라는 이벤트를 사용한다.
    • Enabled 속성을 이용해서 타이머를 시작하거나 중지할 수 있다(이 속성의 기본 값은 중지 상태에 해당하는 false이다)
    • Enabled 속성이 헷갈린다면 Start 메서드와 Stop 메서드를 사용해도 된다.
    • 이벤트 반복 여부를 AutoReset 플래그로 지정할 수 있다 (기본값은 true)
    • Invoke 메서드와 BeginInvoke 메서드를 가진 객체를 돌려주는 SynchronizingObject 속성이 있다. 그 객체를 이용해서 WPF 요소나 Windows Forms 컨트롤의 메서드를 안전하게 호출할 수 있다.
  • 다음은 이 타이머의 사용 예이다.
static void Main()
{
  Timer tmr = new Timer ();
  tmr.Interval = 500;
  tmr.Elapsed += tmr_Elapsed;
  tmr.Start();
  Console.ReadLine();
  tmr.Stop();
  Console.ReadLine();
  tmr.Start();
  Console.ReadLine();
  tmr.DisPose();
}

static void tmr_Elapsed(object sender, EventArgs e)
{
  Console.WriteLine("Tick");
}
  • 다수의 타이머를 그보다 적은 수의 스레드로 돌리기 위해 다중 스레드 타이머 클래스들은 스레드 풀을 사용한다.
    • 이는 콜백 메서드나 Elapsed 이벤트가 매번 다른 스레드에서 호출될 수 있음을 뜻한다. 더 나아가서 Elapsed 이벤트는 이전 Elapsed 이벤트 처리부의 실행 종료 여부와는 무관하게 항상 (근사적으로) 제 시간에 발동한다.
    • 따라서 콜백 메서드나 이벤트 처리부는 반드시 스레드에 안전해야 한다.
  • 다중 스레드 타이머의 정밀도(해상도)는 운영체제에 따라 다른데, 대체로 10-20ms 범위이다. 더 정밀한 타이머가 필요하다면 네이티브 상호운용 기능을 이용해서 Windows의 멀티미디어 타이머를 사용할 수도 있다.
    • 이 타이머의 정밀도는 1ms 까지 내려가며, 관련 함수들은 winmm.dll에 정의되어 있다.
    • 우선 timeBeginPeriod를 호출해서 고정밀 타이머가 필요함을 운영체제에 알리고 그런다음 timeSetEvent를 호출해서 하나의 멀티미디어 타이머를 시작하면 된다.
  • 타이머를 다 사용한 후에는 timeKillEvent를 호출해서 타이머를 멈추고, timeEndPeriod를 호출해서 운영체제에게 고정밀 타이머가 더이상 필요하지 않음을 알려준다.
    • 25장에 P/Invoke를 이용해서 외부 메서드를 호출하는 방법이 나온다.

단일 스레드 타이머

  • .NET Framework는 WPF와 Windows Forms 응용 프로그램을 위한 타이머 두 가지를 제공한다. 이들은 스레드 안전성 문제를 피하도록 설계되었다.
    • System.Windows.Forms.Timer (Windows Forms)
    • System.Windows.Threading.DispatcherTimer (WPF)
  • 이 단일 스레드 타이머들은 해당 환경에서만 사용하도록 만들어진 것이다. 예컨대 Windows Forms용 타이머를 Windows Service 응용 프로그램에서 사용하면 Timer 이벤트가 발생하지 않는다.
  • 둘 다 System.Timers.Timer처럼 Interval, Start, Stop 같은 멤버들을 제공한다.(또한 Tick이라는 멤버도 있는데, 이는 Elapsed에 해당한다) 그리고 둘 다 사용법도 System.Timers.Timer와 비슷하다.
    • 그러나 내부 작동 방식은 다르다. 이들은 스레드 풀에서 가져온 스레드에서 타이머 이벤트를 발동하지 않는다.
    • 대신 이벤트를 WPF나 Windows Forms 메시지 루프에 추가한다. 따라서 Tick 이벤트는 항상 애초에 타이멀르 생성했던 그 스레드에서 발동한다.
  • 보통의 응용 프로그램에서 그 스레드는 곧 모든 UI 요소들과 컨트롤들을 관리하는데 쓰이는 스레드이다. 이런 방식에는 다음과 같은 여러 장점이 있다.
    • 스레드 안전성 문제를 고민할 필요가 없다.
    • 이전 Tick 이벤트의 처리가 끝나지 않으면 새 Tick 이벤트가 발동하지 않는다.
    • Tick 이벤트 처리부에서 직접 UI 요소들과 컨트롤들을 갱신해도 된다. Control.BeginInvoke나 Dispatcher.BeginInvoke를 호출할 필요가 없다.
  • 따라서 이 타이머들을 사용하는 응용 프로그램들은 사실 다중 스레드 프로그램이 아니다. UI 스레드에서 실행되는 비동기 함수들을 이용해서 14장에서 설명한 것과 같은 종류의 유사동시성(pseudoconcurrency)을 실현하는 것일 뿐이다.
    • 이러한 구조에서는 모든 타이머가 하나의 스레드에서 돌아가며, UI 이벤트들의 처리도 그 스레드에서 일어난다.
    • 따라서 Tick 이벤트 처리부는 실행이 빨리 끝나야 마땅하다. 그렇지 않으면 UI의 반응성이 떨어진다.
  • 이러한 이유로 WPF와 Windows Forms 타이머는 UI의 일부 측면을 갱신하는 등의 작은 일에 적합하다.
  • 정밀도 면에서 단일 스레드 타이머들은 다중 스레드 타이머들과 비슷하다(정밀도가 수십 밀리초 수준이다) 그러나 다른 UI 요청(또는 다른 타이머 이벤트)들을 처리하느라 타이머 이벤트 처리가 지연되기도 하기 때문에, 정확도는 대체로 다중 스레드 타이머들보다 떨어진다.
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.