동기화 개요
- 동기화(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);