Menu

C# lock 문: 경쟁 상태, Interlocked, 교착 상태

lock 문은 한 번에 한 스레드만 코드 블록을 실행하게 합니다. 경쟁 상태가 카운터를 망가뜨리는 모습을 보고, lock으로 고치고, 어떤 객체를 잠글지, 단순한 카운터에 Interlocked 쓰기, 잠금 순서로 인한 교착 상태 피하기, 안에서 await할 때 SemaphoreSlim 쓰기를 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

여러 스레드가 같은 데이터를 동시에 읽고 쓰면 갱신이 사라지거나 반쯤 끝난 상태로 보일 수 있습니다. lock 문은 코드 블록을 상호 배타적으로 만듭니다. 한 스레드가 그 안에 있는 동안, 같은 객체의 lock에 도달한 다른 모든 스레드는 차례를 기다립니다.

문제: 경쟁 상태

태스크 네 개가 각각 공유 카운터에 100,000을 더합니다. 답은 400,000이어야 합니다:

출력 예:

Expected 400000, got 245609

총합은 보통 모자라게 나오며, 실행할 때마다 모자라는 양이 다릅니다(단일 코어 컴퓨터에서는 가끔 맞기도 해서 이런 버그를 잡기 어렵습니다). count++는 연산 하나처럼 보이지만 실제로는 세 개입니다. count를 읽고, 하나를 더하고, 다시 씁니다. 두 스레드가 모두 500을 읽고, 모두 501을 계산하고, 모두 501을 쓰면 증가 하나가 사라집니다.

해결책: lock

읽기, 수정, 쓰기를 공유 객체에 대한 lock으로 감싸세요:

출력:

Expected 400000, got 400000

이제 한 번에 한 스레드만 블록 안에 있을 수 있으므로, 각 증가는 다음 스레드가 값을 읽기 전에 완료됩니다. 잠금은 가시성도 보장합니다. 잠금에 들어가는 스레드는 이전 소유자가 떠나기 전에 한 모든 쓰기를 봅니다.

보호는 count에 대한 모든 접근이 같은 잠금을 거칠 때만 동작합니다. 프로그램 다른 곳의 잠그지 않은 count++ 하나가 경쟁을 되돌려 놓습니다.

lock이 컴파일되는 모습

lock은 블록이 예외를 던져도 잠금이 해제되도록 try/finally를 쓰는 Monitor 클래스의 축약형입니다:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor는 영원히 기다리는 대신 시간 제한 뒤 포기하는 TryEnter(obj, timeout)과, 스레드 간 신호를 위한 Wait/Pulse도 제공합니다. 이미 잠금을 가진 스레드는 다시 들어갈 수 있으므로(잠금은 재진입 가능), 잠긴 메서드가 같은 객체로 잠그는 다른 메서드를 호출할 수 있습니다.

잠금 객체 고르기

lock (...) 안의 객체는 스레드들이 합의하는 토큰일 뿐입니다. 규칙:

  • object 타입의 private 읽기 전용 필드를 쓰세요. private readonly object sync = new object();. private이면 외부 코드가 같은 잠금을 잡을 수 없고, readonly면 스레드가 잠금을 가진 동안 토큰이 바뀔 수 없습니다.
  • 절대 lock (this)를 쓰지 마세요. 객체에 대한 참조를 가진 누구나 그것을 잠글 수 있고, 그러면 그 코드가 여러분의 코드를 막거나 함께 교착 상태에 빠집니다.
  • 절대 문자열이나 typeof(...)를 잠그지 마세요. 문자열 리터럴은 인턴되고(프로세스의 모든 "orders"가 같은 객체), Type 객체는 앱 전체에서 공유되므로, 관련 없는 코드가 같은 잠금을 두고 다투게 될 수 있습니다.
  • 절대 값 형식을 잠그지 마세요. int에 대한 lock (count)는 컴파일되지 않고(CS0185 오류), 직접 박싱하면 매번 새 객체가 만들어지므로 아무것도 잠그지 못합니다.
  • 일관성을 유지해야 하는 데이터 집합마다 잠금 하나를 쓰세요. 정적 데이터에는 static 잠금 필드를, 인스턴스별 데이터에는 인스턴스 필드를 씁니다.

여러 단계 연산 보호하기

lock이 가장 필요한 곳은 검사와 동작이 함께 일어나야 하는 곳입니다. 스레드 안전한 계좌가 검사 후 동작 패턴과 두 필드의 일관된 스냅샷을 모두 보여 줍니다:

출력:

10 left after 33 withdrawals

잠금이 없으면 두 스레드가 모두 잔액 40을 보고, 모두 검사를 통과하고, 모두 30을 출금해서 계좌가 마이너스 20이 될 수 있습니다. Summary 메서드도 잠그므로, 출금 뒤의 balance와 출금 전의 withdrawals 개수를 함께 보고하는 일이 절대 없습니다.

Dictionary<TKey, TValue>와 List<T>도 스레드로부터 안전하지 않습니다. 동시 쓰기는 갱신을 잃는 것만이 아니라 내부 배열을 망가뜨릴 수 있습니다. 잠금으로 보호하거나 ConcurrentDictionary<TKey, TValue>와 System.Collections.Concurrent의 다른 타입을 쓰세요.

단일 값을 위한 Interlocked

공유 상태가 정수 하나나 참조 하나이고 갱신이 한 단계라면, Interlocked 클래스가 잠금 없이 원자적으로 처리합니다:

출력:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment, Decrement, Add, Exchange는 원자적 프로세서 연산(x64에서는 명령어 하나)에 대응합니다. CompareExchange(ref location, newValue, expected)는 위치가 여전히 expected를 담고 있을 때만 쓰고, 발견한 값을 반환하므로 어떤 갱신이든 재시도 루프로 만들 수 있습니다. 변수 두 개를 한꺼번에 건드리는 것에는 여전히 lock이 필요합니다.

교착 상태

교착 상태는 두 스레드가 각각 상대가 필요로 하는 잠금을 가지고 있을 때 일어납니다:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

스레드 1이 accountA를, 스레드 2가 accountB를 잡으면 각자 상대를 영원히 기다립니다. 예외도 시간 제한도 없이 프로그램이 멈춥니다. "보내는 쪽"을 잠근 뒤 "받는 쪽"을 잠그는 두 계좌 간 송금은 반대 방향의 송금 두 개가 동시에 실행될 때 정확히 이 상황을 만듭니다.

표준적인 방어책:

  • 고정된 전역 순서로 잠그세요. 송금이라면 돈이 어느 방향으로 가든 ID가 작은 계좌를 먼저 잠급니다.
  • 잠금은 짧게 유지하고 느린 작업(I/O, 로깅, 네트워크 호출)은 밖에서 하세요.
  • 잠금을 가진 채 알 수 없는 코드를 호출하지 마세요. 이벤트, 콜백, 가상 메서드는 자기 잠금을 잡을 수 있습니다.
  • 멈추는 것이 실패보다 나쁜 곳에서는 시간 제한과 함께 Monitor.TryEnter를 쓰세요.

lock 안에서는 await 금지: SemaphoreSlim을 쓰세요

lock 블록 안에서는 await가 허용되지 않습니다(컴파일러 오류 CS1996). await 뒤에 메서드가 다른 스레드에서 이어질 수 있는데, Monitor 잠금은 잡은 스레드가 해제해야 하기 때문입니다. 비동기 코드에서는 개수가 1인 SemaphoreSlim이 비동기와 호환되는 잠금 역할을 합니다:

출력:

5 saved, one at a time

WaitAsync는 항상 finally의 Release와 짝지으세요. 그렇지 않으면 예외가 게이트를 영원히 닫아 둡니다. SemaphoreSlim(3, 3)은 호출자 셋을 한꺼번에 들여보내며, 속도 제한이 있는 API에 대한 동시 요청 수를 제한하는 방법입니다.

System.Threading.Lock(.NET 9)

.NET 9와 C# 13은 전용 System.Threading.Lock 타입을 추가했습니다. lock 문의 객체가 Lock이면 컴파일러는 Monitor 대신 더 빠른 EnterScope API를 씁니다:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

객체를 고르는 규칙은 그대로입니다. 이전 버전에서는 private readonly object가 올바른 선택입니다.

흔한 실수

  • 일부 접근만 잠그고 전부 잠그지 않기. 공유 데이터의 모든 읽기와 쓰기가 같은 잠금을 잡아야 합니다.
  • lock (this), lock (typeof(X)), lock ("name"). 외부 코드가 같은 잠금을 잡을 수 있습니다.
  • 잠금 안에서 느린 I/O 하기. 다른 모든 스레드가 기다립니다. 블록을 짧게 유지하세요.
  • 여러 곳에서 두 잠금을 다른 순서로 잡기. 전형적인 교착 상태입니다.
  • await 주위에 lock 쓰기. 컴파일되지 않습니다. SemaphoreSlim을 쓰세요.
  • 호출마다 새 잠금. lock (new object())는 아무것도 보호하지 않습니다. 객체가 공유되어야 합니다.

자주 묻는 질문

C#에서 lock은 무엇을 하나요?

lock (obj) { ... }는 주어진 잠금 객체에 대해 한 번에 한 스레드만 블록을 실행하게 합니다. 같은 객체의 lock에 도달한 두 번째 스레드는 첫 번째가 블록을 떠날 때까지 기다립니다. lock은 try/finally 안의 Monitor.Enter와 Monitor.Exit으로 컴파일되므로, 블록이 예외를 던져도 잠금이 해제됩니다.

C#에서 어떤 객체를 잠가야 하나요?

전용 private 필드입니다: private readonly object _sync = new object();. 참조 형식이어야 하고, 보호되는 데이터를 건드리는 모든 스레드가 공유해야 하며, 클래스 밖에서 접근할 수 없어야 합니다. this, Type(typeof(MyClass)), 문자열은 절대 잠그지 마세요. 다른 코드가 같은 객체를 잠가서 여러분을 막거나 교착 상태에 빠뜨릴 수 있습니다.

언제 lock 대신 Interlocked를 써야 하나요?

공유 상태가 숫자나 참조 하나이고 연산이 한 단계일 때입니다: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange나 CompareExchange. 이들은 원자적 하드웨어 연산이며 잠금보다 빠릅니다. 필드 여러 개나 컬렉션을 건드리는 것에는 lock을 쓰세요.

C#에서 lock 안에서 await를 쓸 수 있나요?

아니요. await 뒤의 코드가 잠금을 가진 스레드와 다른 스레드에서 재개될 수 있으므로, 컴파일러가 lock 블록 안의 await를 거부합니다(CS1996 오류). 대신 개수가 1인 SemaphoreSlim을 쓰세요: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

lock으로 교착 상태는 어떻게 생기나요?

스레드 1이 잠금 A를 가지고 잠금 B를 기다리는 동안, 스레드 2가 잠금 B를 가지고 잠금 A를 기다립니다. 어느 쪽도 진행할 수 없고, 프로그램은 예외 없이 멈춥니다. 여러 잠금은 항상 같은 전역 순서로 잡고, 잠금은 가능한 한 짧게 유지하고, 잠금을 가진 채 알 수 없는 코드를 호출하지 않는 것으로 예방하세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기