저번에 Interlocked 키워드를 공부하면서 Race condition이 발생하는 코드를 직접 작성하고,
그것을 Interlocked와 Thread.MemoryBarrier()로 해결해보면서 멀티 스레드 환경에 대해 알아봤습니다.
(블로그 글 => [C#] Interlocked에 대해 알아보자)
하지만 Interlocked은 값을 더하기 혹은 빼기 등 연산만 가능해서 제한적인 부분이 있습니다.
이런 제한사항을 Monitor를 사용해서 극복이 가능합니다.
C#의 Monitor 키워드를 알아가면서 임계 영역이라는 것도 함께 알아볼 수 있는 좋은 계기가 되었습니다.
병렬컴퓨팅에서 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원(자료 구조 또는 장치)을 접근하는 코드의 일부를 말한다.
임계 구역은 지정된 시간이 지난 후 종료된다. 때문에 어떤 스레드가 임계 구역에 들어가고자 한다면 지정된 시간만큼 대기해야 한다. 스레드가 공유자원의 배타적인 사용을 보장받기 위해서 임계 구역에 들어가거나 나올때는 세마포어 같은 동기화 매커니즘이 사용된다.
<위키피디아 - 임계 구역(Critical Section)>
간단하게 정리하면 둘 이상의 스레드가 동시에 공유 자원을 접근할 수 있는 영역을 얘기합니다.
임계 영역은 스레드 강의 Race Condition(경쟁 조건)을 방지하고 공유 자원에 안전하게 접근하기 위해 사용됩니다.
C#에서는 이런 임계 영역을 간단하게 지정할 수 있는 방법 중 하나가 Monitor 클래스르 사용하는 것입니다.
개체에 재한 액세스를 동기화하는 메커니즘을 제공합니다.
<MS docs - Monitor 클래스>
public static class Monitor
static 클래스로 객체를 따로 생성하지 않아도 불러와 사용할 수 있는 클래스입니다.
using System.Diagnostics.Metrics;
class Program
{
static int number = 0;
static object _lockObj = new object();
static void Thread_1()
{
Monitor.Enter(_lockObj);
for (int i = 0; i < 100000; i++)
{
number++;
}
Monitor.Exit(_lockObj);
}
static void Thread_2()
{
Monitor.Enter(_lockObj);
for (int i = 0; i < 100000; i++)
{
number--;
}
Monitor.Exit(_lockObj);
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
위의 코드에서 Monitor.Enter()는 임계 영역의 시작점 Monitor.Exit() 종료 지점입니다.
두 개의 스레드가 공유 자원에 접근하고 있습니다.
Monitor 클래스를 사용하면 예상대로 0이 출력됩니다.
하지만 Monitor 클래스를 사용하지 않으면 의도와는 전혀 다른 값이 출력됩니다.
(매번 실행할 때마다 값이 바뀝니다.)
이렇듯 Monitor 클래스를 사용해서 특정 임계 영역 블럭을 배타적으로 Locking 해서 해결할 수 있습니다.
특정 object를 잠금의 키로 사용하기 위해서입니다.
하나의 스레드가만이 임계 영역에 접근하도록 하기 위해 object에 대한 잠금 설정을 합니다.
object가 잠긴 상태에서는 다른 스레드가 해당 object에 대한 잠근을 얻으려고 시도해도 막히게 됩니다.
여기서 꼭 object 클래스를 키로 사용하지 않아도 됩니다.
MS docs의 예제(링크)에서 보면 Random 클래스의 객체를 키로 사용하는 것을 볼 수 있습니다.
Random rnd = new Random();
Monitor.Enter(rnd);
// 로직
Monitor.Exit(rnd);
배타적이라는 말은 '남을 배척하는 경향이 있는 것' 이라는 뜻이 있습니다.
결국 다른 스레드를 배척 혹은 배제하고 하나의 스레드만 임계 영역에 접근하는 것을 얘기합니다.
위와 비슷한 키워드로는 상호 배제(mutual exclusion, Mutex, 뮤텍스)가 있습니다.
공유 불가능한 자원의 동시 사용을 피하기 위해 사용되는 알고리즘을 얘기합니다.
이런 상호 배제는 임계 영역에 의해 구현됩니다.
static void Thread_1()
{
Monitor.Enter(_lockObj);
for (int i = 0; i < 100000; i++)
{
number++;
}
Monitor.Exit(_lockObj);
}
위와 같은 코드는 하나의 반복문만 있기 때문에 임계 영역의 시작점(Enter)과 끝나는 부분(Exit)을 알 수 있습니다.
하지만 코드가 길어지면 시작점과 끝의 판별이 어려울 수 있습니다.
이 때 try finally를 사용하면 좀 더 쉽게 판별할 수 있습니다.
static void Thread_1()
{
Monitor.Enter(_lockObj);
try
{
for (int i = 0; i < 100000; i++)
{
number++;
}
} finally
{
Monitor.Exit(_lockObj);
}
}
위와 같이 작성하면 임계 영역의 시작하고 모르고 Exit을 안하는 문제를 예방할 수 있습니다.
object lockObject = new object();
lock (lockObject)
{
// 임계 영역: 한 번에 하나의 스레드만 접근 가능
// lockObject에 대한 잠금을 설정하고 해제함
}
위와 같이 Lock이라는 키워드를 사용해 Lock에 사용할 키 객체를 넣어주면 됩니다.
코드 영역이 명확하게 나타나고 시작과 끝을 직관적으로 알 수 있습니다.
그리고 코드도 더 간결해집니다.
예제 코드를 가지고 조금이라도 수정하는 것이 해당 키워드를 이해하는데 더 도움이 된다는 것을 알게되었습니다.
예제 코드를 최초로 작성했던 사람의 의도와 왜 그런 코드가 해당 라인에 있는지도 추론할 수 있는 좋은 기회가 되었습니다.
그리고 멀티 스레드의 장점이 공유 자원을 사용하는 것도 있다고 들었습니다.
하지만 그런 장점이 개발을 하는데 꽤나 큰 고통을 주는 경우도 많은 것 같습니다.
의도한 대로 값이 나오지 않는데 어떻게 디버깅 할 지 모르는 부분도 아직 있는 것 같습니다.