한 스레드만 만지면 잠글 것이 없다
Esc

    한 스레드만 만지면 잠글 것이 없다

    –C#동시성

    지금까지 본 것은 전부 부딪혔을 때 어떻게 할까였습니다. Interlocked 도 lock 도 CompareExchange 도 여러 스레드가 같은 것을 건드린다는 전제 위에 있습니다.

    그런데 전제 자체를 없앨 수 있습니다.

    그 데이터를 만지는 스레드가 하나뿐이면 지킬 것이 없습니다. 공짜입니다.

    왜 이게 제일 좋은 답인가

    lock 을 아무리 잘 써도 남는 것들이 있습니다.

    • 잠근 구간만큼 남이 기다립니다
    • 자물쇠가 늘면 잡는 순서를 지켜야 하고, 안 지키면 데드락입니다
    • 어느 자물쇠가 무엇을 지키는지 컴파일러가 안 봐 줍니다. 사람이 기억해야 합니다
    • 새로 들어온 사람이 규칙을 모르고 한 줄 추가하면 그날부터 깨집니다

    마지막이 제일 큽니다. lock 으로 지킨 코드는 시간이 지나면서 조용히 무너집니다. 반면 “이 데이터는 저 스레드만 만진다” 는 규칙은 깨기가 훨씬 어렵습니다. 만질 통로 자체가 없으니까요.

    그래서 순서가 이렇게 됩니다.

    1. 안 나누면 되나 → 한 스레드가 다 갖는다
    2. 나눠야 하면 → 값 하나인가? Interlocked
    3. 여러 줄인가? lock

    1번부터 봅니다. 대부분의 경우 1번으로 끝납니다.

    게임 루프가 이미 그렇게 생겼다

    이 방식이 낯설어 보이지만 이미 쓰고 있습니다. 유니티가 그렇습니다.

    Update() 는 매 프레임 메인 스레드 하나에서 불립니다. 그래서 transform.position 을 고칠 때 아무도 lock 을 걸지 않습니다. 걸 이유가 없습니다. 유니티가 대부분의 API 를 메인 스레드에서만 부르게 막아 둔 것도 같은 이유입니다.

    게임 서버도 같은 구조를 쓸 수 있습니다. 일정한 간격으로 도는 루프 하나가 세계를 진행시키는 것입니다.

    실제로 이렇게 했다

    제 게임 서버에서 몬스터를 움직이는 코드는 스레드 하나입니다.

    /// 서버 권위 몬스터 시뮬레이션 루프. 고정 10Hz 로 모든 방의 몬스터를 진행(이동/페이즈)하고
    /// S_MonsterState 를 방에 브로드캐스트한다.
    public class RoomTickService(RoomManager roomManager, ILogger<RoomTickService> logger)
        : BackgroundService
    {
        private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100); // 10Hz
        private const float Dt = 0.1f;
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            using var timer = new PeriodicTimer(TickInterval);
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                try { TickAllRooms(); }
                catch (Exception e) { logger.LogError(e, "Error in RoomTickService tick"); }
            }
        }
    }

    방이 백 개든 몬스터가 천 마리든 이 루프 하나가 전부 돕니다. 그래서 몬스터 이동 계산에는 자물쇠가 없습니다. 동시에 만지는 사람이 없으니까요.

    한 틱에서 하는 일이 이렇습니다.

    private void TickAllRooms()
    {
        long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        foreach (var room in roomManager.GetAllRooms())
        {
            var packets = room.TickMonsters(Dt, nowMs);
            foreach (var packet in packets)
                room.Broadcast(packet);
    
            room.RegenAllPlayerMana(Dt);
        }
    
        roomManager.SweepDisconnectedPlayers(nowMs);
    }

    몬스터 이동, 마나 회복, 끊긴 플레이어 정리가 전부 한 줄에 서 있습니다. 순서가 정해져 있고 겹치지 않습니다.

    여기서 배운 것 세 가지

    하나. 고정 간격을 씁니다. PeriodicTimer 는 다음 틱까지 기다립니다. Thread.Sleep(100) 을 쓰면 처리 시간이 더해져서 점점 밀립니다. Dt = 0.1f 를 상수로 둔 것도 같은 맥락입니다. 실제 경과 시간을 재서 쓰면 틱이 밀릴 때 이동 거리가 튀어서 클라이언트 예측과 어긋납니다. 시뮬레이션은 시계가 아니라 틱 수로 진행하는 편이 예측하기 쉽습니다.

    둘. 틱 본문을 try/catch 로 감쌉니다.

    BackgroundService 에서 예외가 새어 나가면 호스트가 종료됩니다. 방 하나의 몬스터 계산이 터졌다고 서버 전체가 죽으면 안 됩니다. 그래서 한 틱이 실패해도 다음 틱은 그대로 돕니다.

    셋. 계산과 상태를 나눕니다. 주석에 이렇게 적어 뒀습니다.

    AI 수식은 MonsterAiMath(순수), 상태/락은 Room.TickMonsters

    이동 거리를 구하는 계산은 아무것도 안 건드리는 순수 함수로 뺐습니다. 그래서 그 부분은 스레드를 생각할 필요가 없고 테스트도 쉽습니다. 동시성을 생각해야 하는 코드의 양을 줄이는 것 자체가 목적입니다.

    그래서 자물쇠가 사라졌나

    아닙니다. 정직하게 말하면 줄었을 뿐입니다.

    플레이어가 보낸 패킷은 다른 스레드에서 들어옵니다. 그 핸들러도 Room 을 만집니다. 그래서 Room 안에는 여전히 lock 이 있습니다. 앞에서 본 lock (_monsters) 가 그것입니다.

    틱 루프가 없앤 것은 틱이 하는 일끼리의 경쟁입니다. 몬스터 백 마리를 백 개 스레드로 돌렸다면 서로 부딪혔을 텐데, 한 줄로 세워서 그 경쟁이 통째로 사라졌습니다.

    완전히 없애려면 패킷도 큐에 넣어서 틱 스레드가 꺼내 처리하게 하면 됩니다. 그러면 Room 을 만지는 스레드가 정말 하나가 되고 자물쇠가 전부 없어집니다. 많은 게임 서버가 그렇게 짭니다. 대신 패킷 응답이 최대 한 틱만큼 늦어집니다.

    어디까지 몰지는 지연과 단순함 사이의 선택입니다. 저는 중간에서 끊었습니다.

    이 구조가 안 맞는 경우

    한 스레드에 몰면 그 스레드가 병목이 됩니다. 코어가 여덟 개여도 하나만 씁니다.

    • 한 틱 안에 끝나야 할 계산이 틱 간격을 넘기면 밀리기 시작합니다
    • 밀린 채로 계속 가면 회복이 안 됩니다

    그래서 무거운 것은 그 루프에서 빼야 합니다. 데이터베이스 쓰기나 외부 호출 같은 것이요. 그런 일은 다른 곳으로 넘기고 루프는 계산만 합니다.

    루프 안에서 절대 기다리지 않는다가 원칙입니다. 앞에서 “잠근 채로 남을 부르지 않는다” 고 한 것과 같은 이야기입니다.

    정리

    • 그 데이터를 만지는 스레드가 하나면 지킬 것이 없습니다. 제일 싼 답입니다
    • lock 은 시간이 지나며 조용히 무너집니다. 규칙을 사람이 기억해야 하기 때문입니다
    • 순서는 안 나누기 → Interlocked → lock 입니다. 대부분 첫 번째로 끝납니다
    • 고정 간격 타이머를 쓰고, 틱 본문을 try/catch 로 감싸고, 계산은 순수 함수로 뺍니다
    • 자물쇠가 사라지는 게 아니라 줄어듭니다. 어디까지 몰지는 지연과의 맞바꿈입니다
    • 한 스레드에 몰면 그 스레드가 병목입니다. 기다리는 일은 루프 밖으로 뺍니다