C#으로 Lock을 공부하면서 CS(Computer Science)를 이론으로만 공부했을 때보다,
이해하는데 훨씬 도움이 되고 있습니다.
이론도 나름 도움이 되기는 했지만, 아직 모르는게 많은 상태에서 보니까 너무 추상적이게 다가왔었습니다.
그리고 막상 이해했다고 생각하는 내용도 시간이 지나면 금방 잊어먹었습니다.
하지만 아직은 Lock의 종류, 장점과 단점, 실제 프로젝트에서 언제 사용할지에 대한 것은 감이 안잡히는 상태입니다.
그래서 이렇게 하나씩 글로 정리하고 공부하게 되었습니다.
SpinLock을 알아보기 전에 우선 Lock이 무엇인지 한번 찾아봤습니다.
컴퓨팅 분야에서 완전한 수명 주기를 거치며 데이터의 정확성과 일관성을 유지하고 보증하는 것을 가리키며 데이터베이스나 RDBMS 시스템의 중요한 기능이다.
<위키피디아 - 데이터 무결성>
위키피티아에는 데이터베이스를 특정지어 얘기했지만, 데이터가 다뤄지는 곳에서 자주 나오는 키워드입니다.
integrity라는 단어의 뜻처럼 데이터가 일관되고 정직하며 온전하게 보전된 것을 얘기합니다.
데이터는 우연하게 또는 의도적으로 변경되거나 파괴되는 상황에 노출되지 않고 보존돼야 합니다.
임계 영역(Critical section)에 진입이 불가능할 때 진입이 가능할 때까지 루프를 돌면서 재시도하는 방식으로 구현된 락을 가리킨다.
이름은 락을 획득할 때까지 해당 스레드가 빙빙 돌고 있다는 것을 의미한다. 스핀락은 바쁜 대기의 한 종류이다.
<위키피디아 - 스핀락>
이름 그대로 직관적인 키워드입니다.
예를 들면 한 명만 들어갈 수 있는 방에 이미 누군가 들어가있고,
다른 사람이 방에 들어가기 위해 계속 방 앞에서 기다리면 언제 나오는지 확인 하는 것과 같습니다.
스핀락은 다른 동기화 기법과 비교했을 때 문맥 교환이 발생하지 않는 것으로 알려져 있지만,
이것은 일반적으로 스핀락을 사용하는 스레드가 대기하는 동안 CPU를 계속 사용하면서 다른 스레드로 스위칭하지 않기 때문입니다.
이 과정에서 스핀락을 사용하는 스레드는 계속 CPU를 점유하며 대기하므로 문맥 교환이 발생하지 않는다고 합니다.
하지만 대기하는 시간이 길어질수록 CPU 자원을 낭비하게 됩니다.
그래서 임계 영역에 짧은 시간 동안만 접근하는 경우에 스핀락의 사용이 적합합니다.
class MySpinLock
{
volatile bool _locked = false;
// 1번 : Acquire 메서드
public void Acquire()
{
while (_locked)
{
}
_locked = true;
}
// 2번 : Release() 메서드
public void Release()
{
_locked = false;
}
}
MySpinLock 클래스가 직접 구현한 SpinLock입니다.
1번 Acquire 메서드가 하는 일은 아래와 같습니다.
2번 Release 메서드는 단순하게 잠금을 해제하는 역할을 합니다.
위 SpinLock을 직접 Race condition이 발생하는 멀티 스레드 코드에 적용하면 제대로 작동하지 않습니다.
using System.Diagnostics.Metrics;
class MySpinLock
{
volatile bool _locked = false;
public void Acquire()
{
while (_locked)
{
// 잠김이 풀릴때까지 반복문 진행
}
_locked = true;
}
public void Release()
{
_locked = false;
}
}
class Program
{
static int number = 0;
static MySpinLock _spinLock = new MySpinLock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_spinLock.Acquire();
number++;
_spinLock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_spinLock.Acquire();
number--;
_spinLock.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);
}
}
우선 Race condition이 발생하는 예제 코드를 보면
스레드1은 100000번 number의 값에 1을 더합니다.
스레드2는 100000번 number의 값에 1을 뺍니다.
하지만 number++과 number--는 원자성의 보장하기 위해 아래와 같이 3 단계로 나눠서 실행됩니다.
int temp = number;
int temp += 1;
number = temp;
결국 3 단계로 나눠진 로직에서 두 개의 스레드가 공유 자원에 접근하면서 더하기와 빼기 중 둘 중 하나만 실행되게 됩니다.
매번 실행할 때마다 다른 값이 나옵니다.
그 말은 즉 SpinLock도 제대로 작동하지 않고 있다는 것입니다.
Acquire 함수의 로직에서 Atomic하게 값을 변경하는 작업을 수행하지 않았기 때문입니다.
잠금을 판별해서 기다리도록 하는 while문과 잠금 처리를 하는 것이 두 가지로 나누어져 있습니다.
이런 문제는 아래와 같이 코드를 작성하면 해결이 됩니다.
class MySpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
}
}
public void Release()
{
Interlocked.Exchange(ref _locked, 0);
}
}
이제 변경된 SpinLock으로 Race Condition이 발생하는 코드를 실행하면,
문제 없이 0이 출력되게 됩니다.
강의에 코드를 보고 구현한 것이지만 SpinLock을 한 번 구현해봤습니다.
구현을 해보고 자료를 찾아보기 전에는 SpinLock이 잠금이 풀릴 때까지 무작정 기다리는게 상당히 비효율적이라고 생각했습니다.
그래서 구현할 때는 잘 사용 안할 것이라고 생각했는데, 그것이 아니였습니다.
상대적으로 작업이 짧고 간결한 것에는 사용성이 좋고 컨텍스트 스위칭도 최소화 할 수 있습니다.