SpinLock의 핵심은 Lock을 얻지 못했을 때 루프를 돌며 Lock을 얻는 시도를 반복하는 것이다. SpinLock을 구현할 때는 Lock을 얻는 함수를 호출한다면, 호출한 객체에 Lock을 얻었음을 보장해야 한다.
Lock의 예제는 멀티쓰레드에서 사용한 정수형 자원의 증감 쓰레드를 사용하였고 해당 예제의 main함수는 다음과 같다. Thread1
은 증가연산을 Thread2
는 감소연산을 진행하고 두 쓰레드가 같은 횟수의 연산을 진행하기 때문에 정수형 자원의 최종값은 0이 되어야 한다.
class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread1()
{
for(int i=0;i<10000000;i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread2()
{
for (int i = 0; i < 10000000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1,t2);
Console.WriteLine(_num);
}
}
각 쓰레드는 LockObject인 _lock
을 통해 _num
에 대한 Lock을 얻은 후 연산을 진행한다. SpinLock Class에서 동시성을 제어하는 구현에 따라 쓰레드의 Lock방식이 결정되는 구조이다.
SpinLock의 구조는 Acquire()
로 Lock을 하고 Release()
로 Unlock하는 구조로 현재 Lock이 되어 있는지는 SpinLock Class의 _locked로 나타낸다. 간단하게 구현하면 아래와 같은 방식으로 구현할 수 있다.
class SpinLock
{
volatile bool _locked = false;
public void Acquire()
{
while(_locked)
{
}
_locked = true;
}
public void Release()
{
_locked = false;
}
}
Lock을 얻는 Acquire()
는 다른 쓰레드가 Lock을 얻었는지 나타내는 bool자료형인 _locked를 while문의 조건식으로 넣어 다른 쓰레드가 사용중이라면 루프를 도는 방식으로 구현하였다.
Release()
는 단순히 _locked=false
로 구현하였는데, Release()
가 호출 될 때는 호출한 함수가 이미 상호 베타적인 Lock을 얻은 상태이기 때문에 문제가 되지 않는다.
놀랍게도 위의 코드로 main함수를 작동시키면 0이 나오지 않는다.
_num의 최종값이 0이 아닌 이유를 알아내기 위해서 Acquire()의 구조를 먼저 살펴봐야할 필요가 있다. Acquire()
의 구조를 나누면 크게 두 부분으로 나눌 수 있다.
위의 구조고 나눌 수 있는 이유는 당연히 코드상에 두 부분으로 나누어져 있기 때문이다. Loop에 해당하는 부분은 while(_locked)
이고 Assign에 해당하는 부분은 _locked=true
이다.
문제가 발생하는 상황은 아래그림과 같이 나타낼 수 있다. 쓰레드의 붉은 선은 현재 연산 진행중인 부분이다.
문제가 발생하는 상황에서 Thread1
은 _locked==false
임을 확인하고 Assign
으로 내려가기 직전이고 Thread2
는 Thread1
이 _locked
의 상태를 변경하기 전에 Loop문의 조건식을 지나 Loop를 벗어나기 직전이다.
두 쓰레드는 자신이 Lock을 정상적인 방법으로 얻었고 다른 쓰레드가 Lock을 얻지 못했다고 생각하게 될 것이고 동시에 _num
에 쓰기 작업을 수행하게 될 것이다.
이 문제를 해결하기 위해
_locked
에 읽기 작업과 쓰기 작업을 동시에 할 필요가 있다.
읽기 작업과 쓰기 작업을 동시에 하기 위해 C#의 Interlocked
를 사용할 수 있다.
Interlocked
는 RaceCondition을 해결할 때 사용하는 방법 중 하나이다.
Acquire()
에 Interlocked를 사용하기 위해
_locked
의 자료형을 정수형으로 바꾸어 준다.1
로, 그렇지 않음을 0
으로 나타낸다.Interlocked
의 Swap관련 함수를 사용한다.위 내용을 적용한 Acuire()
는 아래와 같다.
public void Acquire()
{
while (true)
{
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
}
}
Exchange함수는 레퍼런스 변수를 지정한 값으로 바꾸는 함수이며 반환값은 Original Value
이다. 이를 이용해 각 쓰레드는 _locked
를 1로 바꾸고 original를 조사함으로 _locked
를 정말로 자신이 바꿨다면 Loop를 빠져나오도록 한다.
original
은 지역변수이므로 각 쓰레드의 스택영역에 존재한다. 즉, 다른 쓰레드에 영향을 미치지 않는 변수이다.
코드를 더 간소화 한다면 아래와 같이 나타낼 수 있다. Exchange함수가 아닌 CompareExchange를 사용하였고 비교하고자 하는 값을 매개변수로 넣어 _locked
의 값이 0이라면 1로 바꾸고 그렇지 않다면 바꾸지 않는다. CompareExchange의 반환값은 Exchange와 같은 Original Value
이다.
public void Acquire()
{
while (true)
{
if (Interlocked.CompareExchange(ref _locked, 1, 0) == 0)
break;
}
}
Interlocked
는 정수형을 대상으로 하는 클래스로 자세한 내용은 공식문서를 참조하자!
C#의 System.Threading 산하에는 SpinLock이 구현되어 있는데 이를 이용해 Lock을 구현할 수 있다. Threading.SpinLock의 기본적인 인터페이스는 Enter
와 Exit
이 있는데 각각 위 예제의 Acquire
, Release
에 해당한다.
lockTaken
이 있다. Lock을 정상적으로 얻었다면 lockTaken
은 true
가 된다.Enter
로 Lock을 얻은 후 Exit
로 얻은 Lock을 해제해주어야 한다.