C#비동기와 이벤트

스레드 안전과 lock

공유 상태에서 발생하는 레이스 컨디션, lock 키워드의 동작 원리(Monitor.Enter/Exit), Interlocked 원자 연산, volatile의 역할을 정리합니다.

마지막 수정 2026년 3월 26일

기본 패턴

csharp
private readonly object _syncRoot = new();
private int _count = 0;

// lock으로 임계 구역 보호
public void Increment()
{
    lock (_syncRoot)
    {
        _count++;
    }
}

// 단순 카운터는 Interlocked이 더 가볍다
private int _atomicCount = 0;
public void AtomicIncrement()
    => Interlocked.Increment(ref _atomicCount);

// async 환경 — lock 대신 SemaphoreSlim
private readonly SemaphoreSlim _sem = new(1, 1);
public async Task SafeWriteAsync()
{
    await _sem.WaitAsync();
    try   { /* 작업 */ }
    finally { _sem.Release(); }
}

설명

레이스 컨디션

여러 스레드가 공유 변수를 동시에 읽고 쓸 때 결과가 실행 순서에 따라 달라지는 문제입니다. _count++는 읽기 → 더하기 → 쓰기의 세 단계로 실행되므로 중간에 다른 스레드가 끼어들면 증가가 누락됩니다.

csharp
// ❌ 스레드 안전하지 않음
private int _count = 0;
void ThreadA() => _count++;  // 동시에 실행하면 결과 불일치
void ThreadB() => _count++;

lock — 임계 구역 보호

lock(obj) 는 내부적으로 Monitor.Enter / Monitor.Exit 로 변환됩니다. 한 번에 하나의 스레드만 블록 안에 진입할 수 있고, 나머지는 해제될 때까지 대기합니다.

csharp
private readonly object _syncRoot = new object();  // private readonly 전용 객체

public void SafeIncrement()
{
    lock (_syncRoot)   // Monitor.Enter(_syncRoot)
    {
        _count++;
    }                  // Monitor.Exit(_syncRoot)
}

잠금 객체는 반드시 private readonly 로 선언합니다. thistypeof(MyClass)를 쓰면 외부 코드가 같은 객체로 lock해 데드락을 유발할 수 있습니다.

Interlocked — 원자 연산

단순 숫자 연산에는 lock 없이 Interlocked 클래스의 원자 연산이 더 빠릅니다.

csharp
private int _counter = 0;

Interlocked.Increment(ref _counter);       // _counter++ 원자적 실행
Interlocked.Decrement(ref _counter);       // _counter--
Interlocked.Add(ref _counter, 5);          // _counter += 5

// 비교 후 교환 (CAS)
int old = Interlocked.CompareExchange(ref _counter, 10, 0);
// _counter가 0이면 10으로 변경, 이전 값 반환

volatile — 캐시 가시성

volatile 키워드는 컴파일러와 CPU가 해당 변수를 레지스터/캐시에 최적화하지 못하도록 막습니다. 항상 메모리에서 직접 읽고 씁니다.

csharp
private volatile bool _running = true;

void WorkerThread()
{
    while (_running)   // 항상 최신 값을 읽음
    {
        // 작업
    }
}

void Stop() => _running = false;

volatile은 단일 읽기/쓰기의 가시성만 보장합니다. _count++처럼 복합 연산에는 volatile만으로 충분하지 않습니다.

async 환경에서의 잠금

lock 블록 안에서는 await를 사용할 수 없습니다. 비동기 임계 구역에는 SemaphoreSlim(1, 1) 을 사용합니다.

csharp
private readonly SemaphoreSlim _sem = new SemaphoreSlim(1, 1);

public async Task<string> GetDataAsync()
{
    await _sem.WaitAsync();
    try
    {
        return await _httpClient.GetStringAsync(url);
    }
    finally
    {
        _sem.Release();  // 반드시 finally에서 해제
    }
}

빠른 정리

방법스레드 안전async 가능적합한 상황
lock복합 연산, 객체 상태 보호
Interlocked단순 숫자 증감/교환
SemaphoreSlim(1,1)async 임계 구역
volatile가시성만단순 플래그 변수
Monitorlock의 저수준 API

주의할 점

데드락은 두 스레드가 서로 다른 순서로 두 잠금을 획득하려 할 때 발생합니다. 항상 잠금 획득 순서를 일정하게 유지하고, 잠금 범위를 최소화하세요.

lock 블록 안에서 await를 호출하면 컴파일 오류가 납니다. 이미 lock 안에 있는 코드에서 비동기 작업이 필요하다면 SemaphoreSlim으로 전환하거나, lock 밖에서 결과를 받아온 뒤 lock 안에서 상태만 업데이트하는 구조로 분리하세요.

참고 링크

2 sources