1. 고전적인 코프만의 조건
1971년에 컴퓨터 과학자 에드워드 코프만 (Edward G. Coffman Jr.)은 deadlock이 발생하려면 반드시 동시에 만족되어야 하는 네 가지 조건을 정의했어. 이 규칙들은 멀티스레딩 면접이나 강의에서 자주 나오고, 단순히 외우는 것뿐만 아니라 왜 필요한지 이해하는 게 중요하다:
- 상호 배제 (Mutual Exclusion): 적어도 하나의 자원은 한 번에 오직 하나의 스레드만 점유할 수 있다.
- 보유하고 대기 (Hold and Wait): 한 자원을 점유한 스레드가 다른 자원을 기다릴 수 있다.
- 강제 회수 불가 (No Preemption): 자원을 강제로 빼앗을 수 없다 — 자원을 해제하는 건 그 스레드 자신뿐이다.
- 원형 대기 (Circular Wait): 각 스레드가 다음 스레드가 점유한 자원을 기다리는 원형 대기 체인이 존재한다.
이 네 가지 조건이 모두 만족되면 deadlock이 발생할 수 있다. 그 중 하나라도 깨뜨리면 deadlock 가능성은 사라진다.
빠른 의사결정
flowchart TD
A[여러 자원을 잠가야 해?] -- 아니요 --> B[기본 lock 사용]
A -- 예 --> C[항상 같은 순서로 획득할 수 있어?]
C -- 예 --> D[항상 같은 순서로 취득]
C -- 아니요 --> E[중첩 lock 피하기]
D --> F[잠금 시간 최소화]
E --> F
B --> F
F --> G{여전히 불안해?}
G -- 예 --> H[타임아웃과 재시도 추가]
G -- 아니요 --> I[안심해도 돼!]
2. Deadlock 예방의 주요 전략
deadlock은 멀티스레드 세계의 현실적인 문제라서, 우리의 목표는 그것과 공존하는 법을 배우는 거야. 단순한 것부터 영리한 방법까지 여러 전략이 도움이 된다.
항상 같은 순서로 자원을 잠가
핵심은 단순해: 모든 스레드가 항상 같은 순서로 자원을 획득하면 원형 대기는 애초에 발생할 수 없어. 이 방법을 흔히 ordered locking — '순서화된 잠금'이라고 부른다.
예제
위험한 코드:
// 스레드 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// 스레드 2
lock (resB)
{
lock (resA)
{
// ...
}
}
여기서는 deadlock이 발생할 수 있어: 첫 번째 스레드는 resA를 잡고 resB를 기다리고, 두 번째는 그 반대야. 둘 다 대기 상태로 빠져서 절대 풀리지 않는다.
올바른 코드:
// 항상: 먼저 resA, 그다음 resB (절대 반대로 하지 않음)
lock (resA)
{
lock (resB)
{
// ...
}
}
이제 순서가 일관되므로 deadlock은 발생하지 않아. 자원의 수나 이름이 어떻게 되든, 모든 스레드가 동일한 순서로 lock을 걸면 된다.
흔한 실수: 코드 어딘가에 한 군데라도 순서가 어긋나면 deadlock 위험이 다시 돌아온다. 그러니 주의해!
여러 자원을 동시에 잠그는 걸 피해
가장 간단한 방법은 가능한 한 두 개 이상의 자원을 동시에 잠그지 않는 거야. 중첩된 잠금이 많을수록 걸릴 위험도 커진다. 대신 필요한 데이터를 임시 변수로 복사해서 잠금을 풀고, 그다음에 다른 작업을 하는 식으로 처리해라.
잠금 획득에 타임아웃을 사용해
여러 잠금을 꼭 얻어야 한다면 타임아웃이 큰 도움이 된다. 예를 들어 보통의 lock 대신 잠금을 '시도'하고 실패하면 깔끔하게 되돌리는 방식, 즉 Monitor.TryEnter 같은 걸 사용해라.
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// 두 잠금을 최대 2초까지 시도
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // 못 잡으면 돌아감
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// 임계 구역
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
두 번째 잠금을 2초 안에 못 잡으면 첫 번째를 풀고 돌아온다. 이렇게 하면 deadlock이 생기지 않고, 일부 스레드가 시도를 포기하고 롤백하게 된다.
임계 구역 안의 코드 양을 최소화해
임계 구역(잠금 상태)에 들어 있는 코드가 짧을수록 다른 스레드가 기다리는 시간이 줄어든다. 복잡한 계산, 네트워크 호출, 디스크 I/O 같은 건 임계 구역 밖에서 하도록 해라. 잠금을 잡았으면 빠르게 공유 자원을 수정하고 해제하는 게 원칙이다.
상태를 분리해 — 공용 자원 회피
때로는 잠금과 싸우기보다 공용 상태를 피하는 게 더 낫다:
- 불변 객체(immutable)를 사용해라.
- 글로벌 대신 복사본이나 로컬 변수로 작업해라.
- 메시지 전달 방식(Actor Model, 메시지 큐)을 사용해라.
- ConcurrentDictionary, ConcurrentQueue 등 System.Collections.Concurrent의 thread-safe 컬렉션을 활용해라.
3. 이미 Deadlock이 발생했을 때의 해결
수동으로 프로세스 종료 및 재시작 (최선은 아님)
가장 쉬운 방법은 걸린 모든 프로세스를 죽이는 거다. 하지만 데이터 손실 위험이 있고, 최후의 수단으로만 써라.
런타임에서 Deadlock 탐지
일부 시스템은 deadlock을 자동으로 감지할 수 있다. 스레드가 너무 오래 잠금을 못 잡으면 로깅을 남기고 모니터링 알림을 보내거나 복구를 시작할 수 있다.
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("deadlock에 빠졌거나 누군가 잠금을 너무 오래 잡고 있는 것 같아요!");
// 복구를 시작하거나 덤프를 남기거나 사용자에게 알릴 수 있다
}
분산 시스템에서는 대기 그래프를 분석해서 걸린 트랜잭션을 강제로 풀어주는 별도 데텍터를 쓰기도 한다.
자동 롤백과 재시도
현실적인 방법은 '포기하고 다시 시도'하는 거다: 모든 잠금을 제때 못 잡으면 롤백하고, 잠깐(보통 랜덤한 지연으로 충돌을 분산) 기다렸다가 재시도한다.
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// 임계 구역
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// 실패했으면 기다렸다가 다시 시도
Thread.Sleep(500 + new Random().Next(500));
}
4. 모범 사례와 권장사항
- 잠금 획득 순서를 분석해라. 엔트리 포인트를 모아서 순서를 점검해라.
- 가능하면 System.Collections.Concurrent의 컬렉션을 사용해라.
- 코드 분석 도구를 활용해라. Rider나 Visual Studio 같은 IDE는 중첩된 잠금 지점을 찾는 데 도움을 준다.
- 오래 걸리는 잠금 시도를 로깅해라.
- 멀티스레드 컴포넌트에 대해 스트레스 테스트를 해라.
- 잠금 순서에 대한 규약을 문서화해라. 주석이 순서 일관성을 지키는 데 큰 도움이 된다.
5. 실전 예제
예제: 데이터베이스
DB에서는 deadlock이 흔하다: 두 트랜잭션이 같은 테이블을 서로 다른 순서로 갱신하면(A→B와 B→A) 충돌이 난다. 대부분의 DBMS는 이런 상황을 감지해서 한 트랜잭션을 죽인다.
예제: 비동기 메서드
.NET에서 async와 잠금을 섞으면 교착이 날 수 있다: await 때문에 잠금을 잡고 있던 스레드가 블록되면, 다른 스레드가 같은 자원을 기다리면서 교착 상태가 될 수 있다. 임계 구역의 경계를 잘 설계하고 그 안에서 await를 피해라.
6. 잠금 작업에서 흔한 실수
실수 №1: 문자열을 잠금 대상으로 사용.
lock (myString) — 나쁜 아이디어다. .NET 문자열은 intern되기 때문에 사실상 전역 문자열 테이블을 잠그는 꼴이 된다.
실수 №2: 다른 곳에서 다른 순서로 잠금 획득.
프로그램의 서로 다른 부분에서 잠금 순서가 다르면 deadlock 가능성이 급격히 올라간다.
실수 №3: 너무 오래 잠금 유지.
필요 이상으로 잠금을 오래 잡거나 임계 구역 안에서 외부 메서드를 호출하는 건 교착과 지연을 초래한다.
GO TO FULL VERSION