C#/네트워크

[c#][서버] Lock 구현 이론

goliot 2024. 6. 2. 21:21
반응형

락 구현 개념

  • 화장실에 누군가가 문을 잠근 채로 있다면?
    • 그냥 기다린다.
  • 그런데 오랜 시간이 지나도 나오지 않는다면?
    • 조금 시간이 지난 뒤에 다시 온다.
      • 보장이 안되고 랜덤성이 강함
      • 다른 사람이 먼저 채갈수도
  • 줄서기 알바를 쓴다
    • 줄서고있는 알바가 화가 난다

컴퓨터에서의 개념

  • Spin Lock
    • 계속 뺑뺑이를 돌면서 대기
    • 계속 실행 상태로 있기 때문에 자원을 차지하고 있음
      • CPU 점유율이 튄다
  • Context Switching
    • 쓰레드가 소유권을 포기
    • 코어가 다른 쓰레드에게 가버린다
    • 일정 시간이 지난 후 다시 그 쓰레드로 복귀
      • 왔다갔다 하므로 오버헤드 부담이 있음
  • Auto Reset Event
    • 이벤트를 통해 통보를 받음
    • 커널에다가 이벤트 발생을 명령
    • 깨어나는 시점이 다른 것 보단 정확함

SpinLock 구현

//락이 풀릴 때까지 대기
class SpinLock
{
    volatile bool _locked = false;

    public void Acquire()
    {
        while(_locked)
        {
            //잠금이 풀리기를 대기
        }

        //얻었으니 잠그기
        _locked = true;
    }

    public void Release()
    {
        _locked = false;//잠금 풀기
    }
}

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

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

    static void Thread_2()
    {
        for (int i = 0; i < 10000000; 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);
    }
}
  • Release의 경우 그냥 나 혼자 문 여는 작업일 뿐이므로 저거 한 줄로 충분하다!
  • 이상한 값이 나온다!
  • 뭐가 문제일까
    • 자물쇠가 잠기기 전에 두 쓰레드가 모두 입장해 버림

이상한 값이 나오는 모습

  • 해결하려면, 들어가고 잠그는 동작이 모두 하나의 동작으로 이뤄져야 한다!
  • 아래처럼 해결해보자
volatile int _locked = 0;

public void Acquire()
{
    while (true)
    {
        int original = Interlocked.Exchange(ref _locked, 1);
        if (original == 0) break;
    }
}
  • Interlocked.Exchange()
    • 첫 파라미터를 반환값으로 가짐
    • 두번째 파라미터와 첫 파라미터를 교환
  • 이 과정을 통해 스핀 락 구현이 가능
    • 반환값으로 0이 나왔다면, 풀려 있는 상태에서 내가 잠궜다는 것이 확실해 지므로 break;
    • 1이 나왔다면, 이미 잠겨있다는 뜻이므로 다시 반복

올바른 값이 나왔다

  • 바뀐 함수를 싱글쓰레드 개념으로 표현하자면
int original = _locked;
_locked = 1;
if(original == 0)
   break;
  • 이런 동작이 하나의 단위로 수행됐다고 보면 된다.
    • 하지만 그렇게 직관적으로 보이진 않는 것 같다
    • if(_locked == 0) _locked = 1; 이게 더 직관적인 듯 하다.
while (true)
{
    int expected = 0;
    int desired = 1;
    if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
        break;
    //첫, 세번째 인자 비교
    //같으면 첫, 두번째 인자 교환
    //리턴값은 첫 인자의 original 값
}
  • 즉, locked가 내가 원하던 값이었다면 break시키도록 조금 더 가독성을 강화

Context Switching

  • 락을 얻지 못하면, 다른 일을 하다가 일정 시간 후 다시 돌아온다
  • 휴식 방법
    • Thread.Sleep(1);
      • 무조건 휴식, 1ms
    • Thread.Sleep(0);
      • 조건부 양보 => 나보다 우선순위가 낮은 쓰레드에는 양보 불가
    • Thread.Yield();
      • 관대한 양보 => 조건 없이 관대하게 양보, 다른 쓰레드가 있으면 그거 하세요 => 없으면 그냥 내가 쓴다
    • 누가 더 좋다기보단, 각 상황에 맞춰 맞는 것을 쓰자
  • 무한정 뺑뺑이를 예방하는 효과가 있음. 장점만 존재할까?
  • 그러나 쓰레드 교체 과정에서 오버헤드가 분명히 발생함
    • User mode -> Kernel mode -> User mode 의 전환 과정
  • 그렇다면 직원(쓰레드)의 정보는 어떻게 가지고 있나?
    • 메모리 어딘가에 저장되어 있지, 쓰레드에 무언가 저장돼있지 않다.
    • 그러므로 쓰레드 교체하는 과정에서 이 정보들을 저장하고, 로드하는 과정이 존재하는 것
      • 어떤 상태인지, 코드를 어디까지 실행했는지 등
    • 그러므로 오버헤드가 크다!
    • 소유권을 포기 하는 것이 좋은 일 만이 아니다.

AutoResetEvent

  • 다른 직원에게, 화장실이 비었는지 알려달라고 부탁하는 방식
  • 해당 쓰레드 입장에서는, 자물쇠가 풀릴때만 들어가면 된다는 장점이 있음
class Lock
{
    //bool <- 커널이 조정
    AutoResetEvent _available = new AutoResetEvent(true);
    //문을 연 상태로 시작할지, 닫은 채로 시작할 지 인자로 결정
    //true가 열려 있는 것, 열고 들어가면 자동을 닫힘
    public void Acquire()
    {
        _available.WaitOne(); // 입장 시도
        //이거 한줄로 기다리고 들어가고 잠그고 다 해줌
        //_available.Reset(); //잠그는것, 자동으로 해주기 떄문에 안써도 됨
    }

    public void Release()
    {
        _available.Set(); //flag = true
        //자물쇠를 푸는 것
    }
}
  • SpinLock 처럼 100만번 수행하면, 너무 오래걸려서 끝나지 않는 것 처럼 보임
  • 10000번 정도 반복이라면 금방 끝남
    • 즉, 편해지고 정확해지지만, 커널에 왔다갔다 하면서 속도가 아주 느려진다
  • Event는 락이 아니라도 다른데에 쓰기도 함

ManualResetEvent

class Lock
{
    //bool <- 커널이 조정
    ManualResetEvent _available = new ManualResetEvent(true);

    public void Acquire()
    {
        _available.WaitOne(); // 입장 시도
        _available.Reset(); //입장 후 문 닫기
        //Manual에서는 이 작업을 수동으로 해야 함
    }

    public void Release()
    {
        _available.Set(); //flag = true
        //자물쇠를 푸는 것
    }
}
  • Auto와 다르게, 자물쇠를 잠그는 동작이 자동이 아니라 나뉘어 있다.
    • 그렇기 때문에, 잠그기 전에 두 쓰레드가 동시에 입장이 가능해진다!
    • Auto를 써서 한 동작으로 끝내자
  • 그렇다면 Manual은 언제 쓰냐?
    • 꼭 한번에 한 쓰레드만 입장할 필요가 없는 경우
      • 로딩이나 패킷 받는 오래걸리는 작업을 기다리고, 그게 끝나면 모든 쓰레드가 재가동 되게 하는 경우
  • 아무튼 이전과 다르게 Kernel이 코드에 개입한다는 개념이 매우 중요!
    • 부담이 많이 된다는 것도!

Mutex

static Mutex _lock = new Mutex();

static void Thread_1()
{
    for(int i=0; i<1000000; i++)
    {
        _lock.WaitOne(); //문 열고 들어가서 잠그고
        _num++;
        _lock.ReleaseMutex(); //열어줌
    }
}

static void Thread_2()
{
    for (int i = 0; i < 1000000; i++)
    {
        _lock.WaitOne();
        _num--;
        _lock.ReleaseMutex();
    }
}
  • 커널 동기화 객체의 개념
  • 관리자(커널)가 직원들(쓰레드)의 우선순위를 잡아줌
  • AutoResetEvent랑 뭐가 다르냐?
    • Mutex가 정보를 더 가지고 있음
      • 한 쓰레드가 몇 번이나 잠갔는지 카운트
      • Thread ID도 갖고 있음 -> Lock, Release가 같은 쓰레드가 한 것임을 확인
    • 그렇기 때문에 너무 비싼 명령임
    • 별로 쓸 일 없다
반응형

'C# > 네트워크' 카테고리의 다른 글

[c#][서버] Thread Local Storage  (0) 2024.06.03
[c#][서버] ReaderWriterLock  (0) 2024.06.03
[c#][서버] 데드락  (0) 2024.06.02
[C#][서버] Lock 기초  (0) 2024.06.02
[C#][서버] Interlocked  (0) 2024.06.01