📌 ReaderWriterLock 이란?
읽기 전용 작업(Read-Only) 에 대해서는 동시 접근을 허용하고, 쓰기 작업에 대해서는 독점 접근(exclusive)을 함.
🤔 왜, 언제 사용할까 ❗❓
lock을 사용하면 오직 하나의 스레드만 코드(임계영역)에 접근할 수 있다.
하지만 하나의 스레드만이 쓰고 읽을 수 있다면 매우 시간도 오래걸리고 비효율적일 것이다.
👉 ReaderWriterLock은 이를 해결해준다. 다수의 스레드들이 읽을 수 있게 하며, 쓰기 전용 권한을 가질 수 있도록 한다. 다수의 독자가 있고 작성자가 적은 경우에 효율적이다.
Read : 다수의 스레드가 접근 가능
다른 쓰레드가 쓰고(Write)있으면 대기 -> Write Lock이 해제 되어야 진입 가능
Write : 동시에 하나의 스레드만 수정(접근) 가능
읽고 있는 중에 데이터가 수정되면 안되므로, 모든 Read Lock이 해제되어 있는 상황이어야 함
-> ReadLock이 해제될 때 까지 대기
💥 ReaderWriterLock 구현하기
👉 재귀적 Lock을 허용하지 않는 상황
설정 : 스핀락 사용, 최대 스핀 횟수(MAX_SPIN_COUNT) 는 오천번으로 설정
32비트를 사용한다.
찬찬히 코드를 살펴보자
1️⃣ 변수 및 플래그 설정
EMPTY_FLAG 는 플래그 기본 값,
WRITE_MASK는 1부터 15번째의 비트(15개),
READ_MASK 는 16부터 31번째 비트를 사용한다(16개)
MAX_SPIN_LOCK은 스핀락 최대 시도 횟수로 5천번으로 제한해 두었다.
_flag 값은 초기 기본 플래그 값인 EMPTY_FLAG를 할당한다.
2️⃣ 함수 만들기
진입 시도하여 락을 걸어주는 WriteLock()과
진입 했을 때, 락을 풀어주는 WriteUnLock()
그리고 마찬가지로 ReadLock()과 ReadUnLock()을 껍데기만 일단 만들어 두자.
// 재귀적 락을 허용할 지(No)
// 스핀락 정책 (5000번 -> Yield)
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
// [ Unused(1)] [WriteThreadId(15)] -> 한번에 한 스레드만 획득 가능 [ReadCount(16)]
int _flag = EMPTY_FLAG;
public void WriteLock()
{
...
}
public void WriteUnlock()
{
...
}
public void ReadLock()
{
...
}
public void ReadUnlock()
{
...
}
}
3️⃣ WriteLock 과 WriteUnLock 구현하기
👉 WriteLock
1) Thread.CurrentThread.ManagedThreadId를 통해 현재 스레드 Id를 얻고,
WRITE_MASK와의 논리 연산을 통해 desire 값으로 초기화 한다.
2) _flag의 값을 확인하면서 lock을 획득하고 있는 지 아닌지를 반복문을 돌면서 확인한다.
3) _flag 값이 EMPTY_FLAG와 동일하면 _flag값에 desire 값을 할당한다
(진입하여 lock을 획득한 다음, 반복문을 나온다. ❗ 이 때, Interlocked의 compareExchange를 활용해 값을
비교하고 초기화 하는 작업이 한 번에 이루어 지도록 한다. )
4) 5천번 이상 시도를 했을 때는, Yield()를 통해 다른 스레드에게 점유를 넘긴다.
👉 WriteUnLock
1) 쓰기가 종료되면 락을 풀어 주어야 한다.
_flag 값을 다시 초기값(EMPTY_FLAG == 0) 으로 바꾼다.
public void WriteLock()
{
// 아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다
int desire = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (Interlocked.CompareExchange(ref _flag, desire, EMPTY_FLAG) == EMPTY_FLAG)
{
return;
}
Thread.Yield();
}
}
}
public void WriteUnlock()
{
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
4️⃣ ReadLock과 ReadUnlock 구현하기
1️⃣ Readlock
1) _flag와 READ_MASK 논리 연산 값을 expected에 할당한다.
(읽을 수 있는 지 여부를 확인하기 위한 변수 설정)
2) _flag와 expected 값이 같으면, expected 값을 1 증가 (write 상태가 아님을 의미)
3) 최대 시도 횟수를 넘어가면 다른 스레드에게 Yield()를 통해 양보한다.
👉 ReadUnlock
1) 락을 풀어주기 위해 증가시켰던 값을 Interlocked 통해 감소시킨다
public void ReadLock()
{
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;
//해당 방법은 스레드 동기화가 잘 이루어지지 않는 문제 발생 -> 한번에 처리하는 코드로 바꿔야 함
/*if((_flag & WRITE_MASK) == 0)
{
_flag = _flag + 1;
return;
}*/
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag); // 1을 줄임
}
👉 재귀적 락을 허용하는 경우
재귀적 락을 허용하는 경우란 다음 경우를 말한다.
동일 스레드 내에서(Write 스레드가) 하고 Write 하는 경우 Ok
Write -> Read 하는 경우도 Ok
하지만 동일 스레드 내에서 Read -> Write하는 것은 막는다.
즉, 읽으면서 쓰는 경우는 막는 것이다.
1️⃣ WriteLock과 WriteUnlock의 달라지는 점
👉 Writelock
1) 현재 스레드 ID와 lock을 소유하고 있는 ID를 확인하여 이미 writelock 을 획득하고 있는 지 확인한다.
1-2) 획득하고 있으면, _writeCount 변수를 두어 값을 증가시킨다. (재귀적 허용)
소유하고 있지 않을 때는 기존과 같다.
👉 WriteUnlock
1) lock을 획득한 만큼(증가시킨 만큼) 풀어주어야 한다. _writeCount 의 값을 감소시킨다.
2) _writeCount가 0이 되었을 때, write을 unlock 시킨다. (초기 상태로 세팅한다.)
2️⃣ ReadLock과 ReadUnlock의 달라지는 점
1) 현재 스레드가 이미 writeLock을 획득하고 있는 지 확인한다.
2) 소유하고 있다면 _flag 값 자체를 증가시킨다. (_flag 값 자체가 ReadCount가 된다 - 공유 카운트)
소유하고 있지 않을 때는 동일하며 ReadUnlock 또한 동일하다.
최종 코드
// 재귀적 락을 허용할 지(No)
// 재귀적 락을 허용할 지(YES) WriteLock -> WriteLock OK, WriteLock->ReadLock Ok, ReadLock -> WriteLock No
// 스핀락 정책 (5000번 -> Yield)
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
// [ Unused(1)] [WriteThreadId(15)] -> 한번에 한 스레드만 획득 가능 [ReadCount(16)]
int _flag = EMPTY_FLAG;
int _writeCount = 0;
public void WriteLock()
{
//동일 스레드가 WriteLock을 이미 획득하고 있는 지 확인
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
_writeCount++;
return;
}
//아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다
int desire = (Thread.CurrentThread.ManagedThreadId << 16) && WRITE_MASK;
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (Interlocked.CompareExchange(ref _flag, desire, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
/*//시도를 해서 성공하면 return
if (_flag == EMPTY_FLAG)
_flag = desire;*/
}
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 (Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
Interlocked.Increment(ref _flag);
return;
}
//아무도 WriteLock을 획득하고 있지 않으면, ReadCount를 1 늘린다.
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;
//해당 방법은 스레드 동기화가 잘 이루어지지 않는 문제 발생 -> 한번에 처리하는 코드로 바꿔야 함
/*if((_flag & WRITE_MASK) == 0)
{
_flag = _flag + 1;
return;
}*/
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag); // 1을 줄임
}
}
메인 실행문
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.ReadLock();
count--;
_lock.ReadUnlock();
}
});
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(count);
}
⭐ 정리(재귀적 허용일 때)
WriteLock 메소드 : 스레드 Id를 확인하여 write 락을 소유하고 있는 지 확인한다.
다른 스레드는 현재 스레드가 소유권을 포기하기 전까지 대기 상태로 있어야 한다.
(소유권은 한 스레드만 획득 가능)
동일한 스레드가 소유권을 또 가지려고 할 때, 별도의 _writeCount 변수를 두어 이 값을 증가시킨다.
WriteUnlock 메소드 : _writeCount 가 증가된 만큼 감소시키고 그 값이 0이 되면 락을 해제한다.
ReadLock 메소드 : write을 소유하고 있지 않을 때, 다수의 스레드가 경합하여 공유 카운트를 올리게 한다.
동일한 스레드가 소유하고 있을 때에도 공유 카운트를 증가시켜야 한다.
ReadUnlock 메소드 : 공유 카운트의 값을 감소시켜 락을 풀어준다.
'게임 서버 > Thread' 카테고리의 다른 글
[c#/유니티] DeadLock과 SpinLock에 대해 (0) | 2022.12.08 |
---|---|
[c#/유니티] 임계영역(Critical Section)을 위한 Monitor와 Lock (0) | 2022.12.07 |
[c#/유니티] Interlocked에 대해 (2) | 2022.12.07 |
[c#/유니티] 캐시(Cache) 이론과 메모리 배리어(Memory Barrier) (2) | 2022.12.06 |
[c#/유니티] 컴파일러 최적화 (1) | 2022.12.06 |