[C# 서버] ReadWriteLock

이정석·2023년 8월 3일
0

CSharpServer

목록 보기
4/13

ReadWriteLock

ReadWriteLock은 여러 쓰레드가 동시에 읽는것(Read)는 가능하지만 쓰는것(Write)은 하나의 쓰레드만 가능하도록하는 Lock기법이다.

ReadWriteLock은 당연히 쓰기 작업보다는 동시 읽기 작업이 많은 상황에 유리하고 쓰기작업에는 하나의 쓰레드만 가능하도록 하기 때문에 데이터의 무결성을 쉽게 보장할 수 있다.

1. Lock Class

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규칙에 따라 구현해야 한다.

  1. 재귀Lock은 허용한다.
  2. MAX_SPIN_COUNT까지 시도하고 Lock을 얻지 못한다면 해당 쓰레드는 Yield한다.

Lock Class에는 현재 상태를 의미하는 _flag외에 구현을 더 쉽게하기 위한 몇가지 local 상수가 4개가 있는데 다음과 같은 상수가 있다.

  1. EMPTY_FLAG: 누구도 Lock을 가지고 있지 않음을 의미하는 Flag 상수로 값은 0x00000000이다.
  2. WRITE_MASK: Flag의 비트중 WriteLock을 가지고 있는 쓰레드의 ID를 추출하기 위한 MASK상수로 해당 MASK를 Flag와 AND연산하면 현재 Lock을 가지고 있는 쓰레드의 ID를 알 수 있다.
  3. READ_MASK: Flag의 비트중 ReadCount부분을 추출하기 위한 MASK상수로 Flag와 AND연산하면 현재 ReadLock을 가지고 있는 쓰레드의 개수를 알 수 있다.
  4. MAX_SPIN_COUNT: 최대 SPIN횟수를 저장하기 위한 상수로 현재 값은 5000이다.

C#에서 현재 쓰레드의 ID는 Thread.CurrentThread.ManagedThreadId로 가져올 수 있다.

2. WriteLock

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할당을 동시에 하는 부분이다.

(1) 재귀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를 늘려주면 된다.

(2) WriteLock 할당

WriteLock을 할당하는 과정을 나눈다면 다음과 같이 나눌 수 있다.

  1. 다른 쓰레드가 Lock을 가지고 있는지 확인한다.
  2. 아무도 가지고 있지 않는다면 현재 쓰레드의 ThreadID를 _flag에 반영하고 _writeCount1로 설정한다.

두 부분을 코드로 구현하면 다음과 같다. 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();
    }

3. WriteUnlock

WriteUnlock은 WriteLock을 가진 쓰레드가 호출하는 함수로 현재 가지고 있는 Lock을 반환하는 함수이다. 위의 WriteLock함수에서 구현내용으로인해 WriteUnlock이 호출될 때는 다음과 같은 내용을 확신할 수 있다.

  • WriteUnlock을 호출한 쓰레드를 제외하고 WriteLock, ReadLock을 가진 쓰레드는 존재하지 않는다.
  • 해당 쓰레드가 WriteLock을 가지고 있으며 _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을 가지고 있지 않은 상태이기 때문이다.

4. ReadLock

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증가를 동시에 하는 부분이다.

(1) 재귀Lock 확인

ReadLock의 재귀확인은 WriteLock과 크게 다른 부분이 없다. 재귀판별하는 부분은 동일하며 재귀일 경우 _writeCount를 늘리는 것이 아닌 _flag1늘림으로 _flag의 ReadCount를 증가시킨다.

    int lockThreadId = (_flag & WRITE_MASK) >> 16;
    if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
    {
        Interlocked.Increment(ref _flag);
        return;
    }

(2) WriteLock 할당

ReadLock은 WriteLock을 가지고 있는 쓰레드가 없을때 ReadLock을 얻을 수 있다. 이 내용은 다음과 같은 단계로 나눌 수 있다.

  1. _flag의 ThreadID를 확인한다.
  2. ThreadID가 0이라면(WriteLock을 가진 쓰레드가 없다면) ReadCount를 1증가시킨다.

위 내용을 구현한 코드는 다음과 같다.

    if((_flag & WRITE_MASK) == 0)
    {
        _flag += 1;
        return;
    }

하지만, 위 코드로 구현했을 때 다음과 같은 문제가 생길 수 있다.

  1. ThreadA는 WriteLock을 ThreadB는 ReadLock을 얻고자 한다.
  2. 두 쓰레드가 동시에 WriteLock, ReadLock을 호출한다.
  3. ThreadB는 _flag의 ThreadID가 0임을 확인한다.
  4. ThreadA는 _flagEMPTY_FLAG임을 확인하고 WriteLock을 할당받는다.
  5. ThreadB는 _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을 제한할 수 있다.

5. ReadUnlock

ReadUnlock의 구현은 매우 간단하다. Interlocked.Decrement를 사용하면 된다. 재귀Lock을 하여도 WriteLock과 달리 다른 초기화 설정이 필요없기 때문에 -1만 해주면 된다.

    public void ReadUnlock()
    {
        Interlocked.Decrement(ref _flag);
    }

System.Threading.ReaderWriterLockSlim

ReadWriteLock은 SpinLock과 마찬가지로 C#의 System.Threading 산하에 ReadWriteLock이 구현되어 있다. 원래의 버전은 ReaderWriterLock이라는 이름으로 구현되어 있었지만 최신 버전에는 ReaderWriterLockSlim을 사용하면 된다.

Threading.ReaderWriterLockSlim의 기본인터페이스는 EnterExit이 있는데 Reader, Writer에 각각 Enter, Exit가 존재한다.

  • EnterReadLock(), EnterWriteLock()
  • ExitReadLock(), ExitWriteLock()
profile
게임 개발자가 되고 싶은 한 소?년

0개의 댓글