공부/인프런 - Rookiss

Part 4-2-10. 멀티쓰레드 프로그래밍 : SpinLock

셩잇님 2023. 9. 13. 16:50
반응형

 

 

멀티 쓰레드

 

SpinLock

 

 락 구현 이론을 바탕으로 스핀 락을 실제로 구현해보자. 또한 스핀 락의 개념은 매우 중요해서 면접에서 멀티쓰레드 프로그래밍을 경험했다고 하면 0순위로 물어본다고 하니, 이번 기회에 확실하게 알고 넘어가자. 😎

 

using System;
using System.Threading;
using System.Threading.Tasks;

namespace CSharp
{
    class SpinLock
    {
        // 상태
        // true = 사용하는 중(잠금)
        // false = 사용하지 않는 중(미잠금)
        volatile bool _locked = false;

        // 획득
        public void Acquire()
        {
            while (_locked)
            {
            	// 기다리는 중... 😩
            }

            _locked = true;
        }

        // 반환
        public void Release()
        {
            _locked = 0;
        }
    }

    class Program
    {
        static int _num = 0;
        static SpinLock _lock = new SpinLock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

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

            Task.WaitAll(t1, t2);
            Console.WriteLine(_num);
        }
    }
}

 

 위 소스코드를 실행하면 우리의 예상값인 0이 아닌 또 괴랄한 값이 나타난다. 또 뭐가 문제여서 이 지랄을 하는 걸까.. 🤦‍♂️ PPT를 통해 무엇이 잘못되었는지 확인해보자.  

 

 

 위 이미지를 보면 사람 A, B 둘 다 화장실이 너무 급해서 동시에 화장실로 들어가버린 상황이 된 것이다. 그러나 사람 A, B 모두 누가 들어왔던지, 말던지 신경쓰지 않고 서로 사용하기 위해 화장실에 들어와 문을 잠구고 서로 문을 잠궈서 사용할 수 있다고 기뻐하는 상황이 되는 것이다. 즉 1인 화장실에 2명이 동시에 들어가놓고 누가 같이 들어온 것은 신경쓰지도 않은 채, 서로 문을 잠군 것이다. 그렇다면 왜 이런 상황이 발생했을까?

 

 답은 쉽다. 원자성을 지켜주지 않았기 때문에 이러한 일이 발생한 것이다. 

 

class SpinLock
    {
        // 획득
        public void Acquire()
        {
            while (_locked)
            {
            	// 기다리는 중... 😩
            }

            _locked = true;
        }

 

 코드를 다시한번 살펴보자. 스핀 락 클래스를 살펴보면 동작이 이전에 number++ 해주던 것과 같이 두 가지의 일로 나뉘어져서 그런 것이다. 즉 while문의 동작이 실행되고, _locked = true;를 시켜주는 동작이 서로 달리 동작되어 원자성이 지켜지지 않아 문제가 되는 것이다. 이 부분을 하나의 동작으로 한 번에 처리해야 하는 것이다. 

 

 비유를 통해 쉽게 이해하자면 화장실 안에 들어간다음 열쇠를 잠구는 것이 하나의 행동으로 이뤄져야 한다는 것이다. 들어간 다음 → 문을 잠구자!의 개념이 아니라는 것이다. 따라서 멀티 쓰레드 환경에서는 원자적으로 동작하는 것이 필요하다.

 

 그렇다면 어떻게 처리해줘야 정상적으로 작동할까? 우리는 지난 시간에서 Interlocked 메서드를 사용했다. 이번에도 해당 메서드를 통해 해결하자. Interlocked.Exchange 메서드를 이용하면 해결 할 수 있다.

 

        public void Acquire()
        {
            //while (_locked)
            //{
            //}
            //_locked = true;
            // ❌

            while (true)
            {
                int original = Interlocked.Exchange(ref _locked, 1);
                if (original == 0)
                    break;
            }
            // ⭕
        }

 

  사용하고 있는 Exchange 메서드 중 해당 인자 값을 사용하는 함수 내부를 살펴보면 아래와 같다.

 

        //
        // 요약:
        //     원자 단위 연산으로 부호 있는 64비트 정수를 지정된 값으로 설정하고 원래 값을 반환합니다.
        //
        // 매개 변수:
        //   location1:
        //     지정된 값으로 설정할 변수입니다.
        //
        //   value:
        //     location1 매개 변수의 설정 값입니다.
        //
        // 반환 값:
        //     location1의 원래 값입니다.
        //
        // 예외:
        //   T:System.NullReferenceException:
        //     location1의 주소는 null 포인터입니다.

  

 뭔 소리인가 싶지만, 하나하나씩 풀어보자. Exchange 메서드는 ref로 전달되는 _locked 값에 '1'이라는 값을 넣어주는 행위를 한다. 이 때 넣어 주기 이전의 값을 뱉을 수 있는데 이를 소스 코드에서는 'original'로 뱉어준다. 따라서 넣어 주기 이전의 값을 이용하여 체크를 진행해 스핀 락을 구현하는 것이다.

 

 즉 위 소스코드를 싱글 스레드 기준으로 생각하면 아래 소스코드와 같다.

 

            {
                int original = _locked;
                _locked = 1;
                if (original == 0)
                    break;
            }

 

 근데 여기서 또 의문이 든다. 이전 시간에서 우리는 _locked 변수는 static을 이용해 공유하여 사용하고 있기 때문에 멋대로 읽어서 사용할 경우 다른 스레드가 접근하여 값을 변경한다고 알고 있다. 근데 여기서는 값을 뱉어준 뒤 if문을 이용하여 비교하는 것을 볼 수 있다. 하.. 또 이건 왜 되는 것일까? 🤬

 

 이는 _locked가 병합하지 안흔 하나의 스레드의 스택에 존재한 메모리이기 때문이다. 따라서 멀티쓰레드 프로그래밍을 시작하면 해당 값이 안전한 값인지, 아닌지 파악하는 능력이 필요하다.

 

 


 

보다 가시성 있게 읽어볼 순 없을까?

 

            while (true)
            {
                int original = Interlocked.Exchange(ref _locked, 1);
                if (original == 0)
                    break;
            }
            // ⭕

 

 위 소스코드를 보면 우리가 하고 싶은 행동은 결국 아래와 같다.

 

                    if (_locked == 0)
                        _locked = 1;

 

따라서 보다 가시성있게 수정하는 방법이 있다. 마찬가지로 Interlocked 클래스 내부에 있는 CompareExchange 메서드를 사용하면 된다. 하이고.. 🤦‍♂️

 

                int original = Interlocked.CompareExchange(ref _locked, 1, 0);
                // 무슨 값을 어떻게 왜 비교하는지 알기 어렵다.. 🤷‍♂️
                
                if (original == 0)
                    break;

 

근데 딱 봐도 코드가 무엇을 비교하는지 명확하기 알게 어렵다. 따라서 루키스님은 C++에서 사용하는 방법을 이용해 깔끔하게 구현하길 추천한다. 위의 소스코드를 최적화하면 아래와 같다.

 

                // expected = 예상 하는 값이 무엇이냐
                // desired = 내가 원하는 값은 무엇이냐
                int expected = 0;
                int desired = 1;
                
                // CAS(Compare-And-Swap)라고 한다.
                if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                    break;

 

 

 근데 문득 의아했다. 이렇게 되면 expected와 desired는 또 원자성이 보장이 되는 걸 어떻게 보장하는가? 나와 같은 사람들이 있나보다. 질문을 했는데 CAS류 함수와 사용하면 문제가 없다고 한다.

 

정말 알다가도 모르겠는 멀티쓰레드 프로그래밍 환경! 👍

 

 

🤬

 

 

 

반응형