내가 처음인지 물어보려면 CompareExchange 다
던전에서 마지막 몬스터가 죽으면 클리어를 발화합니다. 그런데 마지막 타격은 여러 명이 동시에 넣을 수 있습니다.
클리어는 딱 한 번만 발화해야 합니다. 두 번 나가면 보상이 두 번 지급됩니다.
lock 으로 써 보면 이렇게 됩니다.
lock (_자물쇠)
{
if (_클리어됨) return false;
_클리어됨 = true;
return true;
}
동작은 합니다. 그런데 하는 일에 비해 장치가 큽니다. 지키는 것이 bool 하나인데 자물쇠를 하나 두고 기다리게 만듭니다.
CPU 에 “값이 이것이면 저것으로 바꾸고, 원래 값을 알려 줘” 라는 한 걸음짜리 명령이 있습니다.
CompareExchange 는 이렇게 생겼다
Interlocked.CompareExchange(ref 대상, 새값, 기대값)
하는 일은 이렇습니다. 끊기지 않는 한 동작으로 처리됩니다.
대상이기대값과 같은가- 같으면
새값으로 바꾼다. 다르면 아무것도 안 한다 - 어느 쪽이든 바꾸기 전의 값을 돌려준다
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로 다시 시도합니다. 경쟁이 드물 때만 이득입니다