본문 바로가기
Programming/C#

C# 멀티스레드 프로그래밍 커스텀 재귀적 락과 스핀락 정책 구현

by Dev_카페인 2024. 7. 1.
반응형

C# 멀티스레드 프로그래밍 커스텀 재귀적 락과 스핀락 정책 구현

재귀적 락과 스핀락이란?

재귀적 락

재귀적 락은 동일한 스레드가 이미 소유한 락을 다시 획득할 수 있는 락입니다. 재귀적 락이 없다면, 동일한 스레드가 중첩된 호출에서 동일한 락을 다시 획득하려 할 때 데드락이 발생할 수 있습니다.

스핀락

스핀락은 잠금을 시도하는 스레드가 일정 횟수만큼 바쁜 대기(spin) 상태를 유지하면서 잠금을 재시도하는 락입니다. 바쁜 대기 중에는 컨텍스트 스위칭이 발생하지 않으므로, 락이 곧 해제될 것으로 예상되는 경우 스핀락을 사용하면 성능을 향상시킬 수 있습니다. 스핀 횟수가 일정 수를 초과하면 Thread.Yield()를 호출하여 CPU를 양보합니다.

코드 분석

아래는 재귀적 락과 스핀락 정책을 사용하여 커스텀 락을 구현한 코드입니다:

// 재귀적 Lock을 허용할지
    // 스핀락 정책 (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)]
        // WriteThread = WriteLock 스레드가 누구인지
        // ReadCount = 읽는 카운트
        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을 획득하고 있지 않을 때, 경합에서 소유권을 얻는다.
                // ManagedThreadId를 Wirte ThreadId로 사용
                // 16비트를 밀어줌
                // 비트 연산으로 [WriteThreadId(15)] 부분만 남게 함
                int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
            while(true)
            {
                for(int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    // 시도를 해서 성공하면 return;
                    //if (_flag == EMPTY_FLAG) 이 구문은 Thread와의 경합에서 여러 쓰레드가 차지할 수 있음
                    //    _flag = desired;
                    if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
                    {
                        _writeCount = 1;
                        return;
                    }

                }

                // 양보
                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++)
                {
                    // 이 부분도 수식이 나눠져 있어서 코드가 엉킨다.
                    //if((_flag & WRITE_MASK) == 0)
                    //{
                    //    _flag = _flag + 1;
                    //    return;
                    //}
                    int expected = (_flag & READ_MASK);
                    if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
                        return;
                }
                // 양보
                Thread.Yield();
            }
        }
        public void ReadUnlock() 
        {
            Interlocked.Decrement(ref _flag);
        }
    }

    class Program
    {

        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 < 10000; i++)
                {
                    _lock.WriteLock();
                    _lock.WriteLock();
                    count++;
                    _lock.WriteUnlock();
                    _lock.WriteUnlock();
                }
            });
            Task t2 = new Task(delegate ()
            {
                for(int i = 0; i < 10000; i++)
                {
                    _lock.WriteLock();
                    count--;
                    _lock.WriteUnlock();
                }
            });

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(count);
        }
    }

코드 설명

1. 락 클래스의 플래그 정의

락 클래스는 AutoResetEvent 대신 플래그를 사용하여 락의 상태를 추적합니다. EMPTY_FLAG는 락이 비어있는 상태를 나타내며, WRITE_MASK와 READ_MASK는 쓰기 및 읽기 상태를 나타냅니다.

2. 재귀적 WriteLock 구현

WriteLock 메서드는 현재 스레드가 이미 쓰기 락을 소유하고 있는지 확인합니다. 이미 소유하고 있다면 _writeCount를 증가시키고, 그렇지 않다면 스핀락을 통해 락을 획득하려고 시도합니다. 성공하면 _writeCount를 1로 설정합니다.

3. WriteUnlock 구현

WriteUnlock 메서드는 _writeCount를 감소시키고, 만약 0이 되면 플래그를 초기 상태로 바꿉니다.

4. 재귀적 ReadLock 구현

ReadLock 메서드는 현재 스레드가 이미 쓰기 락을 소유하고 있는지 확인합니다. 이미 소유하고 있다면 _flag를 증가시키고, 그렇지 않다면 스핀락을 통해 읽기 락을 획득하려고 시도합니다.

5. ReadUnlock 구현

ReadUnlock 메서드는 _flag를 감소시킵니다.

이론적인 배경

1. 임계영역과 상호배제

임계영역은 동시에 접근하면 안 되는 공유 자원을 보호하기 위해 사용하는 코드 영역입니다. 상호배제는 임계영역에 하나의 스레드만 접근할 수 있도록 하는 기법입니다.

2. 락 (Lock)

락은 상호배제를 구현하는 가장 일반적인 방법입니다. C#에서는 lock 키워드나 Monitor 클래스를 사용하여 락을 구현할 수 있습니다.

3. 스핀락 (SpinLock)

스핀락은 잠금을 시도하는 동안 스레드가 대기 상태로 전환되지 않고, 일정 횟수만큼 바쁜 대기 상태를 유지하면서 반복적으로 락을 시도합니다. 스핀 횟수를 초과하면 Thread.Yield()를 호출하여 CPU를 양보합니다.

4. 데드락 (Deadlock)

데드락은 두 개 이상의 스레드가 서로 자원을 점유한 채 상대방의 자원을 기다리면서 무한 대기 상태에 빠지는 문제입니다. 데드락을 방지하기 위해 락을 획득하는 순서를 정하는 등의 기법을 사용할 수 있습니다.

5. volatile 키워드

volatile 키워드는 변수의 값이 여러 스레드에 의해 변경될 수 있음을 컴파일러에게 알려줍니다. 이를 통해 변수의 값이 캐시되지 않고 항상 메모리에서 읽히도록 보장합니다.

결론

이번 포스트에서는 재귀적 락과 스핀락 정책을 사용한 커스텀 락 구현에 대해 살펴보았습니다. 멀티스레드 환경에서 락을 적절히 사용하여 데이터 무결성을 유지하면서도 성능을 향상시킬 수 있습니다. 특히, 재귀적 락과 스핀락은 특정 상황에서 매우 유용하게 사용될 수 있습니다. 예제 코드를 통해 락의 동작 방식을 이해하고, 실제 프로젝트에 적용해 보시길 바랍니다.

반응형