CodeGym /행동 /C# SELF /Race Conditions 심층 분석

Race Conditions 심층 분석

C# SELF
레벨 57 , 레슨 0
사용 가능

1. 더 복잡한 Race Condition 예제

Race Condition은 가장 교활한 버그 중 하나입니다. 왜일까요? 보통 드물게 발생하고, CPU 속도, OS 지연, 스레드 간의 우연한 전환에 의존하기 때문에 재현이 어렵습니다. 정적 분석기는 잡지 못하고, 유닛 테스트도(당신이 초능력이 없는 이상) 잡아내지 못합니다. 하지만 한번 발생하면 프로덕션에서 큰 골칫거리가 됩니다.

이런 불쾌한 놀라움을 피하려면 Race Condition이 어떻게 생기는지 잘 이해하고 있어야 합니다.

예제 1. 은행 윤리가 없는 은행

아주 단순한 은행 계좌 클래스가 있다고 해봅시다:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

두 스레드가 동시에 계좌에 100200을 입금한 뒤 각각 50을 출금한다고 가정해보세요. 단일 스레드 환경이라면 잔액은 항상 (0 + 100 + 200 - 50 - 50) = 200이 됩니다.

멀티스레드 현실에서는 어떻게 될까요? 실험해봅시다:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // 여기서 실행할 때마다 다른 값이 나올 수 있다!

설명:
두 스레드가 모두 Balance0으로 읽고, 하나는 100을 쓰고 다른 하나는 200을 쓰는 사이에 스케줄링 순서가 바뀌면 잔액이 돈을 '잃어버리거나' '덧붙여질' 수 있습니다. 락이 없는 은행은 사기꾼의 꿈입니다!

예제 2. 플래그 변수

플래그 변수를 상태 표시기로 쓰는 실수가 흔합니다:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // 기다림...
    }
    // 뭔가를 수행
}

다른 스레드가 isReady = true를 쓸 수 있습니다. 언뜻 보면 안전해 보이지만, 불리언 읽기/쓰기조차도 안전하지 않을 수 있습니다! 이유는 컴파일러 최적화, CPU 캐시, 멀티프로세서 환경에서의 명령 재정렬입니다.

무슨 일이 일어날 수 있나?
한 스레드는 플래그 변경을 절대 못 보고 무한 루프를 돌 수 있습니다. 스레드 간에 '플래그'를 전달할 때는 항상 특수 프리미티브를 사용하세요 (volatile, Interlocked, 이벤트 등 — 자세한 내용은 공식 문서 참조).

예제 3. null 검사와 객체 생성

싱글턴 클래스 예제를 생각해봅시다:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

코드는 사소해 보이지만, 여러 스레드가 동시에 Instance를 호출하면 여러 인스턴스가 생성될 수 있습니다! 이는 동기화 없는 double-check 패턴의 고전적 문제입니다: _instance 필드는 보호되지 않으면 레이스로 초기화될 수 있습니다.

2. 코드에서 Race Condition이 생기는 원리

읽기-수정-쓰기 연산의 해부 — 악의 구조

다시 카운터 증가를 봅시다: counter++

CPU 관점에서 이건 다음과 같습니다:

  1. 메모리에서 읽기: register = counter;
  2. 증가: register = register + 1;
  3. 다시 쓰기: counter = register;

두 스레드가 이 블록을 거의 동시에 수행하면 무슨 일이 일어날까요?

  • 둘 다 counter = 0을 읽는다.
  • 둘 다 내부 레지스터를 1로 증가시킨다.
  • 둘 다 1을 쓴다.

결과: 두 번 증가했지만 값은 단지 1만 증가했다! 한 증가가 '먹혔습니다' — 아무도 눈치채지 못합니다.

두 스레드 충돌의 시각화


스레드 A        |   스레드 B        |   counter-------------------------------------------
읽음 (0)        |                  |   0
                |   읽음 (0)       |   0
증가 (1)        |                  |   0
                |   증가 (1)       |   0
쓰기 (1)         |                  |   1
                |   쓰기 (1)       |   1  <-- 앗! 2를 기대했는데 1이 나왔다

왜 Race Condition 잡기 힘든가?

  • Race Condition은 "가려집니다". Console.WriteLine을 몇 개 넣으면 오류가 사라질 수 있습니다. 스레드 스케줄링이 바뀌기 때문입니다.
  • 오류는 코어 수, 부하, OS와 .NET 버전에 따라 달라집니다. 어제는 잘 되던 게 오늘은 망가질 수 있습니다.
  • 결과가 불안정합니다: 항상 발생하지 않고 '특정 상황'에서만 드러납니다.
  • 디버깅이 아주 골치 아픕니다.

3. 컬렉션과 .NET 클래스에서의 Race Condition

Race Condition은 변수뿐 아니라 컬렉션, 큐, 그리고 표준 .NET 클래스들에서도 발생합니다. 항상 안전하다고 가정하지 마세요.

예: List<T>는 thread-safe가 아니다

두 스레드가 동시에 보통의 List<T>.Add()를 호출하면 결과가 끔찍할 수 있습니다:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // 아마도 10000보다 작을 가능성이 높다

여기서 Race Condition은 Add() 내부에서 발생합니다. 컬렉션이 내부 배열을 확장하고 요소를 복사하는 도중에 다른 스레드가 추가 작업을 하면 데이터 손실, 예외, 또는 손상된 컬렉션이 생길 수 있습니다.

예: 여러 스레드가 같은 인덱서 사용

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

결과적으로 array[5]에는 1이나 2가 들어갈 수 있습니다 — 누가 마지막으로 썼는지에 따라. 때론 이런 동작이 의도된 경우도 있지만 대부분은 미묘하고 잡기 힘든 버그의 근원입니다.

4. 유용한 뉘앙스

Race Condition과 비원자적(비-atomic) 연산

작아 보이지만 원자성을 보장하지 않는 연산들에 특히 주의하세요.

  • i++, i--
  • a = b
  • myObject.Property = value
  • "플래그를 확인하고 값 변경"
  • "객체를 가져와 내부 상태를 변경"

이 모든 동작은 각각 원자적이지 않습니다! 단계 사이에 다른 스레드가 개입할 수 있습니다.

Race Condition 관련 신화와 함정

신화 1: "변수가 int이면 안전하다, 원시 타입이니까".
현실: int 연산도 volatile로 표시되거나 특수 API를 사용하지 않으면 원자성을 보장하지 않습니다.

신화 2: "한 스레드가 쓰고 다른 스레드가 읽기만 하면 괜찮다".
현실: 아니다! .NET 컬렉션은 동시에 읽기와 쓰기가 일어날 때 무결성을 보장하지 않습니다. 깨진 객체나 이상한 예외가 발생할 수 있습니다.

신화 3: "Race Condition은 오직 고부하 서비스에서만 발생한다".
현실: 소규모 유틸리티, 게임, 데스크탑 앱에서도 발생할 수 있습니다. 단지 드물게 또는 늦게 드러날 뿐입니다.

Race Condition을 막는 방법

  • 동기화 프리미티브 사용하기 (lock, Mutex, Monitor 등).
  • 숫자 증가/감소 같은 기본 연산에는 Interlocked 사용 (문서).
  • 컬렉션에는 스레드 안전한 전용 클래스 사용 (예: ConcurrentBag, ConcurrentDictionary 등 — 문서 참조).
  • 보호되지 않은 플래그나 상태 변수를 쓰지 않기 (필요하면 volatile, 이벤트, 동기화 프리미티브 사용).

언제 Race Condition이 발생하는가

시나리오 Race Condition이 있는가? 보호 방법
여러 스레드가 데이터를 읽음 아니오 (변경이 없을 때) -
한 스레드가 쓰고 다른 스레드가 읽음
lock/volatile
여러 스레드가 씀
lock/Interlocked
여러 스레드가 컬렉션을 변경 스레드 안전 컬렉션 사용
여러 스레드가 플래그 사용 volatile, lock, 이벤트

원자성은 만병통치약이 아니다

가끔은 원자적 연산조차 논리적 '레이스'를 막지 못합니다.

예:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

두 스레드가 동시에 이 if에 들어가면 둘 다 키가 없다고 보고 둘 다 값을 생성해서, 두 번째가 첫 번째 결과를 덮어씁니다. 이런 경우에는 단순한 원자적 int 연산이나 Interlocked로는 해결되지 않습니다. 전체 락이 필요하거나 전문화된 함수(예: ConcurrentDictionaryGetOrAdd)를 사용해야 합니다.

4. 학생들이 Race Condition 다루면서 흔히 하는 실수

실수 #1: lock은 "큰 것"에만 필요하다고 생각한다.
사실 간단한 변수도 보호 없이는 깨질 수 있다.

실수 #2: 잘못된 객체를 잠근다.
예: 외부에서 접근 가능한 문자열이나 객체를 lock에 사용하면 의도한 동기화가 동작하지 않는다.

실수 #3: "거의 항상 맞으니" 라고 믿는다.
버그가 드물게 나타난다고 보호를 안 해도 되는 건 아니다.

실수 #4: 비동기 메서드를 동기화 없이 사용한다.
운에 맡기면 예기치 못한 곳에서 엉망이 된다.

실수 #5: double-check 싱글턴을 lock 없이 구현한다.
메모리에 한 개의 객체만 있어야 하는데 여러 복사본이 생기고 디버깅이 지옥이 된다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION