lock 은 순서를 만들고 대가를 받는다
앞에서 Interlocked 로 세 걸음을 한 걸음으로 만들었습니다. 다만 그건 변수 하나에 대한 단순한 셈일 때만 됩니다.
지켜야 할 것이 이런 모양이면 못 씁니다.
_방목록.Add(방);
_개수++;
_logger.LogInformation("방 {Id} 생성", 방.Id);
세 줄이 통째로 한 덩어리여야 합니다. 중간에 다른 스레드가 _방목록 을 보면 개수가 안 맞는 상태를 봅니다.
lock은 “이 사이에는 한 번에 한 명만” 을 강제합니다. 대신 기다리게 만듭니다.
lock 은 이렇게 생겼다
private readonly object _자물쇠 = new object();
void 추가(방 방)
{
lock (_자물쇠)
{
_방목록.Add(방);
_개수++;
}
}
lock (x) 는 이렇게 동작합니다.
x를 잡는다. 이미 남이 잡고 있으면 놓을 때까지 기다린다- 블록 안을 실행한다
- 나가면서 놓는다. 예외가 나도 놓는다
3번이 중요합니다. 컴파일러가 try/finally 로 감싸 주기 때문에 중간에 터져도 자물쇠가 남지 않습니다.
앞에서 본 using 과 같은 구조입니다.
무엇을 잠그는가
여기가 제일 많이 오해받는 자리입니다.
lock 은 데이터를 잠그지 않습니다. 자물쇠 객체를 잡을 뿐입니다.
lock (_자물쇠A) { _목록.Add(1); } // 스레드 1
lock (_자물쇠B) { _목록.Add(2); } // 스레드 2
둘 다 lock 을 썼는데 서로 아무 상관이 없습니다. 다른 자물쇠를 잡았으니 동시에 들어갑니다.
_목록 은 그대로 깨집니다.
그래서 규칙은 이렇습니다. 같은 데이터를 지키는 코드는 전부 같은 자물쇠를 써야 합니다. 어느 자물쇠가 어느 데이터를 지키는지 주석으로 적어 두는 편이 낫습니다. 컴파일러는 안 봐 줍니다.
잠그면 안 되는 것들
자물쇠로 쓰면 안 되는 것이 있습니다.
lock (this) // 밖에서도 이 객체를 잠글 수 있습니다
lock (typeof(내클래스)) // 프로그램 전체에서 하나뿐입니다
lock ("문자열") // 같은 리터럴이 전부 같은 객체입니다
셋 다 남이 같은 것을 잠글 수 있다는 게 문제입니다. 내가 모르는 코드가 내 자물쇠를 붙잡고 안 놓으면 내 코드가 멈추는데, 원인을 찾을 방법이 없습니다.
그래서 private readonly object 를 따로 하나 두는 것이 기본입니다. 아무도 그 참조를 모르니까요.
값 타입도 안 됩니다. 박싱이 일어나서 매번 다른 객체를 잠그게 됩니다. 잠근 척만 하는 코드가 됩니다.
대가 셋
lock 은 공짜가 아닙니다.
하나. 기다립니다. 한 번에 한 명이니 나머지는 서 있습니다. 잠근 구간이 길수록 더 오래 섭니다. 그래서 안에서 파일을 읽거나 네트워크를 부르면 안 됩니다. 잠근 채로 남을 부르지 않는다가 원칙입니다.
둘. 재진입은 되는데 그게 함정이 되기도 합니다. 같은 스레드가 같은 자물쇠를 다시 잠그는 것은 허용됩니다.
lock (_자물쇠)
{
도우미(); // 이 안에서 또 lock (_자물쇠) 해도 통과
}
편해 보이는데, 그 말은 중간 상태를 나 자신이 볼 수 있다는 뜻입니다.
_목록 을 반쯤 고쳐 놓은 상태에서 부른 메서드가 그 목록을 읽으면 깨진 값을 봅니다.
자물쇠는 걸려 있는데 데이터가 이상한, 제일 찾기 어려운 종류의 버그입니다.
셋. 데드락. 이게 제일 큽니다.
데드락은 순서가 엇갈릴 때 생긴다
두 스레드가 자물쇠 두 개를 다른 순서로 잡으면 서로를 기다리며 영원히 섭니다.
예외도 안 나고 로그도 안 남습니다. 그냥 멈춥니다. 서버라면 그 요청들이 전부 쌓입니다.
막는 방법은 하나뿐입니다. 자물쇠를 잡는 순서를 프로젝트 전체에서 하나로 정하는 것입니다. 언제나 1번 다음 2번이면 위 그림이 안 만들어집니다.
그런데 자물쇠가 늘어나면 이 순서를 지키기가 급격히 어려워집니다. 그래서 진짜 답은 따로 있습니다. 자물쇠를 여러 개 잡는 코드를 안 만드는 것입니다.
실제로 어디에 썼나
제 게임 서버에서 lock 이 나오는 클래스는 Room 하나입니다.
소켓 서버 전체를 통틀어 그렇습니다.
// Room.cs
private readonly Dictionary<ulong, Session> _playerSessions = new();
private readonly Dictionary<long, PlayerState> _playerStates = new();
// 서버 권위 몬스터 상태 (instanceId → state). 접근 시 반드시 lock(_monsters).
private readonly Dictionary<int, MonsterState> _monsters = new();
// 서버 권위 바닥 아이템 (groundId → item). 접근 시 반드시 lock(_groundItems).
private readonly Dictionary<int, GroundItem> _groundItems = new();
여기서 두 가지를 했습니다.
컬렉션 자기 자신을 자물쇠로 씁니다. lock (_monsters) 처럼요.
따로 자물쇠 객체를 두지 않는 대신, 그 컬렉션은 밖으로 절대 안 내보냅니다.
private readonly 이고 참조를 반환하는 메서드가 없으니 남이 잠글 수 없습니다.
자물쇠 하나에 지키는 데이터 하나가 딱 붙어 있어서 어느 자물쇠가 무엇을 지키는지 헷갈릴 일이 없습니다.
주석으로 규칙을 적어 뒀습니다. “접근 시 반드시 lock(_monsters)” 처럼요. 컴파일러가 안 지켜 주니 사람이 읽을 곳에라도 적어야 합니다.
그리고 더 중요한 것은 나머지 전부가 Room 밖이라 잠글 일이 없다는 점입니다.
패킷 핸들러도, 세션도, 방 관리자도 lock 이 없습니다.
공유 상태를 한 클래스에 몰아넣었기 때문입니다.
자물쇠가 한 클래스 안에만 있으면 잡는 순서를 그 파일 하나만 보고 확인할 수 있습니다. 위에서 본 데드락은 자물쇠가 여러 파일에 흩어져 있을 때 생기는데, 그럴 일이 없어집니다.
이게 lock 을 잘 쓰는 법의 전부라고 봅니다. 잘 쓰는 것보다 쓸 자리를 좁히는 것이 먼저입니다.
정리
lock은 여러 줄을 묶어 “한 번에 한 명” 을 강제합니다.Interlocked로 안 되는 자리에 씁니다- 데이터가 아니라 자물쇠 객체를 잡습니다. 같은 데이터는 반드시 같은 자물쇠로 지켜야 합니다
this,typeof(...), 문자열, 값 타입은 자물쇠로 쓰지 않습니다. 남이 같은 것을 잠글 수 있습니다- 잠근 채로 남을 부르지 않습니다. 기다리는 사람이 그만큼 늘어납니다
- 자물쇠 둘을 다른 순서로 잡으면 데드락입니다. 예외도 로그도 없이 멈춥니다
- 순서를 정하는 것보다 자물쇠를 여러 개 잡을 일을 안 만드는 것이 낫습니다
- 공유 상태를 한 클래스에 모으면 자물쇠도 거기만 있게 됩니다