한 스레드만 만지면 잠글 것이 없다
지금까지 본 것은 전부 부딪혔을 때 어떻게 할까였습니다.
Interlocked 도 lock 도 CompareExchange 도 여러 스레드가 같은 것을 건드린다는 전제 위에 있습니다.
그런데 전제 자체를 없앨 수 있습니다.
그 데이터를 만지는 스레드가 하나뿐이면 지킬 것이 없습니다. 공짜입니다.
왜 이게 제일 좋은 답인가
lock 을 아무리 잘 써도 남는 것들이 있습니다.
- 잠근 구간만큼 남이 기다립니다
- 자물쇠가 늘면 잡는 순서를 지켜야 하고, 안 지키면 데드락입니다
- 어느 자물쇠가 무엇을 지키는지 컴파일러가 안 봐 줍니다. 사람이 기억해야 합니다
- 새로 들어온 사람이 규칙을 모르고 한 줄 추가하면 그날부터 깨집니다
마지막이 제일 큽니다. lock 으로 지킨 코드는 시간이 지나면서 조용히 무너집니다.
반면 “이 데이터는 저 스레드만 만진다” 는 규칙은 깨기가 훨씬 어렵습니다. 만질 통로 자체가 없으니까요.
그래서 순서가 이렇게 됩니다.
- 안 나누면 되나 → 한 스레드가 다 갖는다
- 나눠야 하면 → 값 하나인가?
Interlocked - 여러 줄인가?
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로 감싸고, 계산은 순수 함수로 뺍니다 - 자물쇠가 사라지는 게 아니라 줄어듭니다. 어디까지 몰지는 지연과의 맞바꿈입니다
- 한 스레드에 몰면 그 스레드가 병목입니다. 기다리는 일은 루프 밖으로 뺍니다