4. 락 (Lock)
독점 락은 어떤 코드 블럭에 대해 항상 하나의 쓰레드만이 실행될 것을 보장하기 위해 사용됩니다. 가장 주요한 독점 락으로서는 Lock과 Mutex가 있습니다. 이 두 개 중에서는 Lock쪽이 가장 빠르게 사용할 수 있으며 간단합니다. 하지만 Mutex는 다른 프로세서에서 동작하는 어플리케이션 간에 독점 락을 거는 것이 가능합니다.
이 장에서는 우선 Lock의 사용 방법부터 시작해서 그 후에 Mutex와 Semaphores(비독점 락)에 대해서 설명합니다. 나중에 reader/writer locks에 대해서 설명합니다.
우선은 아래의 코드부터 시작해보죠.
class ThreadUnsafe { static int _val1 = 1, _val2 = 1; static void Go() { if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0; } }
이 클래스는 Thread-Safe는 아닙니다. 만약 Go 메서드가 2개의 쓰레드에서 동시에 실행되는 경우 제로 나누기에 의한 예외가 발생할 가능성이 있습니다. 한편 쓰레드가 if 문을 평가하여 Console.WriteLine을 실행 할 때까지 사이에 또 하나의 쓰레드가 _val2을 제로로 할 가능성이 있기 때문입니다.
lock 을 사용하면 이 문제를 해결할 수 있습니다.
class ThreadSafe { static readonly object _locker = new object(); static int _val1, _val2; static void Go() { lock (_locker) { if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0; } } }
1개의 쓰레드만이 동기 객체를 락하는 것이 가능합니다. 다른 쓰레드는 락이 해방될 때 까지 대기를 하게 되어 Ready Queue에서 락의 해방을 대기합니다. 락이 해방되면 기본적으로는 최초에 추가된 쓰레드에 대해서 락이 풀려 처리가 실행되게 됩니다. (Windows와 CLR은 때때로 이 순서를 교체하는 일도 있어서 기본적으로라고 하는 미묘한 뉘앙스로 설명하게 되었습니다) 독점 락은 락을 기다리고 있는 쓰레드의 중요성에 관계없이 강제적으로 처리를 직렬화하여 실행한다고 하는 형태로 설명할 수 있습니다. 왜냐하면 다른 쓰레드가 독점 락을 취득하여 실행 중일 떄는 그 실행이 끝날 때 까지는 기다리고 있는 쓰레드가 실행되는 일이 없기 때문입니다. 이 예로서는 Go 메서드의 안에 있는 _val1과 _val2의 값을 락에 의해 보호하고 있기 때문입니다.
락 대기를 하고 있는 쓰레드의 ThreadState는 WaitSleepJoin이 되어 있습니다. 1. 쓰레드의 사용 방법에서 쓰레드가 다른 쓰레드에 의해 어떻게 해방 또는 종료 되는 지를 설명하고 있습니다. 이것은 쓰레드의 종료 시에 사용되는 대단히 중요한 기술입니다.
- lock(Monitor.Enter/Monitor.Exit)
- Mutex
- SemaphoreSlim
- Semaphore
- ReaderWriterLockSlim
- ReaderWriterLock
락의 종류 | 프로세스 | 오버헤드 |
lock(Monitor.Enter/Monitor.Exit) | 같은 프로세스 | 20ns |
Mutex | 다른 프로세스도 가능 | 1000ns |
SemaphoreSlim | 같은 프로세스 | 200ns |
Semaphore | 다른 프로세스도 가능 | 1000ns |
ReaderWriterLockSlim | 같은 프로세스 | 40ns |
ReaderWriterLock | 같은 프로세스 | 100ns |
C#의 Lock을 사용하면 둘러쌓인 블럭을 컴파일 시에 내부적으로 try-finally와 Monitor.Endter/Monitor.Exit로 둘러쌓인 형태로 새로 쓰여지게 합니다. Go 메서드의 내부는 조금 간략하게 알기 쉽게 설명하자면 아래의 코드와 같이 변환됩니다.
Monitor.Enter (_locker); try { if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0; } finally { Monitor.Exit (_locker); }
Monitor.Enter 메서드를 호출하기 전에 Monitor.Exit 메서드를 호출하면 예외가 발생합니다.
락 취득처리의 오버로드에 대해
이제까지 보여드린 코드는 C# 1.0, C# 2.0, C# 3.0에서 컴파일이 생성하는 코드를 기반으로 설명해왔습니다.
하지만 이것들의 코드에는 몇가지 취약한 부분이 있습니다. 대부분 있을 수 없다고 생각할 수 있지만 예를 들면 Monitor.Enter와 try 사이에 예외가 발생한 경우를 생각해보죠. (예를 들면 다른 쓰레드가 Abort가 호출된 경우라던가 OutOfMemoryException이 발생한 경우 등) 그러한 상황에서는 블럭이 취득되는 후의 경우에는 그 락은 해방되지 않게 됩니다.
그 결과 락이 누수(Leak)가 발생하여 재이용할 수 없게 되는 문제가 발생합니다.
이 위험성을 피하기 위해 CLR 4.0의 디자이너는 Monitor.Enter에 아래의 오버로드를 추가했습니다.
public static void Enter (object obj, ref bool lockTaken);
이 메서드를 실행 후의 lockTaken이 False일 경우 예외가 발생한 그 순간에는 락은 취득되지 않게 됩니다. 이 오버로드를 사용한 새로운 패턴은 아래와 같이 됩니다. (또한 이것은 C# 4.0 의 lock은 한층 더 이것과 같은 형태로 전개됩니다.
bool lockTaken = false; try { Monitor.Enter (_locker, ref lockTaken); // Do your stuff... } finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor는 TryEnter라고 하는 메서드도 제공하고 있어 밀리초 단위 또는 TimeSpan으로 타임아웃을 지정하는 것도 가능합니다. 이 메서드는 락이 취득된 경우에 True를 반환하며 락을 취득할 수 없다거나, 타임 아웃이 된 경우에는 false를 반환합니다. TryEnter는 락 오브젝트 인수만의 오버로드도 정의되어 있어, 메서드 호출값에 락이 취득할 수 있는지 없는지를 체크하는 것이 가능합니다.
Enter 메서드와 같이 lockTaken 인수를 읽는 오버로드 버전도 있습니다.
동기를 시키고 싶은 복수의 쓰레드에서 접근할 수 있는 변수라면 무엇이든 동기 오브젝트 로서 사용할 수 있습니다. 단 1가지 규칙이 있습니다. 그 오브젝트는 반드시 참조형(Reference) 변수여야 합니다. 보통 동기 오브젝트는 private 변수로서 선언되는 것이 많이 이것에 의해 락 로직을 클래스의 내부에 숨기는 것이 가능합니다. 동기 오브젝트는 보호 하고 싶은 오브젝트 그 자체를 사용하는 것도 가능합니다. 아래는 그 예제 입니다.
class ThreadSafe { List <string> _list = new List <string>(); void Test() { lock (_list) { _list.Add ("Item 1"); ...
락을 위해 멤버 변수를 선언하는 것으로 보다 세세하게 록을 제어할 수 있습니다. this 키워드에 의해 현재의 오브젝트와 Type 오브젝트도 동기 오브젝트로서 사용가능합니다.
lock (this) { ... }
또는
lock (typeof (Widget)) { ... } // For protecting access to statics
이 방법의 단점은 락의 로직을 캡슐화할 수 없다는 점입니다. 결과로서 데드락과 필요 이상으로 락 대기가 발생하는 것을 방지하기가 어렵게 됩니다. 또한 Type 객체의 락은 어플리케이션 도메인을 넘어 적용되기 때문에 주의가 필요합니다. (같은 프로세스에서 실행되고 있는 경우에 한정됩니다.)
람다 식과 익명 메서드에서 캡쳐된 지역 변수도 동기 오브젝트로서 사용할 수 있습니다.
기본적인 규칙으로서는 공유 오브젝트에서의 변경 처리는 모두 락을 사용할 필요가 있습니다. 설령 가장 단순한 경우 (1개의 필드에서의 값 대입)가 있더라도 동기에 대해서 생각할 필요가 있습니다. 아래의 클래스에서는 Increment 메서드 및 Assign 메서드의 양쪽 모두 쓰레드 세이프는 되어 있지 않습니다.
class ThreadUnsafe { static int _x; static void Increment() { _x++; } static void Assign() { _x = 123; } }
위의 클래스를 쓰레드 세이프로 고쳐 쓰면 아래와 같이 됩니다.
class ThreadSafe { static readonly object _locker = new object(); static int _x; static void Increment() { lock (_locker) _x++; } static void Assign() { lock (_locker) _x = 123; } }
블럭하지 않는 동기의 장에서는 상기의 표현을 자세히 설명합니다. 또한 메모리 베리어와 Interlocked 클래스를 사용하여 락을 하지 않고 이것들의 요건을 만족시키는 방법에 대해서 설명합니다.
만약 몇가지 변수 그룹을 같은 락을 사용해서 일괄적으로 읽어 들인다거나 변경하거나 하지 않으면 안되는 상황을 원자성(Atomicity)라고 합니다. 변수 x와 y가 항상 locker 오브젝트에 의해 락된 값의 취득 및 변경한다고 해보죠.
lock (locker) { if (x != 0) y /= x; }
한가지 말하고 싶은 것은 x와 y는 원자로 접근된다고 하는 점입니다. 왜냐하면 이 코드 블럭은 다른 쓰레드가 끼어들어 값을 변경한다거나 하는 일이 없기 때문입니다. 이렇게 되면 0 나누기 에러가 발생할 가능성은 전혀 없어 x와 y는 항상 독점 락이 되는 상태로 접근되게 됩니다.
decimal _savingsBalance, _checkBalance; void Transfer (decimal amount) { lock (_locker) { _savingsBalance += amount; _checkBalance -= amount + GetBankFee(); } }
만약 GetBankFee() 메서드에서 예외가 발생한 경우, 은행의 잔고는 부정한 값으로 되어 버립니다. 이것을 피하기 위해서는 GetBankFee() 메서드를 락 하기 전에 호출하여 값을 취득해 놓으면 회피 가능합니다. 좀 더 복잡한 경우에는 롤백(RollBack) 처리를 catch 문과 finally 문을 사용하여 구현할 필요가 있습니다.
쓰레드는 같은 오브젝트에 대해서 lock을 취득하여 처리를 실행할 수 있습니다.
lock (locker) lock (locker) lock (locker) { // Do something... }
또는
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker); // Do something... Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
이것들의 경우 가장 밖의 lock문을 벗어난 타이밍에 오브젝트의 락이 해방됩니다. 또는 Monitor.Exit의 횟수가 Enter의 수와 일치하는 경우 해재됩니다. 중첩이 된 lock은 어떤 메서드가 다른 lock 문에 있는 메서드를 실행하는 경우 등으로 유용합니다.
static readonly object _locker = new object(); static void Main() { lock (_locker) { AnotherMethod(); // We still have the lock - because locks are reentrant. } } static void AnotherMethod() { lock (_locker) { Console.WriteLine ("Another method"); } }
쓰레드는 가장 바깥 측의 lock 문의 부분에서 블럭되게 됩니다.
데드락은 2개의 쓰레드가 서로의 리소스를 갖게 되는 상태가 될 경우에 발생합니다. 가장 간단한 예는 아래와 같습니다.
object locker1 = new object(); object locker2 = new object(); new Thread (() => { lock (locker1) { Thread.Sleep (1000); lock (locker2); // Deadlock } }).Start(); lock (locker2) { Thread.Sleep (1000); lock (locker1); // Deadlock }
3개 이상의 쓰레드가 연쇄하여 훨씬 복잡한 데드락이 발생하는 경우도 있습니다.
데드락은 멀티 쓰레드에서 발생하는 문제 중 가장 어려운 것 중 하나입니다. 특히 상호 관계로 있는 오브젝트가 많이 존재하는 경우는 더욱 그렇습니다. 어려운 것은 어떤 호출하는 곳에서 데드락을 발생시키고 있는 지를 특정하는 것을 불가능하다는 것이 최대의 문제입니다.
x라고 하는 클래스를 정의하여 그 클래스의 private한 필드 x를 락을 한다고 합시다. 모르는 사이에 클래스 y의 필드 b가 락이 되어 있다고 합시다. 한편 다른 쓰레드가 그 반대를 실행하고 있다고 합시다. 그렇게 되면 데드락이 발생합니다.
역설적으로 오브젝트 지향의 디자인 패턴으로 잘 설계된 프로그램인 만큼 연쇄적으로 호출되는 횟수가 많은 실행 시까지 이 문제가 발생한다는 것을 느낄 수 없게 되기도 합니다.
데드락은 일관성 있게 순서대로 오브젝트를 락하는 것으로 회피할 수 있습니다. 하지만 이 방법은 지금 가리킨 샘플에는 적용이 어려워 도움이 되지 않습니다. 좀 더 좋은 방법은 락 블럭의 안에서 호출하고 있는 메서드가 내부에서 자기 자신으로의 참조를 가지고 있지 않은지 어떤지를 주의 깊에 본다고 하는 방법이 있습니다. 또한 다른 클래스의 쓰레드를 호출할 때에 정말로 락이 필요한지 어떤지를 주의깊에 보시기 바랍니다. (대부분은 그럴 수 밖에 없는 경우가 많지만 때로는 다른 방법으로 실현할 수 있는 경우가 있습니다.) 선언적인 방법, 데이터 병렬화, 불변의 타입, 블럭하지 않는 동기 등의 방법 등으로 의지하는 것으로 락의 필요성을 배제할 수 있습니다.
'Programming > Thread' 카테고리의 다른 글
Threading in C# 3.동기화 처리의 기초 (0) | 2016.10.27 |
---|---|
Threading in C# 2.쓰레드의 개념(2) (0) | 2016.10.23 |
Threading in C# 1.쓰레드의 개념(1) (0) | 2016.10.21 |