count++ 는 한 번에 일어나지 않는다
스레드 둘이 같은 변수를 천 번씩 더합니다. 결과는 이천이어야 합니다.
int count = 0;
void 더하기()
{
for (int i = 0; i < 1000; i++)
count++;
}
// 스레드 둘이 더하기() 를 각각 실행
돌려 보면 1000 대 후반이 나옵니다. 1873, 1994, 어떤 때는 2000. 매번 다릅니다.
버그가 매번 다른 답을 내면 원인을 찾기가 아주 어렵습니다. 그리고 이 코드에는 이상해 보이는 곳이 없습니다.
count++는 한 줄이지만 기계에서는 세 걸음입니다. 그 사이에 남이 끼어들 수 있습니다.
한 줄이 세 걸음인 이유
CPU 는 메모리에 있는 값을 직접 못 더합니다. 레지스터로 가져와서 더하고 다시 써야 합니다.
1. 읽기 count 의 값을 내 레지스터로 가져온다
2. 더하기 레지스터에서 1 을 더한다
3. 쓰기 레지스터 값을 count 에 쓴다
앞에서 식은 순서대로 줄어든다고 했습니다. 그 순서가 여기서는 끊길 수 있는 세 조각이 됩니다.
스레드는 언제든 멈출 수 있습니다. 운영체제가 CPU 를 다른 스레드에 넘기는 데 우리 허락을 받지 않습니다. 2번과 3번 사이에서 멈출 수도 있다는 뜻입니다.
둘 다 잘못한 게 없습니다. 각자 자기가 읽은 값에 정확히 1 을 더했습니다. 문제는 B 가 읽은 시점에 A 의 결과가 아직 안 써졌다는 것입니다.
이런 것을 경쟁 조건이라고 부릅니다. 답이 누가 먼저 도착하느냐에 따라 달라진다는 뜻입니다.
왜 재현이 안 되나
위 그림에서 엇갈리려면 아주 짧은 순간에 스레드가 바뀌어야 합니다. 자주 일어나지 않습니다. 천 번 돌면 몇 번쯤 겹칩니다. 그래서 이렇게 됩니다.
- 개발 기기에서는 잘 됩니다. 코어가 많고 부하가 적으면 잘 안 겹칩니다
- 테스트도 통과합니다. 한 번 돌려서 맞으면 통과입니다
- 서버에 올리고 사람이 몰리면 터집니다. 그리고 로그를 봐도 이상한 데가 없습니다
그래서 경쟁 조건은 찾아서 고치는 것이 아니라 안 생기게 짜는 것입니다. “돌려 봤는데 되던데요” 는 아무것도 증명하지 않습니다.
무엇이 위험한가
++ 만 그런 게 아닙니다. 읽고 판단하고 쓰는 것이 나뉘면 전부 같은 문제입니다.
if (_방장 == null) // 읽고 판단하고
_방장 = 나; // 쓴다
둘이 동시에 들어오면 둘 다 null 을 보고 둘 다 방장이 됩니다.
if (재고 > 0)
재고--;
재고 1 개를 둘이 삽니다.
if (!_목록.Contains(x))
_목록.Add(x);
같은 것이 두 번 들어갑니다. 확인과 실행 사이가 벌어져 있어서입니다.
이 모양을 확인 후 실행이라고 부릅니다. 확인한 결과가 실행할 때도 참이라는 보장이 없습니다.
한편 이런 것은 안전합니다.
int a = _값; // 읽기만
_다른값 = 5; // 쓰기만 (32비트 이하 타입)
한 걸음짜리는 끊길 자리가 없습니다. 다만 읽은 값이 최신이라는 보장은 또 다른 이야기입니다. 그건 뒤에서 따로 봅니다.
더하기 하나면 방법이 있다
count++ 처럼 한 변수를 한 번 더하는 것이라면 세 걸음을 한 걸음으로 만들 수 있습니다.
CPU 가 그걸 지원합니다.
Interlocked.Increment(ref count);
읽고 더하고 쓰는 것을 끊기지 않는 한 동작으로 처리합니다. 중간에 다른 스레드가 끼어들 자리가 아예 없습니다. 그리고 잠그지 않으니 싸게 끝납니다.
Interlocked.Increment(ref x); // ++
Interlocked.Decrement(ref x); // --
Interlocked.Add(ref x, 10); // += 10
Interlocked.Exchange(ref x, 5); // = 5 하고 옛 값을 돌려줌
ref 로 넘기는 것에 주목할 만합니다. 값의 사본이 아니라 그 자리 자체를 건드려야 하니까요.
실제로 어디에 쓰나
제가 만든 게임 서버에서 이걸 쓴 자리는 전부 번호 발급입니다.
접속이 들어올 때마다 세션에 번호를 하나씩 붙입니다. 접속은 여러 스레드에서 동시에 들어오는데, 번호가 겹치면 세션이 서로를 덮어씁니다.
// SessionManager.cs
public Session? CreateSession(Socket clientSocket, CancellationToken ct)
{
var id = Interlocked.Increment(ref _nextSessionId);
...
}
방 안에서 이펙트 인스턴스에 번호를 붙일 때도 같습니다.
// Room.cs
private int _nextEffectInstanceId;
public int NextEffectInstanceId()
=> Interlocked.Increment(ref _nextEffectInstanceId);
여기에 lock 을 쓸 수도 있었습니다. 그런데 하는 일이 더하기 하나뿐이라 그럴 이유가 없습니다.
lock 은 여러 줄을 묶어 지킬 때 쓰는 도구입니다. 한 줄짜리에 쓰면 값도 못 하고 비싸기만 합니다.
Interlocked.Exchange 를 쓴 자리도 하나 있습니다. 서버를 두 번 켜거나 두 번 끄는 것을 막는 곳입니다.
// TcpNetworkListener.cs
public void Start(int backlog = 512)
{
if (Interlocked.Exchange(ref _running, 1) == 1)
return; // 이미 켜져 있었다. 아무것도 안 한다
...
}
Exchange 는 새 값을 넣고 옛 값을 돌려줍니다. 그래서 “내가 켠 것인가, 이미 켜져 있었나” 를 한 걸음에 알 수 있습니다.
if (_running == 1) return; _running = 1; 로 쓰면 그 사이가 벌어져서 둘 다 통과할 수 있습니다. 위에서 본 그 모양입니다.
정리
count++는 읽기·더하기·쓰기 세 걸음입니다. 그 사이에 스레드가 바뀔 수 있습니다- 둘 다 잘못한 게 없는데 더한 값이 사라집니다. 이게 경쟁 조건입니다
- 답이 매번 다르고 재현이 안 됩니다. 개발 기기에서는 잘 되고 부하가 걸리면 터집니다
++만이 아니라 확인 후 실행이 전부 같은 문제입니다- 한 변수에 대한 단순한 셈이면
Interlocked로 한 걸음으로 만들 수 있습니다 Exchange는 옛 값을 돌려줘서 “내가 처음인가” 를 물을 수 있습니다