Critical Section은 병렬컴퓨팅에서 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하는 코드를 말한다. -위키백과-
즉, 공유 자원을 접근하는 코드를 Critical Section이라고 한다.
Critical Section을 구현하는 여러가지 방법이 있는데 그 중 가장 대표적인 Spinlock을 오늘 구현해보도록 하겠다.
SpinLock을 쉽게 설명하자면, 어떤 사람이 화장실을 문을 잠그고 볼일을 보는 동안, 문 앞에서 무작정 기다리다가 사람이 나오면 그때 들어가서 문을 잠그는 방식이다.
무지성 기다림 -> 사람 나옴 -> 문 들어가서 잠금 으로 간단하게 이해할 수 있다.
직관적으로 다음과 같이 SpinLock을 구현할 수 있을 것 처럼 보인다.
class SpinLock // Lock이 풀릴 때 까지 돌아가는 존버메타
// 화장실을 사용하는 사람이 문을 열고 나올때 까지 앞에서 기다리는 중 ㅠㅠ
{
volatile bool _locked = false; // 가시성을 보장해주겠다.
public void Acquire()
{
while (_locked) // 화장실 문이 비어있을 때 까지 대기를 한다.
// _locked이 false일때 빠져나간다.
{ }
// 들어가서 문을 잠그는 작업
_locked = true;
}
public void Release()
{
_locked = false;
}
}
class Program
{
static int number = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for(int i=0; i<10000; i++)
{
_lock.Acquire();
number++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i=0; i<10000; i++)
{
_lock.Acquire();
number--;
_lock.Release();
}
}
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);
}
}
먼저, volatile 키워드로 _locked 변수의 가시성을 보장해준다.
Acquire 함수는 화장실에서 무작정 기다리는 상황을 구현한 함수이다.
_locked이 true일 때 즉, 다른 쓰레드에서 작업을 할 때 (화장실을 쓸 때)는 무작정 기다리다가, _locked이 false로 바뀌면 그때 while문을 빠져나온다.
그 다음 _locked을 true로 바꿔서 다른 쓰레드가 공유 자원에 접근하지 못하도록 잠군다.
작업이 다 끝났으면 Release 함수를 실행한다. 이는 볼일을 마치고 화장실을 나가는 상황이다. _locked을 false로 바꿔서 다른 쓰레드가 공유 자원에 접근할 수 있도록 해준다.
또 시작이다. 멀티 쓰레드 환경에서 우리가 무엇을 놓쳤길래 이런 결과가 나올까? 바로 원자성이다.
아주 미세한 순간이지만, 화장실 문 앞에서 기다리는 과정과 문이 열릴 때 들어가서 문을 잠그는 작업 사이에 시간 차가 있다.
만약 그 미세한 순간에 다른 쓰레드가 접근한다면? 화장실에 두 쓰레드가 들어가는 상황이 발생한다. 그런 상황에서는 number에 무슨 값이 들어있는지를 예측할 수 가 없다.
구체적으로 얘기하자면, number++와 number-- 연산은 우리 눈에는 하나의 연산으로 보이지만 기계어(어셈블리 언어)의 관점에서는 3단계의 연산으로 이루어졌다.
Race Condtion과 Interlocked을 공부할 때 배웠다. ㄷㄷ
예를 들어 number++ 라는 연산은 CPU 레벨에서
number의 주소에 저장된 값을 가져와서
그 값에 1을 더해주고
더해준 값을 다시 number에 대입한다.
의 3단계로 이루어 진다. number-- 도 마찬가지다.
멀티 쓰레드 환경에서는 쓰레드들의 경합에 의해서 마지막 단계 즉, number에 대입하는 단계에서 엉뚱한 값이 대입할 수도 있는 것이다.
따라서 문제를 해결하기 위해서는 화장실 문 앞에서 기다리는 과정과 문을 열고 잠그는 과정을 한 번에 실행하여 number에 작업할 때 하나의 쓰레드만 작업할 수 있게 해야한다.
public void Acquire()
{
while (true)
{
int expected = 0;
int desired = 1;
//Compare and Swap(CAS)
int original = Interlocked.CompareExchange(ref _locked, desired, expected);
// _locked 가 comparand(expected)와 같다면 _locked에 desired을 대입
// original은 원래 _locked이 가지고 있던 값.
if (original == expected)
{
break;
}
}
}
_locked 변수를 int형 변수로 만들어주고 Acquire 함수를 다음과 같이 수정해보자.
Interlocked.CompareExchange 함수는 _locked와 expected 의 값이 같으면, 원자성을 보장하면서 _locked 에 desired 값을 할당한다.
위에서 우리가 _locked이 false로 변해서 while을 빠져나오는 것과 _locked에 true를 할당하는 과정이 원자성이 보장이 안된 것에 반해
Interlocked을 사용하여 비교와 대입을 한 번에 할 수 있다.
이 방법을 Compare and Swap 줄여서 CAS라고 한다.
number에 0이 나오는 것을 확인할 수 있다.