ReadWriteLock은 여러 쓰레드가 동시에 읽는것(Read)는 가능하지만 쓰는것(Write)은 하나의 쓰레드만 가능하도록하는 Lock기법이다.
ReadWriteLock은 당연히 쓰기 작업보다는 동시 읽기 작업이 많은 상황에 유리하고 쓰기작업에는 하나의 쓰레드만 가능하도록 하기 때문에 데이터의 무결성을 쉽게 보장할 수 있다.
ReadWriteLock을 구현하기 전에 정해야할 것들이 있다. 예를들면, 재귀 Lock에 대한 허용이나 Spin횟수와 같은 Lock규칙을 정해야한다.
Lock Class를 구현하기 위한 구조는 아래와 같다. 현재 Lock의 상태는 _flag
정수형 변수로 관리할 것이며 WriteLock을 가지고 있는 쓰레드가 재귀Lock을 했는지 Count하기 위한 _writeCount
변수도 존재한다. _flag
의 각 비트는 [Unused1][WriteThreadId(15)][ReadCount(16)]
로 사용할 예정이다.
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()
{
}
public void WriteUnlock()
{
}
public void ReadLock()
{
}
public void ReadUnlock()
{
}
}
ReadLock과 WriteLock을 동시에 제공하기 때문에 Lock
, Unlock
에 관한 함수는 4개이며 다음과 같은 Lock규칙에 따라 구현해야 한다.
재귀Lock
은 허용한다.MAX_SPIN_COUNT
까지 시도하고 Lock을 얻지 못한다면 해당 쓰레드는 Yield
한다.Lock Class에는 현재 상태를 의미하는 _flag
외에 구현을 더 쉽게하기 위한 몇가지 local 상수가 4개가 있는데 다음과 같은 상수가 있다.
0x00000000
이다.5000
이다.C#에서 현재 쓰레드의 ID는
Thread.CurrentThread.ManagedThreadId
로 가져올 수 있다.
WriteLock을 얻기위해 쓰레드가 실행해야 할 함수로 재귀Lock인지 확인한 후 Lock을 얻는것을 시도한다. 구현된 코드는 아래와 같다.
public void WriteLock()
{
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if(Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
_writeCount++;
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();
}
}
WriteLock의 단계는 크게 두 단계로 이루어져 있는데 첫번째는 재귀Lock을 확인하는 단계이고 두번째는 다른 쓰레드의 Lock여부와 Lock할당을 동시에 하는 부분이다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if(Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
_writeCount++;
return;
}
현재 상태를 의미하는 _flag
는 int형으로 32비트로 이루어져 있다. 0번 비트는 사용하지 않고, 1~16번 비트는 현재 WriteLock을 가지고 있는 ThreadID를 의미하며 17~32번 비트는 ReadLock을 가지고 있는 쓰레드의 수를 의미한다.
재귀Lock을 판별하기 위해서 _flag
의 ThreadID와 현재 WriteLock에 접근한 ThreadID를 비교해줄 필요가 있다. 만약, 같다면 현재 Thread가 WriteLock을 가지고 있고 재귀Lock을 시도한것이기 때문에 _writeCount
를 늘려주면 된다.
WriteLock을 할당하는 과정을 나눈다면 다음과 같이 나눌 수 있다.
_flag
에 반영하고 _writeCount
를 1
로 설정한다.두 부분을 코드로 구현하면 다음과 같다. desired
는 현재 ThreadID를 16비트 왼쪽쉬프트 한 뒤 WRITE_MASK
와 AND연산을 함으로 형식에 맞게 필터링 해준 값이다.
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();
}
하지만, 위 코드로 구현하게될 경우 제대로된 상호배제를 보장해줄 수 없다. 그 이유는 비교
와 대입
을 한번에 해주지 않았기 때문인데 이부분은 SpinLock을 구현할 때 다뤘던 부분이다.
이런 문제를 해결하기 위해 Interlocked
를 사용한다. _flag
는 int형이기 때문에 각종 Exchange
함수를 이용해 비교와 대입을 한번에 할 수 있다. 이러한 내용을 반영한 최종 코드는 다음과 같다.
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();
}
WriteUnlock은 WriteLock을 가진 쓰레드가 호출하는 함수로 현재 가지고 있는 Lock을 반환하는 함수이다. 위의 WriteLock함수에서 구현내용으로인해 WriteUnlock이 호출될 때는 다음과 같은 내용을 확신할 수 있다.
_writeCount
만큼 재귀Lock을 하였다.위 내용을 구현한 코드는 다음과 같다. LockCount를 줄인 후, 줄인 Count가 0이라면 _flag
의 값을 초기값으로 설정한다.
public void WriteUnlock()
{
int lockCount = --_writeCount;
if (lockCount == 0)
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
값을
EMPTY_FLAG
로 해주는 이유는WriteUnlock
을 호출한 결과는 어떤 쓰레드도 Lock을 가지고 있지 않은 상태이기 때문이다.
ReadLock은 WriteLock과 비슷하다 과정역시 재귀Lock을 확인한 후 Lock을 얻는 것을 시도한다. 구현된 코드는 아래와 같다.
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();
}
}
ReadLock역시 크게 두 부분으로 나눌 수 있는데 재귀Lock을 확인하는 단계와 다른 쓰레드의 Lock여부와 Lock증가를 동시에 하는 부분이다.
ReadLock의 재귀확인은 WriteLock과 크게 다른 부분이 없다. 재귀판별하는 부분은 동일하며 재귀일 경우 _writeCount
를 늘리는 것이 아닌 _flag
를 1
늘림으로 _flag
의 ReadCount를 증가시킨다.
int lockThreadId = (_flag & WRITE_MASK) >> 16;
if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
Interlocked.Increment(ref _flag);
return;
}
ReadLock은 WriteLock
을 가지고 있는 쓰레드가 없을때 ReadLock
을 얻을 수 있다. 이 내용은 다음과 같은 단계로 나눌 수 있다.
_flag
의 ThreadID를 확인한다.WriteLock
을 가진 쓰레드가 없다면) ReadCount를 1
증가시킨다.위 내용을 구현한 코드는 다음과 같다.
if((_flag & WRITE_MASK) == 0)
{
_flag += 1;
return;
}
하지만, 위 코드로 구현했을 때 다음과 같은 문제가 생길 수 있다.
WriteLock
을 ThreadB는 ReadLock
을 얻고자 한다.WriteLock
, ReadLock
을 호출한다._flag
의 ThreadID가 0임을 확인한다._flag
가 EMPTY_FLAG
임을 확인하고 WriteLock
을 할당받는다._flag
의 ReadCount를 1
증가시킨다.위의 문제상황은 ThreadA와 ThreadB가 각각 WriteLock
, 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;
}
Thread.Yield();
}
Interlocked
를 통해 비교와 변경을 동시에 하는 것은 WriteLock
과 동일하지만 ThreadID를 구하는 부분이 사라진 것을 알 수 있다. 이유는 expected
변수를 설정할 때 READ_MASK
와 AND연산을 하는데 이 과정에서 expect
의 ThreadID는 0
으로 설정되기 때문이다. 즉, expect
변수는 ThreadID가 0인 상태의 _flag
를 나타내는 것이다.
expect
와의 CompareExchange로 비교와 할당을 동시에 진행하게 되면 다른 쓰레드에서 WriteLock
을 요청해도 ReadLock
을 제한할 수 있다.
ReadUnlock의 구현은 매우 간단하다. Interlocked.Decrement
를 사용하면 된다. 재귀Lock을 하여도 WriteLock
과 달리 다른 초기화 설정이 필요없기 때문에 -1
만 해주면 된다.
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
ReadWriteLock은 SpinLock과 마찬가지로 C#의 System.Threading 산하에 ReadWriteLock이 구현되어 있다. 원래의 버전은 ReaderWriterLock
이라는 이름으로 구현되어 있었지만 최신 버전에는 ReaderWriterLockSlim
을 사용하면 된다.
Threading.ReaderWriterLockSlim의 기본인터페이스는 Enter
와 Exit
이 있는데 Reader, Writer에 각각 Enter
, Exit
가 존재한다.