내가 처음인지 물어보려면 CompareExchange 다
Esc

    내가 처음인지 물어보려면 CompareExchange 다

    –C#동시성

    던전에서 마지막 몬스터가 죽으면 클리어를 발화합니다. 그런데 마지막 타격은 여러 명이 동시에 넣을 수 있습니다.

    클리어는 딱 한 번만 발화해야 합니다. 두 번 나가면 보상이 두 번 지급됩니다.

    lock 으로 써 보면 이렇게 됩니다.

    lock (_자물쇠)
    {
        if (_클리어됨) return false;
        _클리어됨 = true;
        return true;
    }

    동작은 합니다. 그런데 하는 일에 비해 장치가 큽니다. 지키는 것이 bool 하나인데 자물쇠를 하나 두고 기다리게 만듭니다.

    CPU 에 “값이 이것이면 저것으로 바꾸고, 원래 값을 알려 줘” 라는 한 걸음짜리 명령이 있습니다.

    CompareExchange 는 이렇게 생겼다

    Interlocked.CompareExchange(ref 대상, 새값, 기대값)

    하는 일은 이렇습니다. 끊기지 않는 한 동작으로 처리됩니다.

    1. 대상 이 기대값 과 같은가
    2. 같으면 새값 으로 바꾼다. 다르면 아무것도 안 한다
    3. 어느 쪽이든 바꾸기 전의 값을 돌려준다

    3번이 핵심입니다. 돌려받은 값이 기대값 이면 내가 바꾼 것이고, 아니면 남이 이미 바꾼 것입니다.

    private int _발화됨;   // 0 = 아직, 1 = 발화함
    
    bool 처음인가()
    {
        return Interlocked.CompareExchange(ref _발화됨, 1, 0) == 0;
    }

    동시에 열 명이 불러도 true 를 받는 사람은 정확히 한 명입니다.

    lock 과 무엇이 다른지 보면 이렇습니다.

    lock CompareExchange
    알려 주는 것 들어가도 된다 내가 바꾼 사람인가
    못 들어가면 기다린다 기다리지 않는다. 실패를 바로 알려 준다
    지킬 수 있는 것 여러 줄 변수 하나

    기다리지 않는다는 게 중요합니다. 진 사람은 서 있지 않고 바로 false 를 받아 자기 갈 길을 갑니다.

    값이 둘 이상이면 상호 배타가 된다

    bool 대신 int 를 쓰면 여러 결말 중 하나만 고르게 만들 수 있습니다.

    던전은 클리어로 끝나거나 실패로 끝납니다. 둘 다 일어나면 안 됩니다. 마지막 몬스터를 잡는 순간 마지막 플레이어가 죽을 수도 있으니 실제로 겹칠 수 있는 상황입니다.

    // 0 = 아직, 1 = 클리어, 2 = 실패
    private int _outcome;
    
    bool 클리어claim() => Interlocked.CompareExchange(ref _outcome, 1, 0) == 0;
    bool 실패claim()   => Interlocked.CompareExchange(ref _outcome, 2, 0) == 0;

    둘 다 0 일 때만 성공합니다. 그래서 한쪽이 이기면 다른 쪽은 자동으로 집니다. 자물쇠를 공유하지 않았는데도 상호 배타가 됩니다. 같은 변수 하나를 두고 다투기 때문입니다.

    실제로 이렇게 썼다

    제 게임 서버의 Room 에 그대로 있습니다. 주석까지 이 이야기입니다.

    // 던전 결과(클리어/실패) — 단일 terminal 상태. 0=None,1=Cleared,2=Failed.
    // Interlocked.CompareExchange 로 최초 1회만 claim → 클리어/실패 상호 배타(둘 다 발화 불가).
    private int _outcome;

    클리어 판정은 이렇게 생겼습니다.

    public bool TryMarkCleared()
    {
        lock (_monsters)
        {
            if (!_monstersSpawned || _monsters.Count > 0)
                return false;
        }
    
        // 전멸 확인 후 terminal 상태를 원자적으로 claim — 실패와 동시 발화 불가.
        return Interlocked.CompareExchange(ref _outcome, 1, 0) == 0;
    }

    한 메서드에 두 도구가 같이 있습니다. 그리고 자리가 다릅니다.

    • lock (_monsters) 는 컬렉션을 읽는 동안만 걸립니다. 몬스터가 정말 전멸했는지 세는 구간입니다
    • CompareExchange 는 그 락 밖에서 일어납니다

    실패 판정도 같은 모양인데 자물쇠가 다릅니다.

    public (bool NewlyDowned, bool FailClaimed) MarkPlayerDowned(long userId)
    {
        bool newly, allDown;
        lock (_downed)
        {
            newly = _downed.Add(userId);
            allDown = _downed.Count >= _expectedUserIds.Count;
        }
    
        bool failClaimed = allDown
            && Interlocked.CompareExchange(ref _outcome, 2, 0) == 0;
        return (newly, failClaimed);
    }

    여기가 이 글에서 제일 중요한 지점입니다.

    클리어는 _monsters 를 잠그고 실패는 _downed 를 잠급니다. 서로 다른 자물쇠입니다. 그러니 lock 으로는 둘을 상호 배타로 만들 수 없습니다. 동시에 들어갈 수 있으니까요.

    둘을 갈라 주는 것은 _outcome 이라는 변수 하나입니다. 둘 다 그 하나를 0 에서 바꾸려 하고, 한 번만 성공합니다.

    세 번째 자물쇠를 만들어 둘을 묶을 수도 있었습니다. 그런데 그러면 자물쇠가 셋이 되고, 잡는 순서를 지켜야 하고, 앞에서 본 데드락 가능성이 생깁니다. 변수 하나로 끝나는 일에 자물쇠를 늘리지 않는 편이 낫습니다.

    되돌아보기 패턴

    CompareExchange 의 다른 쓰임이 하나 더 있습니다. 단순한 셈이 아닌 갱신입니다.

    Interlocked.Add 는 더하기만 합니다. “지금 값의 두 배로” 나 “지금 값과 새 값 중 큰 쪽으로” 같은 것은 못 합니다. 그럴 때는 이렇게 씁니다.

    int 현재, 새것;
    do
    {
        현재 = _최고기록;
        새것 = Math.Max(현재, 점수);
    }
    while (Interlocked.CompareExchange(ref _최고기록, 새것, 현재) != 현재);

    읽고, 계산하고, 읽었을 때와 값이 그대로면 씁니다. 그 사이에 남이 바꿨으면 돌려받은 값이 달라서 다시 돕니다.

    기다리는 대신 다시 시도합니다. 이걸 낙관적 갱신이라고 부릅니다. 경쟁이 드물면 대개 한 번에 끝나서 lock 보다 쌉니다. 반대로 경쟁이 심하면 계속 헛돌아서 더 나쁩니다.

    그래서 판단 기준이 나옵니다.

    • 경쟁이 드물고 갱신이 짧다 → CompareExchange
    • 경쟁이 잦거나 지킬 것이 여러 줄이다 → lock

    정리

    • CompareExchange(ref x, 새값, 기대값) 는 기대값일 때만 바꾸고 원래 값을 돌려줍니다
    • 돌려받은 값이 기대값이면 내가 바꾼 사람입니다. lock 은 이걸 못 알려 줍니다
    • 진 사람은 기다리지 않고 바로 실패를 받습니다
    • int 로 결말을 나누면 자물쇠를 공유하지 않고도 상호 배타가 됩니다
    • 자물쇠가 서로 다른 두 경로를 갈라 주는 것이 변수 하나일 수 있습니다
    • 단순한 셈이 아닌 갱신은 do/while 로 다시 시도합니다. 경쟁이 드물 때만 이득입니다