Quick Reference
여러 스레드가 함께 바꾸는 하나의 상태 규칙은 lock, 단일 숫자·reference의 원자적 교환은 Interlocked, async 작업을 포함하는 독점 구간은 SemaphoreSlim, 단순 stop flag의 가시성은 Volatile 또는 volatile을 사용합니다. 가장 좋은 동기화는 공유 mutable state를 줄이는 것입니다.
private readonly System.Threading.Lock _gate = new(); // .NET 9 / C# 13+
private int _count;
public void AddOne()
{
lock (_gate)
_count++;
}
public int AddOneAtomically() => Interlocked.Increment(ref _count);- .NET 9/C# 13 이전에는 전용
private readonly object _gate = new();를 lock 대상으로 씁니다. lock본문에는await를 쓸 수 없습니다.- 같은 field를 읽고 판단하고 바꾸는 여러 단계는
Interlocked한 번만으로 보호되지 않습니다.
race condition과 lock
_count++는 read, add, write의 복합 동작입니다. 두 스레드가 같은 이전 값을 읽으면 한 증가가 사라질 수 있습니다. 잔액과 한도처럼 여러 field가 함께 만족해야 하는 invariant도 하나의 lock으로 묶습니다.
private readonly System.Threading.Lock _balanceGate = new();
private decimal _balance;
public bool TryWithdraw(decimal amount)
{
lock (_balanceGate)
{
if (amount <= 0 || amount > _balance)
return false;
_balance -= amount;
return true;
}
}lock (System.Threading.Lock)은 C# 13/.NET 9+에서 EnterScope() 기반으로 동작합니다. 다른 reference type을 lock하면 compiler는 Monitor.Enter/Exit를 try/finally로 사용하므로 예외가 나도 lock이 해제됩니다. this, typeof(...), interned string은 외부 코드도 같은 객체를 잠글 수 있으므로 대상으로 쓰지 않습니다.
lock 안에서는 공유 상태만 빠르게 읽고 갱신합니다. 파일 I/O, HTTP 호출, event handler처럼 오래 걸리거나 외부 코드를 호출하면 lock contention과 deadlock 가능성이 커집니다. 두 lock이 필요하면 항상 같은 순서로 획득합니다.
Interlocked와 가시성
Interlocked는 increment, add, exchange, compare-exchange처럼 하나의 memory location에 대한 원자 연산을 제공합니다. compare-exchange는 값이 기대값일 때만 바꾸는 CAS 패턴입니다.
private int _state;
public bool TryStart()
{
int previous = Interlocked.CompareExchange(ref _state, 1, 0);
return previous == 0;
}Interlocked operation은 동기적으로 끝나는 단일 연산입니다. await 전후에 호출할 수는 있어도 비동기 임계 구역을 열어 두는 lock은 아닙니다. 여러 field를 함께 검사·변경하거나 컬렉션 불변식을 유지해야 하면 lock 또는 그에 맞는 concurrent collection을 사용합니다.
volatile은 volatile read/write의 memory ordering과 가시성을 제공하지만 compound operation을 원자적으로 만들지 않습니다. 단순 종료 flag는 Volatile.Read/Volatile.Write로 표현할 수 있습니다.
private int _stopping;
public void Stop() => Volatile.Write(ref _stopping, 1);
public void Run()
{
while (Volatile.Read(ref _stopping) == 0)
DoOneUnit();
}async 임계 구역
lock의 body에는 await를 사용할 수 없습니다. 비동기 작업 전체가 한 번에 하나만 실행되어야 하면 SemaphoreSlim(1, 1)을 사용하고, 획득한 뒤에만 finally에서 Release합니다.
private readonly SemaphoreSlim _writeGate = new(1, 1);
public async Task SaveAsync(CancellationToken cancellationToken)
{
await _writeGate.WaitAsync(cancellationToken);
try
{
await WriteToStorageAsync(cancellationToken);
}
finally
{
_writeGate.Release();
}
}이 semaphore도 class가 소유한 disposable resource입니다. class가 더 이상 쓰이지 않을 때 Dispose하며, cancellation으로 WaitAsync가 획득 전에 실패한 경우에는 finally에 들어가지 않으므로 Release하지 않습니다.
자주 틀리는 부분
volatile int에_count++를 해도 race condition은 해결되지 않습니다.lock대상에 public 객체,this,Type, string을 사용하지 않습니다.- async method에서
lock을SemaphoreSlim으로 기계적으로 바꾸기 전에 독점 범위와 취소·timeout 정책을 정합니다. - 같은 lock 안에서 외부 callback을 호출하면 재진입·deadlock·지연이 생길 수 있습니다.
참고 링크
3 sources