멀티 쓰레드
ReaderWriterLock을 구현해보자! 먼저 소스코드가 길어질 수 있으므로 ServerCore의 새로운 스크립트를 Lock.cs로 만들어주자.
namespace ServerCore
{
class Lock
{
int _flag;
}
}
새로운 플래그를 위와 같이 설정하자. 플래그는 int형 변수를 사용하기 때문에 32비트를 가지게 된다. 우리는 32비트의 구조를 이용하여 Write의 영역과 Read의 영역을 구분할 것이다. 맨 앞의 1번 비트는 사용하지 않는다. 왜냐하면 해당 값을 사용하게 되면 음수의 값을 사용하기 때문이다.
따라서 우리는 2번 비트부터 16번 비트까지는 WriteThreadID의 영역을 이용해 쓰레드 아이디를 저장하고, 17번 비트부터 32번 비트까지는 ReadCount라고 하여 카운트를 읽을 때 사용하기로 한다. 즉. ReadCount는 ReadLock을 획득 했을 때 여러 스레드들이 동시에 Read를 실행하는 것을 카운트하고, WriteThreadId 같은 경우는 WriteLock을 획득 했을 때 WriteLock을 획득한 스레드의 ID를 저장하는 것으로 생각하면된다.
namespace ServerCore
{
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
// 시작 값을 플래그가 빈 상태인 EMPTY_FLAG로 설정한다.
int _flag = EMPTY_FLAG;
}
}
기본적으로 공간을 분리했으면 이제 락을 만들 때에 어떠한 정책을 펼칠것인지 설정해야한다. 재귀적 락은 허용할 것인지, 스핀 락은 몇 번을 돌고 난 이후에 포기할 것인지 등의 대한 정보를 설정하는 것이다. 지금은 일단 재귀적 락을 허용하지 않는 것으로 설정하고 스핀 락의 경우는 최대 5,000번의 스핀 락을 시도했는데도 락을 획득하지 못한다면 Yield를 이용해 포기하는 것으로 설정하자.
자, 이제 WriteLock, WriteUnlock, ReadLock, ReadUnLock에 대해서 구현해보자.
WriteLock
public void WriteLock()
{
// 아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다.
// desired = 내가 원하는 값
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
// 스핀 락 구조 형태
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// 아무도 락을 획득하지 않은 상태 == EMPTY_FLAG
if (_flag == EMPTY_FLAG)
{
// _flag = desired;
// ❌
// 어떻게 해야할까? InterLocked을 사용한다. ⭕
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
return;
}
}
Thread.Yield();
}
}
구현하려는 WirteLock의 기능에 대해서 생각해보자. 우리는 아무도 WriteLock과 ReadLock을 획득하고 있지 않을 때, 이를 경합해서 소유권을 얻고자 하기 위해 해당 메서드를 실행한다.
내부를 살펴보자. 우리는 스핀 락 구조를 사용할 것이기 때문에 while(true)을 설정하고, MAX_SPIN_COUNT 만큼 실행한다. 이때 시도를 해서 성공을 하면 해당 값을 return 하면 된다. 그렇다면 _flag에 우리가 원하는 값을 넣어주면 된다. 이 때 우리가 원하는 값을 WriteThreadId에 넣어주면 되는데 어떻게 넣어줄 수 있을까?
바로 Thread.CurrentThread.ManagedThreadId 메서드를 이용해서 현재 스레드의 ID를 가져온다. 해당 메서드는 스레드의 고유 식별자를 나타내는 정수이다. 따라서 이를 desired 라는 변수로 설정(내가 원하는 값)하여 _flag = desired; 를 실행하여 값을 넣어주면 된다.
근데 이렇게 실행하면 될까? 🤔 당연히 안된다. 왜냐하면 값을 집어 넣는 행위가 또 원자적이지 않기 때문이다. 즉 멀티 쓰레드 환경에서 여러 쓰레드가 동시에 WriteLock 메서드를 실행하면 현재 플래그는 비어있는 상태이므로 동시 다발적으로 실행이 되어 자기가 원하는 값을 넣어주기 때문에 결국 _flag에는 이상한 값이 들어가버린다. 따라서 이전에 학습한 것과 같이 Interlocked을 이용하여 값을 넣어준다.
WriteUnlock
public void WriteUnlock()
{
// _flag를 EMPTY_FLAG로 바꿔준다.
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
개고생했던 WriteLock과 달리 Unlock에서는 _flag의 값을 EMPTY_FLAG로만 변경해주면 된다. 😫..
ReadLock
public void ReadLock()
{
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// if ((_flag & WRITE_MASK) == 0)
// {
// _flag = (_flag + 1) & READ_MASK;
// return;
// }
// ❌
// expected == 예상하고 있는 값
int expected = (_flag & READ_MASK);
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
{
return;
}
// ⭕
}
Thread.Yield();
}
}
마찬가지로 구현하려는 ReadLock의 기능에 대해서 생각해보자. 우리는 아무도 WriteLock을 획득하고 있지 않을 때에 ReadCount를 1 늘려주길 원한다.
따라서 WriteLock과 같이 스핀 락을 사용할 것이기 때문에 while(true)을 설정하고, 마찬가지로 MAX_SPIN_COUNT 만큼 실행한다. 이 때에도 시도 후 성공을 하면 해당 값을 return 하면 된다. 즉. 아무도 WriteLock을 획득하고 있지 않을 때 아래와 같은 처리를 진행하면 된다.
if ((_flag & WRITE_MASK) == 0)
{
_flag = (_flag + 1) & READ_MASK;
return;
}
그러나 여기까지 왔으면 알것이다. 당연히 위의 소스코드는 문제가 생긴다. 왜냐하면 원자성을 보장하고 있지 않기 때문이다. 따라서 아래와 같이 수정하여 실행한다.
// expected == 예상하고 있는 값
int expected = (_flag & READ_MASK);
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
{
return;
}
한눈에 코드를 알아보기 힘들다. 우리가 사용하려던 (_flag & WRITE_MASK) == 0가 사라졌기 때문이다. 내가 예상하던 값은 Unused와 WriteThreadId의 값이 없는 0인 상태의 값이다. 그러나 이는 무조건 실패하는 값의 조건이다. 왜냐하면 만약 특정 스레드가 WriteLock을 잡고 있으면 [WriteThreadId(15)]의 내부 값이 0이 아니므로 if 문을 절대 통과할 수 없다.
또한 다음으로는 WriteLock 메서드는 사용안하고 있는 상태이지만 여러 스레드가 동시에 ReadLock을 잡으려고 한다면 expected 값은 똑같겠지만, 내부에서 경합을 하게 되어 첫 번째의 친구는 expected + 1의 값을 정상적으로 실행하는데 나머지로 들어온 친구들 또한 expected + 1 처리를 진행 했기 때문에 내가 예상했던 값이 나타나지 않아 실패하기 된다.
즉. 위의 소스 코드의 동작방식을 예로 들어 설명한다면, A, B 스레드가 있는데 동시에 ReadLock 메서드에 들어왔다고 가정해보면 expected의 값은 0이기 떄문에 A가 expected 값을 1 증가시켜 1이 되고, B가 또 다른 expected 값을 1 증가시켜 마찬가지로 1이 된다. 하지만 A가 이미 expected 값을 1 증가시켜 변경했기 때문의 B의 처리는 A에 의해 실패하게 되는 것이다.
그렇다면 B의 처리는 어떻게 진행될까? 이는 다음 for문에 해당 값을 변경할 수 있다. 이것이 멀트쓰레드 환경에 있어서 Lock을 취할 수 있는 기본 실행 원칙이다. 😩..
ReadUnlock
public void ReadUnlock()
{
// Decrement에서 1을 빼준다.
Interlocked.Decrement(ref _flag);
}
ReadUnlock에서는 Decrement 메서드를 이용해 _flag의 값을 하나 줄이면 된다. 여기까지가 재귀적 락을 허용하지 않는 상태에서의 RWLock의 기본 구현이다. 아래는 재귀적 락을 허용하는 RWLock의 예시이다.
생각해보면 WriteLockId에 값을 넣어줘야 하는 의문이든다. 그냥 불리언을 이용하여 사용하면 되는 것이 아닐까? 생각할 수 있지만, 이는 다 계획이 있었던 것이다.
바로 재귀적 락을 허용해야할 때 사용한다. 따라서 bool을 이용하지 않고 const와 int를 이용하여 작업을 진행한 것이였다. 자 그렇다면 재귀적 락을 허용한다면 기본적으로 알아야 하는 것이 있다. 바로 어떤 것은 재귀적 락을 허용하고, 어떠한 것은 허용하지 않을 지 구분해야 하는 것이다.
당연하겠지만 재귀적 락을 허용한다면 WriteLock이 WriteLock을 재귀적으로 사용하는 것은 문제가 없다. 또 WriteLock가 ReadLock을 허용하는 것도 당연히 문제가 없다. 그렇지만 ReadLock이 WriteLock을 한다는건 미친 행동이기 때문에 문제가 된다. 이 점을 명심하자.
재귀적 Lock이 허용된 상태에서의 RWLock
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
int _flag = EMPTY_FLAG;
// 재귀적으로 몇 개의 Write를 관리하기 위한 변수
int _writeCount = 0;
public void WriteLock()
{
// 동일 스레드가 WriteLock을 이미 획득하고 있는지 확인한다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (lockThreadId == Thread.CurrentThread.ManagedThreadId)
{
// 이미 획득하고 있으면 _writeCount를 증가시키고 return
_writeCount++;
return;
}
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (_flag == EMPTY_FLAG)
{
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
}
}
Thread.Yield();
}
}
public void WriteUnlock()
{
int lockCount = --_writeCount;
if (lockCount == 0)
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인한다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (lockThreadId == Thread.CurrentThread.ManagedThreadId)
{
// ReadCount를 늘려준다.
Interlocked.Increment(ref _flag);
return;
}
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// expected == 예상하고 있는 값
int expected = (_flag & READ_MASK);
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
{
return;
}
}
Thread.Yield();
}
}
public void ReadUnlock()
{
// Decrement에서 1을 빼준다.
Interlocked.Decrement(ref _flag);
}
}
먼저 재귀적으로 현재 몇 개의 Write를 관리하기 위한 변수 _writeCount를 선언하여 이를 통해 관리를 진행한다. WriteLock의 상단에는 동일 스레드가 Lock을 이미 획득하고 있는지를 확인하는 구문을 추가하여 준다.
// 동일 스레드가 WriteLock을 이미 획득하고 있는지 확인한다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (lockThreadId == Thread.CurrentThread.ManagedThreadId)
{
// 이미 획득하고 있으면 _writeCount를 증가시키고 return
_writeCount++;
return;
}
이 후 CompareExchange를 진행할 때 _writeCount를 1로 설정해준다. 이 부분이 정상적으로 이해가 되지 않았는데 챗 GPT한테 물어보니 다음과 같았다.
위 소스 코드에서 _writeCount를 1로 설정해주는 이유는 다음과 같습니다:
1. WriteLock 메서드가 호출될 때, 현재 스레드가 이미 WriteLock을 획득한 상태인 경우에는 _writeCount를 증가시켜야 합니다. 이는 재귀적으로 WriteLock을 획득할 때를 고려한 부분입니다. 재귀적으로 호출되는 경우, _writeCount를 증가시켜 해당 스레드가 몇 번 WriteLock을 획득했는지 추적할 수 있습니다.
2. _flag가 비어있는(EMPTY_FLAG) 상태에서 Interlocked.CompareExchange 메서드를 사용하여 _flag를 원하는 값(desired)으로 설정하려고 시도합니다. 만약 이 시도가 성공하면, 새로운 스레드가 WriteLock을 획득한 것이므로 _writeCount를 1로 설정하고 반환합니다. 이렇게 하면 해당 스레드는 WriteLock을 성공적으로 획득한 것으로 간주됩니다.
3. _flag를 desired 값으로 설정하는데 성공하지 못하면, 다른 스레드가 이미 WriteLock을 획득한 상태이므로 스핀 락 구조를 사용하여 대기합니다. 스핀 락에서 대기하는 동안 _flag의 상태가 변경되지 않으므로 _writeCount를 설정하지 않고 대기합니다.
즉, _writeCount를 1로 설정하는 것은 WriteLock을 획득한 스레드의 첫 번째 시도에 해당하며, 이후에 재귀적으로 호출되면 _writeCount를 증가시켜 해당 스레드가 몇 번 WriteLock을 획득했는지 추적합니다.
라고 한다. 얼핏 맞는 말인것 같아 이렇게 이해하고 넘어가기로 했다. 😁..
WriteUnlock에서는 기존과는 다르게 무조건 적으로 값을 바꿔주면 안된다. 내가 WriteCount++을 통해 값을 늘려준 만큼 회수해야하기 때문이다. 이는 짝을 맞춰주는 행위라고 생각하면 될 것이다.
ReadLock에서는 WriteLock과 같이 메서드 상단에 아래와 같이 코드를 추가해야 한다.
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인한다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (lockThreadId == Thread.CurrentThread.ManagedThreadId)
{
// ReadCount를 늘려준다.
Interlocked.Increment(ref _flag);
return;
}
이 떄에도 ReadCount를 늘리기 위해 Interlocked.Increment를 이용하여 늘려주는 것을 명심하자. ReadUnlock은 따로 변한 것이 없기 때문에 생략한다.
구현한 RWLock이 정상적으로 실행되는지 테스트해보자.
class Program
{
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);
}
}
다시 기존에 작성한 program 스크립트로 돌아와 위와 같이 작성한다. 델리게이트를 통해 사용하고 실행해보면 정상적으로 0이 나오는 것을 알 수 있다.
이 때 t1에 _lock.WriteLock(); 을 하나 더 추가해서 실행하면 WriteUnlock의 짝을 맞춰주었지 않기 때문에 무한 루프를 도는 것을 볼 수 있다. 또한 ReadLock, ReadUnlock을 실행하면 해당 메서드는 상호 배타적인 관계가 되지 않아 이상한 값이 나오는 것도 확인할 수 있다.
어렵다 어려워. 🤦♂️
'공부 > 인프런 - Rookiss' 카테고리의 다른 글
Part 4-3-1. 네트워크 프로그래밍 : 네트워크 기초 이론 (0) | 2023.09.19 |
---|---|
Part 4-2-15. 멀티쓰레드 프로그래밍 : TLS(Thread Local Storage) (1) | 2023.09.18 |
Part 4-2-13. 멀티쓰레드 프로그래밍 : ReaderWriterLock (0) | 2023.09.16 |
Part 4-2-12. 멀티쓰레드 프로그래밍 : 이벤트(AutoResetEvent, ManualResetEvent), 뮤텍스 (0) | 2023.09.16 |
Part 4-2-11. 멀티쓰레드 프로그래밍 : 문맥 교환(Context Switching) (0) | 2023.09.16 |