본격적인 설명에 앞서 아래 코드를 먼저 확인 하자.
class Program
{
static int number = 0;
static void Thread_1()
{
for(int i=0; i<100000; i++)
{
number++;
}
}
static void Thread_2()
{
for(int i=0; i<100000; 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에 1을 더하는 연산을 10만번, number에 1을 빼는 연산을 10만번 수행한다.
우리는 당연히 number에 0이 할당되었을 것이라고 생각한다.
하지만 결과는 전혀 엉뚱한 값이 나온다.
이런 현상이 발생하는 이유는 Race Condition(경합 조건) 때문이다.
Race Condition 이란?
멀티쓰레드 환경에서 두 명령어가 동시에 같은 기억 장소를 액세스할 때 그들 사이의 경쟁에 의해 수행 결과를 예측할 수 없게 되는 것이다.
쉽게 이해가도록 하기 위해 레스트랑의 관점에서 설명해보겠다.
레스토랑에서 콜라를 가져다 달라는 주문을 받았을 시 직원들(쓰레드들)이 경쟁하여
손님(사용자)에게 콜라를 여러 개를 가져다 주는 현상이라고 이해하면 쉽다.
즉, 쓰레드들 간의 경합에 의해 수행결과를 예측할 수 없는 것이다.
코드를 자세히 살펴 보자.
number++;
number--;
두 코드는 어셈블리 언어의 관점에서 아래와 같이 해석할 수 있다.
int temp = number;
temp += 1;
number = temp;
int temp = number;
temp -= 1;
number = temp;
number++ 와 number-- 코드는 3가지 연산을 통해 동작하는 코드이다.
따라서 멀티 쓰레드 환경에서는 위의 6줄의 코드가 동시에 수행하게 되어 결과를 예측할 수 없게 되는 것이다.
이러한 문제를 해결하려면 원자성(Atomicity)을 보장해주면 된다.
원자성이란? 더 이상 쪼개질 수 없는 성질로써, 어떠한 작업이 실행될때 언제나 완전하게 진행되어 종료되거나, 그럴 수 없는 경우 실행을 하지 않는 경우를 말한다
즉, number++와 number-- 연산이 원자성을 보장하여 한꺼번에 실행되거나 아예 실행하지 않도록 해주면 된다.
코드를 다음과 같이 수정하자.
static void Thread_1()
{
for(int i=0; i<100000; i++)
{
Interlocked.Increment(ref number);
}
}
static void Thread_2()
{
for(int i=0; i<100000; i++)
{
Interlocked.Decrement(ref number);
}
}
Interlocked 계열은 All or Nothing. 즉, 실행을 하거나 안되거나 이다.
Interlocked 하나가 실행되면 다른 Interlocked은 대기하고 있는다.
이렇게 함으로써 동시다발적으로 경합을 하면 최종승자만이 작업을 할 수 있고 결과를 보장받을 수 있다.
하지만 작업 속도는 당연히 원래 연산보다 느려질 수 밖에 없다. 한 작업이 끝날 때 까지 다른 작업이 대기해야 하기 때문이다.
번외로, 만약에 우리가 number란 값을 추출하고 싶다면 어떻게 해야할까?
for(int i=0; i<100000; i++)
{
prev = number;
Interlocked.Increment(ref number);
next = number;
}
이런 식으로 하면 당연히 안된다.
prev = number 같이, number을 꺼내오는(Load) 연산 중에 다른 쓰레드에서 number에 접근할 수 있기 때문에 예측할 수 없는 결과가 도출된다.
int afterValue = Interlocked.Increment(ref number);
위 코드와 같이 Interlocked 함수의 반환 값을 통해 number의 값을 추출해야 한다.