멀티 스레드 코드를 추가 시 공유 자원에 대한 문제는 항상 따라다닌다고 합니다.
스레드가 프로세스와 달리 자원을 공유하는 영역이 있어서 편한것도 있지만,
이것이 양날의 검이 되어서 문제를 일으키게 됩니다.
C#에서는 이런 문제를 해결하기 위한 방법 중 하나로 Interlocked 클래스가 있습니다.
Interlocked 키워드에 대한 것을 인프런 Rookiss님 강의(링크)를 통해 접하게 되었습니다.
Interlocked 키워드를 통해 파생되어 나온 키워드와 예제 코드를 정리하기 위해 이렇게 글을 작성하게 되었습니다.
다중(멀티) 스레드에서 공유하는 변수에 대한 원자 단윈 연산을 제공하는 클래스입니다.
<MS docs - Interlocked 클래스>
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
number++;
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
number--;
}
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);
}
}
위 코드의 의도는 number가 0이 출력되도록 하는 것인데,
전혀 다른 결과가 발생하게 됩니다.
number의 값은 애플리케이션을 실행할 때마다 값이 계속 변경됩니다.
이유는 아직 잘 모르겠지만, 예전에 배웠던 메모리 배리어 코드를 추가해봤습니다.
class Program
{
static int number = 0;
static void Thread_1()
{
Thread.MemoryBarrier();
for (int i = 0; i < 1000000; i++)
number++;
}
static void Thread_2()
{
Thread.MemoryBarrier();
for (int i = 0; i < 1000000; i++)
number--;
}
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);
}
}
여러번 실행해도 의도했던 값인 0이 출력됩니다.
두 개 이상의 스레드가 공유 자원에 대해 읽거나 쓸 때 데이터에 대한 접근 순서로 인해 실행 결과가 달라지는 문제입니다.
이것을 Race Condition(경쟁 상태)이라고 합니다.
메모리 배리어를 사용해서 해결된 이유는 메모리 배리어가 순서를 강제해주기 때문입니다.
그런데 이상한게 있습니다.
number에 -1을 먼저 하던 +1을 하던 어짜피 결과는 0이 나오지 않을까 라고 생각했습니다.
여기서 원자성이라는 성질의 개념이 나오게 됩니다.
더 이상 쪼개질 수 없는 성질을 얘기하며,
하나로 이루어져 여럿으로 나뉠 수 없는 연산에 대해 atomic하다고 표현합니다.
경쟁 상태를 피하기 위해서는 공유 자원에 대한 처리 혹은 연산을 atomic하게 해야 합니다.
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
number++;
}
위 코드는 사실 아래와 같이 동작합니다.
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
{
int temp = number;
temp += 1;
number = temp;
}
}
머신에서는 위와 비슷하게 3단계로 나눠서 연산이 진행됩니다.
그 이유는 연산의 원자성때문입니다.
이렇게 단계가 나눠지기 때문에 멀티 스레드 환경에서 Race Condition으로 인해 다른 결과가 나온 것입니다.
그렇다면 아래와 같이 코드를 작성하면 어떻게 될까요?
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
number += 1;
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
number -= 1;
}
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);
}
}
number++ => number += 1
number-- => number -= 1
로 변경을 했습니다.
여러번 실행을 해도 동일하게 0이 출력되게 됩니다.
그러면 이런 문제들이 발생한 이유를 명확하게 알게 되었습니다.
좀 길게 돌아오기는 했지만, 본론으로 드디어 돌아와서 Interlocked로 해결해보도록 하겠습니다.
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
Interlocked.Increment(ref number);
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
Interlocked.Decrement(ref number);
}
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);
}
}
C#에서는 Interlocked를 아주 간단하게 사용해 원자적인 작업과 데이터 일관성을 보장할 수 있습니다.
Interlocked.Increment(ref number);
쉽게 해결할 수 있지만 이런 Interlocked 클래스는 성능적으로 문제점을 야기할 수 있습니다.
오늘도 공부를 하면서 하나의 키워드에서 상당히 많은 파생된 키워드를 알게 되었습니다.
꼭 그 기술이 실용적이고 사용되는 것인지가 아니라 궁극적인 개념을 공부하면 좋을 것 같다고 생각했습니다.
Interlocked의 개념을 이해한다면 lock에 대한 개념을 알게 되고,
어떤 상황에서 lock을 사용해야 할 지도 알게 되는 것 같습니다.