반응형

[PART13.비동기와 스레딩 기초(14/15)] 경쟁 상태와 데드락 — 감 잡기

멀티스레딩 코드를 처음 짜면 "분명 1000번 더했는데 결과가 987이 나오고", "잘 돌던 프로그램이 어느 날 갑자기 멈춰서 안 풀린다." 두 현상은 각각 경쟁 상태(Race Condition)데드락(Deadlock) 입니다. 이 글은 두 버그가 *왜* 생기는지 그림으로 감을 잡고, *최소한의 도구* (lock, Interlocked) 로 막는 법, 그리고 *피하는 코딩 습관* 까지 입문자 시각으로 정리합니다. ThreadSanitizer 같은 탐지 도구·재진입 락·메모리 배리어는 심화 커리큘럼에서 다룹니다.


0. 한 줄 요약

  • 경쟁 상태: 두 스레드가 같은 변수를 동시에 읽고 쓸 때, *실행 순서가 매번 달라서* 결과도 매번 달라지는 버그입니다.
  • 데드락: 두 스레드가 서로 *상대방이 들고 있는 락을 기다리느라* 영원히 멈춰버리는 상태입니다.
  • 방어 도구: 단순 카운터는 Interlocked, 여러 줄짜리 임계 영역은 lock. 둘 다 "한 번에 하나만 들어가게" 만들어 줍니다.
  • 데드락 회피 규칙: 락 잡는 순서를 *모든 코드에서 동일하게*, 락 안에서는 *외부 코드 호출 금지*, 락 범위는 *최대한 짧게*.

1. 왜 멀티스레딩에서 결과가 어긋날까

1-1. counter++ 는 한 줄이 아닙니다

C# 코드를 작성하는 사람의 눈에는 counter++ 가 한 동작으로 보입니다. 그런데 CPU 입장에서는 세 단계입니다.

  1. Read — 메모리에서 counter 값을 레지스터로 가져옵니다.
  2. Modify — 레지스터에서 1을 더합니다.
  3. Write — 레지스터의 새 값을 메모리에 다시 씁니다.

이 세 단계 사이 *어디든* 다른 스레드가 끼어들 수 있습니다. 끼어들면 한 번 증가가 통째로 사라집니다.

counter++ 가 두 스레드에서 겹치면

A 가 10을 읽은 직후 B 가 끼어들어 똑같이 10을 읽고, 둘 다 11을 씁니다. 두 번 더했는데 한 번분만 적용됐습니다. 이게 잃어버린 갱신(Lost Update) 으로 부르는 가장 흔한 경쟁 상태입니다.

1-2. 컴파일러·CPU 의 명령 재배치

JIT 컴파일러와 CPU 는 *단일 스레드 결과만 같다면* 명령 순서를 자유롭게 바꿔도 된다는 규칙으로 최적화합니다. 단일 스레드에선 문제가 없지만, 다른 스레드가 그 변수를 *중간 상태* 로 관찰할 때 의도가 깨집니다. 이 글에서는 "이런 것도 있다" 정도로만 짚고, 본격적인 해결(메모리 배리어, volatile)은 *심화 커리큘럼* 에서 다룹니다.

용어 짧게 — *원자적(atomic) 연산*: 중간에 끊기지 않고 한 번에 끝나는 연산입니다. CPU 가 직접 보장합니다. *임계 영역(critical section)*: 한 번에 한 스레드만 들어가야 안전한 코드 구간입니다.

2. 비유로 잡기

은행 ATM 두 대가 같은 통장 잔고에 동시에 접근한다고 상상합니다. 두 사람이 동시에 1만 원씩 입금하는데, 두 ATM 모두 *입금 직전 잔고* 를 따로 읽어서 1만 원을 더하고 *각자* 결과를 통장에 씁니다. 한쪽이 덮어써서 1만 원이 사라집니다 — 경쟁 상태.

이번엔 두 사람이 *각자* 다른 ATM 에서 작업 중인데, 첫 번째 사람은 통장 A 의 인증을 잡은 채 통장 B 의 인증을 기다리고, 두 번째 사람은 통장 B 의 인증을 잡은 채 통장 A 의 인증을 기다립니다. 둘 다 손에 든 자원을 놓지 못한 채 영원히 기다립니다 — 데드락.

차이를 한 줄로:

  • 경쟁 상태 → "결과가 *틀려진다*."
  • 데드락 → "결과를 못 내고 *멈춘다*."

3. 코드로 감 잡기 — 경쟁 상태 재현과 방어

3-1. 가장 작은 재현 코드

C#
using System;
using System.Threading;
using System.Threading.Tasks;

class RaceDemo
{
    static int counter = 0;

    static void Main()
    {
        // 두 스레드가 각각 100,000번씩 ++ → 기대값 200,000
        Parallel.Invoke(
            () => { for (int i = 0; i < 100_000; i++) counter++; },
            () => { for (int i = 0; i < 100_000; i++) counter++; }
        );

        Console.WriteLine($"실제: {counter} / 기대: 200,000");
    }
}

이 코드를 여러 번 돌리면 결과가 매번 다릅니다. 어떤 때는 198,742, 어떤 때는 173,005. 200,000 이 *우연히* 나올 수도 있습니다. 멀티스레드 버그는 이렇게 *재현이 들쑥날쑥하다는 것* 자체가 가장 골치 아픈 특징입니다.

3-2. Interlocked — 단순 연산의 가벼운 답

counter++ 정도의 *한 변수에 대한 단순 연산* 은 Interlocked 가 제일 빠릅니다. CPU 가 직접 지원하는 원자적 명령(예: LOCK XADD) 으로 컴파일되어, 락 획득·해제의 OS 비용이 들지 않습니다.

C#
static int counter = 0;

Parallel.Invoke(
    () => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); },
    () => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); }
);

Console.WriteLine($"실제: {counter}"); // 항상 200,000

세 가지만 기억하면 충분합니다.

메서드 의미
Interlocked.Increment(ref x) x++ 를 원자적으로
Interlocked.Add(ref x, n) x += n 를 원자적으로
Interlocked.CompareExchange(ref x, newValue, expected) xexpected 와 같을 때만 newValue 로 바꿉니다. 락 없이 동시성 자료구조를 만드는 핵심 도구입니다.

3-3. lock — 여러 줄을 묶어야 할 때

상태가 여러 변수에 걸쳐 있어 *동시에 일관* 돼야 한다면 lock 으로 묶습니다.

C#
class Wallet
{
    private readonly object _gate = new();      // 락 전용 객체
    private int _krw;
    private int _coin;

    public void Exchange(int krwIn, int coinOut)
    {
        // 두 변수의 변경이 "동시에" 일어난 것처럼 보여야 합니다.
        lock (_gate)
        {
            _krw -= krwIn;
            _coin += coinOut;
        }
    }
}

lock(obj) { ... } 은 컴파일러가 다음 패턴으로 풀어줍니다(개념적 형태).

C#
bool taken = false;
try
{
    Monitor.Enter(_gate, ref taken);
    _krw -= krwIn;
    _coin += coinOut;
}
finally
{
    if (taken) Monitor.Exit(_gate);
}

핵심은 두 가지입니다.

  1. Monitor.Enter상호 배제 를 만듭니다. 다른 스레드가 이미 들어가 있으면 풀릴 때까지 기다립니다.
  2. try/finally예외가 터져도 락은 반드시 풀립니다. 락이 풀리지 않으면 그 자체가 또 다른 정지 원인이 됩니다.
락 객체 고르는 규칙private readonly object _gate = new(); 같은 *전용 객체* 를 만들어 잠급니다. this, typeof(MyClass), string 리터럴 같은 *외부에 노출된 참조* 는 다른 코드가 같은 객체로 락을 잡아 의도치 않은 데드락을 만들 수 있어 피합니다.

3-4. IL 로 한 번 들여다보기 — lock 은 정말 try/finally 일까

C# 13 이전 컴파일러로 lock(_gate) { ... } 을 컴파일한 IL 은 다음과 같습니다(요지만 발췌).

ldarg.0
ldfld      object Wallet::_gate
stloc.0
ldc.i4.0
stloc.1
.try
{
  ldloc.0
  ldloca.s   1
  call       void [System.Threading]Monitor::Enter(object, bool&)
  // ...임계 영역 본문...
  leave.s    END
}
finally
{
  ldloc.1
  brfalse.s  SKIP
  ldloc.0
  call       void [System.Threading]Monitor::Exit(object)
  SKIP: endfinally
}
END: ret

읽기 포인트:

  • Monitor.Enter(obj, ref taken)Monitor.Exit(obj) 가 그대로 보입니다. lock 키워드는 문법적 설탕 입니다.
  • taken 변수가 핵심입니다. Enter 가 락을 *실제로 잡았는지* 가 기록되고, finally 에서 잡힌 경우에만 Exit 합니다. 락을 못 잡은 채로 예외가 나면 잘못된 Exit 를 호출하지 않습니다.
  • Interlocked.Increment 는 IL 에서 일반 메서드 호출처럼 보이지만, JIT 가 플랫폼별 *원자적 명령* (x86: LOCK XADD, ARM: LDADD) 으로 인라인화합니다. 그래서 lock 보다 압도적으로 빠른 겁니다.

C# 13 이상에서는 System.Threading.Lock 타입을 도입하면서 IL 패턴이 약간 달라지지만, "Enter/Exit 를 try/finally 로 감싼다"는 본질은 동일합니다.


4. 데드락 — 멈춰서 안 풀리는 상태

4-1. 데드락의 4가지 필요 조건 (Coffman 조건)

네 조건이 *동시에* 충족돼야 데드락이 일어납니다. 거꾸로, *하나만 깨도* 데드락은 사라집니다. 예방 규칙은 모두 이 네 가지를 깨는 방법입니다.

조건 의미 깨는 법
상호 배제 자원은 한 번에 한 스레드만 점유 (락의 본질이라 깨기 어려움)
점유와 대기 자원을 들고 다른 자원을 기다림 필요한 락을 *한꺼번에* 잡거나 기다리는 동안 들고 있던 락을 놓는다
비선점 들고 있는 락을 빼앗을 수 없음 Monitor.TryEnter 처럼 *타임아웃·실패* 가능한 API 사용
순환 대기 A→B, B→A 로 사이클이 만들어짐 락 획득 순서를 전역적으로 통일

4-2. 가장 작은 데드락 재현 코드

C#
using System.Threading;

class DeadlockDemo
{
    static readonly object lockA = new();
    static readonly object lockB = new();

    static void Main()
    {
        var t1 = new Thread(() =>
        {
            lock (lockA)
            {
                Thread.Sleep(50);          // 상대가 lockB 를 잡을 시간 확보
                lock (lockB) { /* unreached */ }
            }
        });

        var t2 = new Thread(() =>
        {
            lock (lockB)
            {
                Thread.Sleep(50);
                lock (lockA) { /* unreached */ }
            }
        });

        t1.Start(); t2.Start();
        t1.Join();  t2.Join();              // 영원히 안 끝남
    }
}

4-3. 그림으로 보는 순환 대기

순환 대기 — 두 스레드가 서로의 락을 기다림

4-4. 한 줄만 고치면 풀리는 이유

위 코드를 두 스레드 모두 lockA → lockB 순서로 통일 하면 데드락이 사라집니다.

C#
// t2 의 락 순서를 t1 과 같게 맞춤
var t2 = new Thread(() =>
{
    lock (lockA)        // 먼저 lockA
    {
        Thread.Sleep(50);
        lock (lockB) { /* ok */ }
    }
});

A 를 먼저 잡은 한 스레드만 진행하고, 나머지는 *A 부터* 차분히 기다립니다. 순환이 안 만들어집니다 — Coffman 조건 4(순환 대기)가 깨졌습니다.


5. 예방 규칙 세 가지 — "감 잡기" 단계에서 이것만 지켜도 80%

규칙 1. 락 획득 순서를 *전역적으로* 같게 유지한다

여러 락을 함께 잡아야 한다면, *어떤 코드 경로에서든* 같은 순서로 잡습니다. 객체에 정렬 가능한 식별자(예: RuntimeHelpers.GetHashCode, ID) 가 있으면 작은 쪽 먼저 잡는 식으로 *기계적으로* 정합니다.

C#
// 두 계좌를 동시에 잠가야 할 때 — ID 작은 쪽부터
public static void Transfer(Account from, Account to, int amount)
{
    var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
    lock (first.Gate)
    lock (second.Gate)
    {
        from.Balance -= amount;
        to.Balance   += amount;
    }
}

규칙 2. 락 안에서 *외부* 코드를 호출하지 않는다

이벤트 발행, 콜백, 가상 메서드, 외부 라이브러리 호출은 *어떤 락을 추가로 잡을지 알 수 없습니다.* 락을 쥔 채로 그런 코드를 부르면 의도치 않은 순환이 만들어집니다.

C#
// ❌ 위험 — 락 안에서 콜백 호출
lock (_gate)
{
    _items.Add(item);
    OnItemAdded?.Invoke(item);   // 구독자가 또 다른 락을 잡으면? 데드락 가능성
}

// ✅ 안전 — 락 밖으로 빼고 발행
ItemAddedEvent? toRaise = null;
lock (_gate)
{
    _items.Add(item);
    toRaise = OnItemAdded;
}
toRaise?.Invoke(item);

규칙 3. 락 범위는 *최대한 짧게*

락을 들고 있는 시간이 길수록 다른 스레드가 굶고, 데드락이 만들어질 수 있는 *틈* 이 늘어납니다. 락 안에서는 *공유 상태 변경* 만 하고, 계산·I/O·로깅은 락 밖에서 합니다.

C#
// ❌ 락 안에서 무거운 작업
lock (_gate)
{
    var json = JsonSerializer.Serialize(_state);   // CPU 무거움
    File.WriteAllText("dump.json", json);          // I/O — 절대 락 안에서 X
}

// ✅ 스냅샷만 락 안에서 만들고 락 밖에서 처리
State snapshot;
lock (_gate)
{
    snapshot = _state.Clone();
}
var json = JsonSerializer.Serialize(snapshot);
File.WriteAllText("dump.json", json);

6. Unity 입문자가 만나기 쉬운 함정

Unity 게임 로직은 대부분 *메인 스레드* 에서 돌아갑니다. 그래서 입문자는 "나는 멀티스레딩 안 쓰는데"라고 생각하기 쉽습니다. 그러나 다음 상황은 *모르는 사이* 다른 스레드를 끌어옵니다.

  • Task.Run — 무거운 계산을 백그라운드로 보낸 뒤, 그 결과를 메인 스레드의 게임 상태에 반영할 때 둘 사이 공유 변수에 경쟁 상태가 생깁니다.
  • async/await + ConfigureAwait(false)await 이후 코드가 *백그라운드* 스레드에서 이어지는데, 그 코드에서 Unity API 를 부르면 충돌합니다. (Unity API 는 거의 다 *메인 스레드 전용* 입니다.)
  • C# Job System / Burst — 프레임워크 차원에서 안전 검사를 강제하지만, *static* 필드나 외부 컬렉션을 만지면 여전히 경쟁 상태가 가능합니다.
  • 이벤트 콜백 — 네트워크 라이브러리(예: 일부 소켓·SDK) 콜백이 *백그라운드 스레드* 에서 호출됩니다. 거기서 GameObject 를 직접 만지면 즉시 예외 또는 더 미묘한 깨짐이 생깁니다.
현실적인 입문자 가이드라인 — 백그라운드 스레드에서는 *데이터만 계산해서 큐로 메인 스레드에 넘기고*, Unity API 는 항상 메인 스레드에서 호출합니다. 공유하는 단순 카운터·플래그는 Interlocked 로 막고, 그 이상 복잡한 상태는 메시지 큐(예: ConcurrentQueue<T>) 한 곳을 두고 *한 스레드만 쓰기* 패턴으로 잡으면 대부분의 사고를 피할 수 있습니다.

7. 한눈 비교 — Interlocked vs lock

항목 Interlocked lock (Monitor)
보호 범위 단일 변수, 한 연산 여러 줄·여러 변수
비용 매우 저렴 (CPU 명령 1~2개 수준) 상대적으로 비쌈 (경쟁 시 OS 대기)
데드락 가능성 거의 없음 (단일 연산이라 사이클 없음) *순서 통일 안 하면* 가능
적합한 예 카운터, 플래그, 시퀀스 ID 발급 컬렉션 변경, 두 필드 동시 갱신
함정 int/long/ref 타입 위주, 복합 로직 표현 한계 락 객체 잘못 고르거나 외부 호출 들이는 실수

원칙 — 단일 변수는 Interlocked, 그 외는 lock. 락이 꼭 필요한 상황에서만 락을 씁니다.


심화 커리큘럼 (이 글의 범위 밖)

본격적으로 멀티스레딩 디버깅·튜닝에 들어가면 다음 주제가 따라옵니다. 이 시리즈에서는 별도 글로 다룹니다.
  • 메모리 모델·메모리 배리어: volatile, Volatile.Read/Write, Interlocked.MemoryBarrier — JIT/CPU 재배치를 *언제* 막아야 하는가.
  • 재진입 락(Reentrant Lock): 같은 스레드가 같은 락을 다시 잡을 때의 동작과 함정.
  • SemaphoreSlim, ReaderWriterLockSlim, System.Threading.Lock 같은 추가 동기화 프리미티브.
  • Lock-free 자료구조: CompareExchange 기반의 큐·스택·해시맵 설계.
  • 탐지 도구: ThreadSanitizer (Linux/Clang), Visual Studio Concurrency Visualizer, BenchmarkDotNet 의 동시성 모드, Unity Burst 의 aliasing 검사.
  • Async 동기화 컨텍스트와 데드락: WinForms/WPF/SynchronizationContext 위에서 .Result/.Wait() 가 만드는 클래식 데드락.

8. 정리

  • 멀티스레드 코드의 두 대표 버그: 결과가 *틀려지는* 경쟁 상태, 결과 없이 *멈추는* 데드락.
  • 경쟁 상태의 뿌리는 *비원자적 연산* 과 *재배치*. 단일 변수는 Interlocked 로, 여러 줄은 lock 으로 막습니다.
  • 데드락은 *순환 대기* 가 핵심 — 락 순서 통일, 락 안에서 외부 호출 금지, 락 범위 최소화 세 규칙으로 거의 막습니다.
  • Unity 메인 스레드 환경이라도 Task.Run·async/await·콜백·Job System 사용 시 동일한 규칙이 그대로 적용됩니다.
  • 더 깊은 메모리 모델·탐지 도구·Lock-free 설계는 심화 커리큘럼에서 이어 갑니다.
반응형

+ Recent posts