멀티쓰레딩을 이용하여 코드를 작성하는 경우 race condition의 발생에 유의해야 한다.
먼저 race condition이란 여러개의 쓰레드가 동시에 같은 메모리 자원에 접근하는 행위를 의미한다.
static int _num = 0;
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
{
_num++;
}
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
{
_num--;
}
}
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(_num);
}
멀티쓰레딩에 배우면서 가장 처음에 접하게 되는 예제 코드이다. 두개의 쓰레드가 번갈아 가며 static으로 선언된 전역 변수에 접근하여 백만번을 빼고 더하는 코드이다.
단순히 생각하였을 때 백만번 빼고 백만번 더하였으니 0이 되어야 하는 것이 옳다. 하지만 코드를 실행해보면 0이 아니며, 이상한 숫자가 나온다.
바로 결론부터 얘기하면 이는 값을 가져와서 더하거나 빼는 연산의 행위가 원자적(atomic)이지 않기 때문이다. 원자적이라 함은 한번에 일어나는 행위를 말한다. 코드상에서는 _num++; 이 한줄의 코드는 변수를 1증가시키는 한번의 연산으로 진행 될것 같지만 실제로는 아니다.
int temp = num;
temp += 1;
num = temp;
num++; 라는 한 줄의 코드는 실제로는 위와 같이 세개의 과정에 따라 진행된다. 이것은 어셈블리 언어의 load add store에 해당하며 결국 어셈블리 언어의 연산까지 들어가야 원자적으로 진행이 되기 때문에 실제로는 변수가 1증가하는 과정이 원자적이지 못한 것이다.
그렇다면 더하는 과정이 원자적이지 못한것과 결과가 이상한것은 무엇 때문인지를 보면, 2개의 쓰레드가 백만번 번갈아가며 연산을 하던 도중에 _num의 값을 동시에 load해서 가져갔다고 해보자. 각각의 쓰레드는 1을 더하고 빼서 다시 그 값을 메모리에 store할 것이다. 순서는 알 수가 없으나 결국 나중에 store한 _num의 값이 최종 값이 될 것이다.
결과 값이 뭐가 나오는지 알 수는 없지만 확실한 것은 0이 아니라는 것이다. 우리는 당연히 1을 빼고 더하였으니 0이 나오기를 기대했으나 기대 값과 다른 것이다.
이렇게 공용의 자원에 동시에 여러개의 쓰레드가 접근하려는 상황을 race condition이라고 한다.
이런 race condition을 해결하기 위해서는 동시에 여러 쓰레드가 동일한 자원에 접근하는 것을 막기 위해 ciritical section을 지정하여 하나의 쓰레드만이 해당 자원을 물고 있을 수 있게 해야 하며, 이를 문을 걸어 잠근다라는 표현을 사용하여 lock을 이용한다고한다.
lock의 종류에도 여러 가지가 있고 이 글에서는 spin lock에 대해 알아본다.
Spin Lock이란 critical section에 들어갈 수 있는지(자원에 접근할 수 있는지) 계속해서 확인하는 lock이다.
while(_lock == true) { }
코드로 표현하면 단순히 위와 같이 _lock가 false가 되어 풀릴 때까지 무한히 while문을 도는 형태라고 할 수 있다.
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
// 락이 풀리기를 기다리다가 바로 들어간 후 열쇠를 걸어 잠근다 -> 동시에 들어가는 경우가 발생
while (_locked == 1) { }
_locked = 1;
}
public void Release()
{
_locked = 0;
}
}
_locked가 다른 쓰레드에의해 0이 되어 풀리면 while문을 탈출하여 본인이 lock을 가지게 되는 형태의 spin lock을 구현했다고 생각 할 수 있지만 틀렸다.
앞서 언급한 원자적(atomic)인 연산이 무엇이었는가를 떠올려 보자. 만약 두개의 쓰레드가 동시에 Acquire를 호출하여 lock을 얻으려 하는 상황이 왔다고 생각해 보면 두 쓰레드는 동시에 _locked의 값을 load해보고 lock이 걸려있지 않다고 판단하여 동시에 lock을 가지게 된다. 결국 위의 코드는 spin lock의 기능을 하지 못한다고 볼 수 있다.
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
// 락이 풀리기를 기다리다가 바로 들어간 후 열쇠를 걸어 잠근다 -> 동시에 들어가는 경우가 발생
while (true)
{
// CAS compare and swap 패턴
int expected = 0;
int desired = 1;
int original = Interlocked.CompareExchange(ref _locked, desired, expected);
if (original == 0)
break;
}
}
public void Release()
{
_locked = 0;
}
}
위의 코드는 CAS(Compare And Swap)연산 함수를 이용하여 구현한 Spin Lock이다. Acquire가 lock을 얻는 함수, Release가 lock을 풀어주는 함수이다.
여기서 궁금한 것은 while문 안의 코드들인데, 이것이 앞서 틀린 Spin Lock 코드와 무엇을 다르게 해주기에 spin lock을 동작하게 해주는가를 보자.
C#의 Interlocked의 함수들은 멀티 쓰레딩에서 사용하기 위한 원자적인 연산을 제공하는 클래스이다. 그중에서 CompareExchange는 원자적인 값의 교체를 가능하게 해주는 함수로, 동시에 여러 쓰레드가 _locked의 값을 변화시키려 하는 것을 막고, 무조건 먼저 접근한 쓰레드 하나에게만 접근을 허용한다.
위와 같이 cas계열의 함수를 사용하면 간단한 Spin Lock을 위와 같이 구현할 수 있다. 이런 cas 함수들은 언어들마다 이름은 조금씩 다르므로 사용에 유의해야 한다.