읽을(Read) 때는 자유롭지만, 쓸(Write) 때는 lock을 통해 작동할 수 있는 상황에서 사용되는 ReaderWriterLock의 개념에 대해 배웠고, 실전에 적용해볼 시간이다.
이번 내용은 익숙하지 않은 개념들이 많아 스스로 공부하기 위한 부연 설명이 많아 스압임당.
우선 정책에 대한 정의가 필요하다.
Yeild()
)재귀적 허용은 동일한 쓰레드가 계속 lock을 획득하는 경우 처리를 어떻게 할 것인가에 대한 설정이다. 일단 쉽게 하기 위해 허용하지 않고 진행한다.
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
/* WriteThread[15] 한 번에 한 쓰레드만 획득가능.
그 쓰레드가 누군지 확인 가능
ReadCount[16] ReadLock을 여러 쓰레드가 획득할 수
있기에 카운트하기 위함*/
int flag;
비트 공간 할당
아직 비트로 공간을 할당하여 사용한적은 없어 낯선 개념이라 따로 정리를 해본다.
우선 int는 32 비트의 메모리를 할당하기에 32개의 공간이 있다. 그러면 그 중에 일부는 ThreadID, 또 나머지는 ReadCount의 영역으로 활용하는 것이다.
사용하고자 하는 수의 크기가 작다면 int라는 하나의 변수로 아낌없이 사용할 수 있고, 동기화 작업에 유리하다. 그림으로 표현하자면 아래와 같다.
const
이 또한 많이 접해보지 않아 개념을 정리한다. Const 는 변하지 않는 수. 상수를 뜻한다. 선언 시 초기화 하고 변하지 않으며 Static 영역에 자동으로 할당된다.
public void WriteLock()
{
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
while (true)
{
for(int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (flag == EMPTY_FLAG)
flag = desired;
}
Thread.Yield();
}
}
하나 하나 정리해보자, 현재 쓰레드가 몇인지 알기 위해 Thread.CurrentThread.ManagedThreadId
를 사용해 불러온다.
이걸 아까 정해둔 ThreadID의 공간으로 '<< 16' 밀어준 뒤, WRITE_MASK로 선언한 값과 비트연산(&)을 해주면 ThreadID의 값만 남게 된다.
좀 더 자세하게 계산하면 WRITE_MASK는 16-31비트 까지는 모두 1로 처리 되어있고, Thread ID는 몇일지는 모르지만 그 크기만큼 공간을 할 당할 것이다.
그때 위아래 모두 1일 때만 남기는 AND(&)연산을 통해 결과적으로 desire에 Thread ID의 값만 남기는 작업을 해주는 것이다.
for(int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (flag == EMPTY_FLAG)
flag = desired;
}
정책에서 선언 한 대로 5,000번의 스핀락을 돌게 하고, flag를 아무도 획득하지 않았다면, 이름을 넣어준다. 다만 이렇게 두 단계로 동작할 경우 멀리 쓰레드의 오류가 생길 수 있기에 상호배제를 해줘야한다.
전에 배웠던 Interlocked.CompareExchange를 사용해서, 기존 flag, EMPTY_FLAG를 비교하여 둘이 값이 같다면 desired(ThreadID) 값을 넣어준다.
Interlocked.CompareExchange()는 변경되기 전의 값을 반환 해주니, 그 값을 EMPTY_FLAG와 비교하여 return을 해주는 것이다.
결국 아직 획득하지 않은 상황을 검사하고, 값을 넣어주고, return까지 처리가 된 상황이다.
WriteUnlock은 비교적 간단하다. flag 값을 EMPTY로 변경해주어 획득하지 않았다는 걸 알려준다.
flag에 들어있는 값과 WRITE_MASK에 들어있는 값을 연산할 때, 아무도 WriteLock을 잡은적이 없으면 flag는 0이기에 WRITE_MASK와 AND 연산 시 값이 0이 되기에 이 조건을 통과하면 flag의 카운트를 증가 시킨다.
단, 이렇게 단계를 두어 작업을 할 경우 조건에 통과 했을 때 다른 쓰레드가 WriteLock을 잡고 flag에 ID를 써놓고, 여기에 flag+1을 해주며 잘못된 상황이 생길 우려가 있다.
우선 flag에 아무것도 담겨 있지 않는 상태일 경우 작업을 해주어야 하기에, 원하는 상황대로 expected에 flag와 Read Mask를 연산하여 Read의 공간만 살린다.
그뒤 Interlock.CompareExchange를 통해 원하는 상황일 경우 +1을 해줌으로 Read Count를 증가시킬 수 있다.
여기서 중요한 포인트
Writelock을 잡고있는 경우의 flag 값과, expected이 값은 같을 수 없기에 통과 할 수 없으며
Readlock을 여러 스레드가 동시에 접근을 하더라도, 먼저 들어온 스레드가 expected+1을 해버리면서 동시에 Read를 막 올려버리는 상황이 생기지는 않는다.
이 경우를 위해 복잡해보이는 비트 할당 구조를 사용했는데, 재귀적 허용을 한다는건 Writelock을 여러번 획득하고, 해제 하거나 Writelock을 잡고 Readlock을 허용한다는 의미이다.
write_Count 변수는 Writelock을 잡을 때만 관리 하기에 멀티쓰레드에 대한 걱정없이 변수로 설정하여 사용해도 된다.
우선 flag가 가진 쓰레드 ID와 현재 쓰레드 ID가 동일하다면 증가해주어 개수를 체크하고, writelock을 새로 잡는 시점에서는 다시 1로 초기화 해주어 동일 쓰레드가 몇개 잡는지를 확인하는 구조이다.
WriteUnLock()에서는 write_Count를 감소시키고, 0일 때만 초기화 할 수 있게 설정해둔다. 즉, Writelock을 두번 잡았을 경우에는 두 번 모두 해제 해주어야만 하게 설계를한다.
Readlock에서는 동일한 쓰레드 일 경우 Read Count만 증가시키면 되기에 위와 같은 코드를 작성한다.
여기서 주의사항은 Writelock -> Readlock 순서로 잡았다면 해제 할 때는 반드시 Readlock -> Writelock 순서로 해제하여야한다. 생성자 소멸자랑 뭔가 비슷한 거 같은데 아니면 무한루프에 빠지게 된다.
작성하고, 테스트를 해보면 정상적으로 구동하는 걸 확인할 수 있다.
하얗게 불태웠다.. 낯선 개념들이 나오니 뒤에 내용도 이해할 수 없어 시간이 오래 걸렸다. 특히나 비트 계산 부분이 어려워 한참을 돌려봤다.
그래도 여러번 돌려보며 결국엔 이해했으니, 나중에 더 수월하게 구조를 만들고, 문제의 원인을 찾고 해결할 수 있을 것 같다. 굳