Spin Lock
변수를 점유할 때 까지 쉬지않고 계속 점유를 시도하는 방법
ex) 화장실에서 사람이 나올때 까지 대기
Spin Lock 구현
1. 만약 Lock을 다른 쓰레드가 소유하고 있으면, 해당 Lock이 풀릴때 까지, 무한루프를 돈다.
2. 만약 Lock을 소유하고 있는 쓰레드가 없다면, 해당 Lock을 얻고, 이제는 소유중이라고 Lock의 상태를 변경한다.
이 두가지 조건을 만족하는 코드를 작성해보자.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CSharp
{
class SpinLock
{
// 상태
volatile bool _locked = false;
// 획득
public void Acquire()
{
while (_locked)
{
// 기다리는 중...
}
_locked = true;
}
// 반환
public void Release()
{
_locked = 0;
}
}
class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_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(_num);
}
}
}
위 코드의 결과는 예상했던 값인 0이 아니라, 엉뚱한 값이 나온다.
그 이유는 두개의 쓰레드에서 반복문을 돌면서 정말 우연히 동시에 접근하는 경우가 있을 수 있기 때문이다.
ex) 1인용 화장실에 두명이 동시에 들어가서 각자 문을 잠그는 것.
즉, 원자성이 지켜지지 않은 것이다. while문의 동작과 _locked = true;를 시켜주는 동작이 한번에 동작하지 않기 때문에 이러한 결과가 발생한다. 그래서 이 동작들을 한번에 처리해야 한다.
ex) 1. 화장실에 들어간다. 2. 문을 잠근다. 이러한 두가지 행동을 "화장실에 들어가서 문을 잠근다." 라는 하나의 행동으로 표현
public void Acquire()
{
while (true)
{
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
}
}
이번에도 Interlocked 메서드를 사용해서 해결할 수 있다. Exchange 메서드는 ref로 전달되는 _locked 값에 '1'이라는 값을 넣어주는 행위를 한다. 이 때 넣어 주기 이전의 값을 뱉을 수 있는데 이를 소스 코드에서는 'original'로 뱉어준다. 따라서 넣어 주기 이전의 값을 이용하여 체크를 진행해 스핀락을 구현하는 것이다.
public void Acquire()
{
while (true)
{
// expected = 예상 하는 값이 무엇이냐
// desired = 내가 원하는 값은 무엇이냐
int expected = 0;
int desired = 1;
// CAS(Compare-And-Swap)라고 한다.
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
break;
}
}
보다 가시성있게 수정하는 위해서는 마찬가지로 Interlocked 클래스 내부에 있는 CompareExchange 메서드를 사용하면 된다.
* 스핀락은 면접에서도 많이 나오기 때문에 잘 정리해서 확실이 알아두자!
Sleep Lock
점유에 실패하면 일정 시간을 대기하고 점유를 시도하는 방법
ex) 화장실에 사람이 있어서 자리에 돌아갔다가 얼마 뒤, 다시 확인
위의 스핀락과 구현 방식 자체는 동일하고, while문에서 매 프레임 확인하는 것을 일정 시간 대기하도록 구현하면 된다.
Sleep Lock 구현
- Thread.Sleep(ms) : 해당 ms 만큼 무조건 대기
- Thread.Sleep(0) : 조건부 양보, 우선순위가 현재 쓰레드보다 같거나 높은 쓰레드가 없으면 현재 쓰레드
- Thread.Yield() : 현재 실행 가능한 쓰레드가 있으면 먼저 실행, 없다면 남은 시간 소진
public void Acquire()
{
while (true)
{
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
// 세가지 방법 중, 선택
//Thread.Sleep(ms);
//Thread.Sleep(0);
//Thread.Yield();
}
}
Context Switching
그렇다면 무작정 대기하는 Speen Lock보다 Sleep Lock이 좋은가? 정답은 아니다. Thread.Sleep(0)와 Thread.Yield()와 같이 다른 쓰레드로 변경하는 작업은 상당히 부담이 되는 작업이다.
컨텍스트 스위칭이란, 'CPU/코어에서 실행 중이던 프로세스/쓰레드가 다른 프로세스/쓰레드로 교체되는 것'이다. 컨텍스트란, 프로세스/쓰레드의 상태를 의미하고, 이 상태라는 것은 CPU, 메모리에서의 상태를 의미한다.
즉, 대기 시간이 길거나, Context Switching이 부담되지 않는 경우에는 Sleep Lock이 더욱 효율적이고, 그렇지 않으면 Speen Lock이 더욱 효율적이다. 상황에 맞게 적절한 Lock을 선택하는 것이 중요하다.
Event 방식
점유 종료 시점에 발행되는 이벤트를 사용하여 점유를 시도하는 방법
ex) 직원에게 화장실에서 사람이 나오면 말해달라고 하는 것
AutoResetEvent
이 이벤트를 기다리는 쓰레드들에게 신호를 보내 하나의 쓰레드만 통과시키고 나머지 쓰레드들은 다음 신호를 기다리게 한다.
ex) 유료 주차장 자동 게이트와 같이 한 차량이 통과하면 자동으로 게이트가 닫히는 것
class Lock
{
//bool <-커널
AutoResetEvent _available = new AutoResetEvent(true);
public void Acauire()//획득
{
_available.WaitOne();//락 시도
//_available.Reset(); //flag = false 의미로, WaitOne()에 포함
}
public void Release()//반납
{
_available.Set();// 락해제 flag = true
}
}
internal class Program
{
static int _num = 0;
static Lock _lock = new Lock();
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
{
_lock.Acauire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
{
_lock.Acauire();
_num--;
_lock.Release();
}
}
static void Main(string[] arg)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
예상한 것 처럼 0이 출력
ManualResetEvent
하나의 쓰레드만 통과시키고 닫는 AutoResetEvent와 달리, 한번 열리면 대기중이던 모든 쓰레드를 실행하게 하고 코드에서 수동으로 Reset()을 호출하여 문을 닫고 이후 도착한 쓰레드들을 다시 대기토록 한다.
ex) 직접 문을 잠궈야하는 일반적인 문
class Lock
{
//bool <-커널
ManualResetEvent _available = new ManualResetEvent(false);
public void Acauire()//획득
{
_available.WaitOne();//락 시도
_available.Reset();//flag = false (문을 닫음)
}
public void Release()//반납
{
_available.Set();// 락해제 flag = true (문을 염)
}
}
위코드는 예상과 다르게 0이 출력되지 않는다. 그 이유는 앞서 여러번 설명했던 것 처럼, WaitOne() 함수와 Reset() 함수가 각각 따로 실행되기 때문이다.
Lock을 구현할 때는 ManualResetEvent가 아니라, AutoResetEvent로 구현해서 위 동작이 한번에 실행되도록 해야 한다.
Mutex
internal class Program
{
static int _num = 0;
static Mutex _lock = new Mutex();
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
{
_lock.WaitOne();
_num++;
_lock.ReleaseMutex();
}
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
{
_lock.WaitOne();
_num--;
_lock.ReleaseMutex();
}
}
static void Main(string[] arg)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
- AutoResetEvent에 비해서 오래 걸림 (커널 동기화 객체)
- 대신 많은 정보들을 가지고 있음 (몇번 잠궜는지 카운팅, 쓰레드 ID 등)
- 대부분 AutoResetEvent를 이용하고, Mutex를 사용하는 경우는 거의 없음
ReaderWriterLock
게임에서 이벤트를 통해 Reward를 제공하는 코드가 있고, 운영자가 나중에 Reward를 추가할 수 있는 구조로 만든다고 가정한다. 그렇게 되면 Reward를 찾고, 추가하는 과정(Write)에서 멀티 쓰레드의 문제가 생길 수 있기에 lock이 추가 되어야한다.
하지만 리워드를 추가하는 경우는 매우 적기 때문에 (일주일에 한두번 정도) 그러한 상황을 위해서 lock을 걸어 놓는다는 것이 아쉽다. 이때 읽을 때(Read)는 lock 없는 것 처럼 자유롭게 다니고, 추가할 때(Write)는 lock을 통해 해야 하는 구조로 사용하면 효율적이다. 이때 사용하는 것이 ReaderWriterLock 이다.
class Reward
{
}
//선언 형식
static ReaderWriterLockSlim _lock3 = new ReaderWriterLockSlim(); // ReaderWriterLock이 있지만, ReaderWriterLockSlim이 더 최신이고 기능이 좋기 때문에 사용
//자주 호출되는 함수
static Reward GetRewardById(int id)
{
_lock3.EnterReadLock();
//여러 쓰레드가 동시 접근은 가능하지만, 쓰기 작업은 차단
_lock3.ExitReadLock();
return null;
}
//자주 사용되지 않는 함수
static void AddReward(Reward reward)
{
_lock3.EnterWriteLock();
//단 하나의 쓰레드만 접근 가능, 쓰기 가능
_lock3.ExitWriteLock();
}
ReaderWriterLock 직접 구현하기
재귀적 락 허용 X
// 락을 얻은 쓰레드에서 한번 더 락을 얻는 것을 허용할 것인지
// 스핀락 정책 (5000번 -> Yield)
class Lock
{
const int EMPTY_FLAG = 0x00000000; //첫 1비트 // 0000 0000 0000 0000 0000 0000 0000 0000
const int WRITE_MASK = 0x7FFF000; //두번째부터 15비트 //0111 1111 1111 1111 0000 0000 0000 0000
const int READ_MASK = 0x0000FFFF; //뒤 16비트 // 0000 0000 0000 0000 1111 1111 1111 1111
const int MAX_SPIN_COUNT = 5000;
// [Unused(1bit)] [WriteThreadId(15bit)] [ReadCount(16bit)]
int _flag = EMPTY_FLAG;
public void WriteLock()
{
//ThreadID를 가져와서, 15비트를 저장하는 방법
//16비트를 밀고 WRITE_MASK와 & 연산을 통해 범위를 넘어가지 않도록 설정
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
// 아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 소유권을 경합해서 얻는다
while(true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
//시도를 해서 성공하면 return
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
return;
//여러개가 동시에 들어와도 ID가 다르기 때문에 누군가 승자는 존재함
// 동시다발적으로 실행되어야 하기 때문에 이렇게 하면 안된다
/*if(_flag == EMPTY_FLAG)
{
_flag = desired;
return;
}*/
}
Thread.Yield(); //5000번 하고 실패시 양보
}
}
public void WriteUnlock()
{
//저장된 Thread ID를 밀어버리면 된다
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 아무도 WriteLock을 획득하고 있지 않으면, ReadCount를 1 늘린다
// 여러 쓰레드가 동시에 Read를 잡을 수 있게 하기 위함
while(true)
{
for(int i=0; i<MAX_SPIN_COUNT; i++)
{
int expected = (_flag & READ_MASK); //WRITE 부분을 0으로 밀어버린 값이 기대값
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
return;
//A, B가 동시에 입장 했을 때
//A가 승자라고 하면, ReadCount를 0 -> 1로 바꿈
//B가 요청하려 할 때, expected가 0이어야 하는데, 실제 값은 1이 나옴
//for문 다음 반복으로 돌림
//돌리면 expected가 1이 됨, 이제 맞으므로 1 -> 2 연산을 수행함
/*if((_flag & WRITE_MASK) == 0) //& 연산하고 0이라면, 아무도 WRITE를 가지고 있지 않다는 뜻
{
_flag = _flag + 1;
return;
}*/
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag); //Read Count 1 줄이기
}
}
재귀적 락 허용
// WriteLock -> WriteLock, WriteLock -> ReadLock (o)
//ReadLock -> WriteLock (x)
class Lock
{
const int EMPTY_FLAG = 0x00000000; //첫 1비트 // 0000 0000 0000 0000 0000 0000 0000 0000
const int WRITE_MASK = 0x7FFF000; //두번째부터 15비트 //0111 1111 1111 1111 0000 0000 0000 0000
const int READ_MASK = 0x0000FFFF; //뒤 16비트 // 0000 0000 0000 0000 1111 1111 1111 1111
const int MAX_SPIN_COUNT = 5000;
// [Unused(1bit)] [WriteThreadId(15bit)] [ReadCount(16bit)]
int _flag = EMPTY_FLAG;
int _writeCount = 0; //재귀 write를 위한 카운터 추가
public void WriteLock()
{
//동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if(Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
_writeCount++; //이미 갖고 있다면 count만 올려주면 끝
return;
}
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
while(true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
}
Thread.Yield();
}
}
public void WriteUnlock()
{
//락 카운트를 unlock 할 때마다 1씩 줄이다가
int lockCount = --_writeCount;
if (lockCount == 0) //그게 마지막 락이었다면, Release
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if(Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
Interlocked.Increment(ref _flag); //리드카운트 늘리기
return;
}
while(true)
{
for(int i=0; i<MAX_SPIN_COUNT; i++)
{
int expected = (_flag & READ_MASK);
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
return;
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag); //Read Count 1 줄이기
}
}
Write -> Read순서로 Lock을 얻었다면, Read -> Write 순서로 Unlock 해야 함
구현한 ReaderWriterLock 의 Write Test
static volatile int count = 0;
static Lock _lock = new Lock();
static void Main(string[] args)
{
Task t1 = new Task(delegate ()
{
for(int i=0; i<100000; i++)
{
_lock.WriteLock();
count++;
_lock.WriteUnlock();
}
});
Task t2 = new Task(delegate ()
{
for(int i=0; i<100000; i++)
{
_lock.WriteLock();
count--;
_lock.WriteUnlock();
}
});
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(count);
}
0이 출력된다면 정상적으로 실행!
'서버 > 서버 이론' 카테고리의 다른 글
네트워크 기초 이론 (0) | 2025.04.07 |
---|---|
Thread Local Storage (TLS) (0) | 2025.04.06 |
데드락 (DeadLock) (0) | 2025.03.28 |
Lock (0) | 2025.03.27 |
InterLocked (0) | 2025.03.24 |