count++ 는 한 번에 일어나지 않는다
Esc

    count++ 는 한 번에 일어나지 않는다

    –C#동시성

    스레드 둘이 같은 변수를 천 번씩 더합니다. 결과는 이천이어야 합니다.

    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 는 옛 값을 돌려줘서 “내가 처음인가” 를 물을 수 있습니다