기본 패턴
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++는 읽기 → 더하기 → 쓰기의 세 단계로 실행되므로 중간에 다른 스레드가 끼어들면 증가가 누락됩니다.
// ❌ 스레드 안전하지 않음
private int _count = 0;
void ThreadA() => _count++; // 동시에 실행하면 결과 불일치
void ThreadB() => _count++;lock — 임계 구역 보호
lock(obj) 는 내부적으로 Monitor.Enter / Monitor.Exit 로 변환됩니다. 한 번에 하나의 스레드만 블록 안에 진입할 수 있고, 나머지는 해제될 때까지 대기합니다.
private readonly object _syncRoot = new object(); // private readonly 전용 객체
public void SafeIncrement()
{
lock (_syncRoot) // Monitor.Enter(_syncRoot)
{
_count++;
} // Monitor.Exit(_syncRoot)
}잠금 객체는 반드시 private readonly 로 선언합니다. this나 typeof(MyClass)를 쓰면 외부 코드가 같은 객체로 lock해 데드락을 유발할 수 있습니다.
Interlocked — 원자 연산
단순 숫자 연산에는 lock 없이 Interlocked 클래스의 원자 연산이 더 빠릅니다.
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가 해당 변수를 레지스터/캐시에 최적화하지 못하도록 막습니다. 항상 메모리에서 직접 읽고 씁니다.
private volatile bool _running = true;
void WorkerThread()
{
while (_running) // 항상 최신 값을 읽음
{
// 작업
}
}
void Stop() => _running = false;volatile은 단일 읽기/쓰기의 가시성만 보장합니다. _count++처럼 복합 연산에는 volatile만으로 충분하지 않습니다.
async 환경에서의 잠금
lock 블록 안에서는 await를 사용할 수 없습니다. 비동기 임계 구역에는 SemaphoreSlim(1, 1) 을 사용합니다.
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 | 가시성만 | ✅ | 단순 플래그 변수 |
Monitor | ✅ | ❌ | lock의 저수준 API |
주의할 점
데드락은 두 스레드가 서로 다른 순서로 두 잠금을 획득하려 할 때 발생합니다. 항상 잠금 획득 순서를 일정하게 유지하고, 잠금 범위를 최소화하세요.
lock 블록 안에서 await를 호출하면 컴파일 오류가 납니다. 이미 lock 안에 있는 코드에서 비동기 작업이 필요하다면 SemaphoreSlim으로 전환하거나, lock 밖에서 결과를 받아온 뒤 lock 안에서 상태만 업데이트하는 구조로 분리하세요.
참고 링크
2 sources