자금까지 배운 Lock은 구현이나 속도면에서 차이가 있지만 기본적인 원리는 상호배제(한 번에 하나만 들여보내겠다는 의미)이다. 하지만 경우에 따라 다른 형태의 Lock이 필요할 수도 있다.
예를들어 99.9프로의 확률로 안바뀌는 값이 있다고 하면 0.1프로의 확률때문에 스레드가 값에 접근할때마다 Lock을 걸어 상호배제하는 것은 아쉽다. 그래서 값을 단순히 읽기만 할때는 "나 읽을거야"만 알려주고 다 읽으면 "다 읽어서"만 알려주고 자유롭게 값을 읽게 하고 값을 바꿀때만 상호 배제하면 효율적이라는 생각이 든다. 즉 Read할때는 자유롭게 Write할때는 막는 것이다.
이것은 ReaderWriterLock으로 할 수 있다.
class Reward // 보상과 관련된 클래스
{
}
// Slim이 붙은 것이 최신이다.
static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 99.9프로
static Reward GetRewardById(int id)
{
// 아무도 _lock을 잡고 있지 않으면 동시다발적으로 여러 스레드가 들어올 수 있다.
_lock.EnterReadLock();
// 작업
_lock.ExitReadLock();
return null;
}
// 이 함수는 0.01확률로 호출(보상이 추가될때)
void AddReWard(Reward reward)
{
_lock.EnterWriteLock();
// 작업
_lock.ExitWriteLock();
}
다음 강의에서 더 설명
코드 보기 전에 알아야 할 것
const int EMPTY_FLAG = 0x00000000; const int WRITE_MASK = 0x7FFF0000; const int READ_MASK = 0x0000FFFF; const int MAX_SPIN_COUNT = 5000;구현 코드를 보면 이런 부분이 있을 것이다. F가 의미하는 바로 4개가 연속적으로 1인 것이다.
const int WRITE_MASK = 0x7FFF0000; 를 그림으로 표현하면 아래와 같다.
따라서 수와 비트연산을 했을때 마스크로 작용할 수 있다.
먼저 Write부분의 코드이다.
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000; // 락은 얻는 시도는 5000번하고 잠시 쉬다 온다.
int _flag = EMPTY_FLAG;
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++)
{
// 시도해서 성공하면 return
if (Interlocked.CompareExchange(ref _flag, desire, EMPTY_FLAG) == EMPTY_FLAG)
return;
// 이렇게 하면 안됨 2단계로 분리되어 있기 때문
//if (_flag == EMPTY_FLAG)
// _flag = desire;
}
Thread.Yield(); // 잠시 쉬다 온다.
}
}
public void WriteUnlock()
{
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
_flag
해당 변수는 32비트이다. 첫번째 비트는 사용하지 않을 것이고 15개 비트는 WriteLock을 얻은 스레드의 ID를 저장하고 16비트는 현재 ReadLock을 얻은 스레드가 몇개 인지 저장한다. WriteLock을 얻은 스레드를 지금은 굳이 저장할 필요는 없지만 재귀적락을 허용한다면 필요하다. (재귀적락은 밑에 구현할 것)
(Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK
현재 스레드의 Id를 가져와서 왼쪽으로 16번 비트를 민다. 맨 뒤 16개 비트는 ReadLock을 획득한 스레드의 수를 의미하므로 밀어야하한다. 그리고 0x7FFF0000 마스크를 씌워 스레드 ID 구역이 아닌 비트는 0으로 만든다.
Interlocked.CompareExchange(ref _flag, desire, EMPTY_FLAG) == EMPTY_FLAG
동시다발적으로 여러 스레드가 들어온다고 하면 스레드마다 스레드 ID는 다르고 분명리 CompareExchange함수를 먼저 실행하는 스레드가 존재한다. 먼저 실행한 스레드는 _flag의 값을 자신의 스레드ID로 바꾼다. 이렇게 되면 그 스레드는 WriteLock을 얻게 되는 것이고 그 후에 실행하는 스레드들은 _flag의 값과 EMPTY_FLAG의 값이 같지 않아 계속 반복문을 돌게 된다.
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
이부분은 사실 _flag = EMPTY_FLAG로 해도 된다.
다음은 Read부분의 코드이다.
public void ReadLock()
{
// 아무도 WriteLock을 획득하고 있지 않으면 ReadCount를 하나 늘린다.
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// 이게 Lock Free 프로그래밍의 기초이다.
int expected = (_flag & READ_MASK);
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
return;
// 이렇게 하면 안됨 if를 통과해서 늘릴려고 하기 전에 누군가 WriteLock을 잡으면 문제
//if((_flag & WRITE_MASK) == 0)
//{
// _flag = _flag + 1;
// return;
//}
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
int expected = (_flag & READ_MASK)
CompareExchange를 하기전에 현재 _flaf값에서 READ_MASK를 적용해 지금 ReadLock을 얻은 스레드가 몇개인지 구한다.
Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected
전 코드에서 구한 현재 LeadLock을 얻은 스레드릐 수(expected)와 _flag의 값을 비교한다. 여기서 expected의 WriteLock을 얻은 스레드 Id를 저장하는 구역은 0으로 되어 있다. 따라서 WriteLock을 얻은 스레드가 있다면 조건을 만족하지 못하고 계속 반복문을 돌게 된다. WriteLock을 얻은 스레드를 확인하는 것과 ReadLock을 얻은 스레드의 수를 늘리는 것을 동시에 해야하기때문에 위 코드처럼 작성해야한다. 동시다발적으로 읽으려고 하는 스레드가 들온다면 전 코드에서 이미 expected로 받아 뒀기때문에 먼저 CompareExchange함수를 들어간 스레드가 _flag의 값을 바꿔 실패하고 또 반복문을 돌게 된다.
이렇게 뺑뺑이를 돌면서 될때까지 시도하는 것을 Lock Free 프로그래밍의 기본이라고 할 수 있다.
Lock을 만들때는 몇 가지 정책을 정해야한다.
- 재귀적 Lock을 허용할 것인가?
- Spin Lock 정책은 어떻게 할 것인가?
위 코드는 재귀적 Lock을 허용하지 않았고 Spin Lock은 5000번까지 돌고 Yield하는 방식으로 구현한 것이다. 재귀적lock은 writeLock을 획득한 스레드가 동시에 또 writeLock을 획득하거나 readLock을 획득하는 경우이다.
재귀적 Lock을 허용한 코드는 다음과 같다.
int _writeCount = 0; // 재귀적으로 몇개의 라이트락을 잡았는지 카운팅
먼저 _writeCount변수를 추가해준다. 다음은 write부분이다.
단, WriteLock을 하고 ReadLock을 했다면 풀어주는 순서는 ReadUnlock을 하고 WriteUnlock을 해야한다.