← 홈으로 돌아가기
GCD Deep Dive

Deadlock과 큐 vs 스레드 완전 정리

Deadlock, Self-Deadlock, Blocking의 차이와 큐와 스레드의 관계를 Q&A 형식으로 정리

1️⃣ Deadlock의 종류

데드락(Deadlock)이란?

스레드들이 서로의 자원을 쥔 채 안 놓고 상대를 기다려 영영 진행 못 하는 순환 대기 상태입니다.

// 고전적인 Deadlock 예시 let queueA = DispatchQueue(label: "A") let queueB = DispatchQueue(label: "B") // 스레드 1: A 점유 후 B 기다림 queueA.async { print("A 작업 시작") queueB.sync { // B를 기다림 print("A에서 B 호출") } } // 스레드 2: B 점유 후 A 기다림 queueB.async { print("B 작업 시작") queueA.sync { // A를 기다림 print("B에서 A 호출") } } // → 순환 대기: A는 B를 기다리고, B는 A를 기다림 (영원히)

main.sync를 메인 스레드에서 호출하면?

Self-Deadlock이 발생합니다. 메인 큐는 메인 스레드에서만 실행되는데, 메인 스레드가 sync로 멈춰 기다리므로 자기 자신을 기다리는 상황이 됩니다.

// Self-Deadlock DispatchQueue.main.async { print("메인에서 실행 중") DispatchQueue.main.sync { // 💀 데드락! print("이 코드는 절대 실행 안 됨") } } // 왜? // 1. 메인 스레드가 sync로 블록을 기다림 // 2. 블록은 메인 큐에서만 실행됨 // 3. 메인 스레드는 이미 기다리느라 블록 실행 못 함 // → 자기 자신을 기다리는 self-deadlock
차이점:
- 고전 데드락: 스레드 2개 이상이 서로의 자원을 쥔 채 순환 대기
- Self-deadlock: 스레드 1개가 자기 자신을 기다림
- 공통점: 방아쇠는 항상 sync (기다림). async로 바꾸면 안 기다려서 데드락 안 남.

순환 대기 예시는 두 serial 큐가 서로 cross-sync 하는 것?

맞습니다. 단, 두 큐가 동시에 각자 점유 중일 때만 터지는 타이밍 의존 데드락입니다.

// 타이밍 의존 Deadlock let queueA = DispatchQueue(label: "A") let queueB = DispatchQueue(label: "B") // 동시에 실행되면 데드락 queueA.async { // A 점유 중 queueB.sync { // B 기다림 print("A→B") } } queueB.async { // B 점유 중 queueA.sync { // A 기다림 print("B→A") } } // 두 async가 정확히 같은 타이밍에 실행되면: // A는 B를 기다리고, B는 A를 기다림 → Deadlock

2️⃣ 큐 vs 스레드

큐는 스레드에게 태스크를 넘기는 것뿐?

맞습니다.
- 큐 = 태스크 대기열 + 실행 규칙
- 스레드 = 실제 일꾼
- Serial 큐 ≠ 전용 스레드 1개 (메인 큐만 메인 스레드 고정)

// Serial Queue let serialQueue = DispatchQueue(label: "serial") serialQueue.async { print("Task 1 on \(Thread.current)") } serialQueue.async { print("Task 2 on \(Thread.current)") } serialQueue.async { print("Task 3 on \(Thread.current)") } // 출력 예시 (보통은 같은 스레드 재사용): // Task 1 on worker thread 3 // Task 2 on worker thread 3 (보통 같은 스레드) // Task 3 on worker thread 5 ← 가끔 다른 스레드로 바뀔 수 있음 // Serial Queue의 보장: // ✅ Task가 순서대로 실행됨 (동시에 2개 실행 안 됨) // ⚠️ 보통은 한 스레드를 재사용하지만, 같은 스레드라는 "보장"은 없다 // (스레드가 회수/교체되면 다른 스레드일 수 있음. 메인 큐만 스레드 고정)

serial 큐가 실행 중이면 다른 태스크를 받지도 못하나?

아니요. 접수(enqueue)는 항상 가능하고, 직렬화되는 건 실행(스레드에 넘겨 돌리기)뿐입니다.

let serialQueue = DispatchQueue(label: "serial") // Task 1 실행 중 (10초 걸림) serialQueue.async { print("Task 1 시작") Thread.sleep(forTimeInterval: 10) print("Task 1 완료") } // Task 1이 실행 중이어도 Task 2, 3 접수는 즉시 가능 ✅ serialQueue.async { print("Task 2") } // 즉시 return (대기열에 추가됨) serialQueue.async { print("Task 3") } // 즉시 return (대기열에 추가됨) print("모든 async 호출 완료") // 즉시 출력 // 출력: // Task 1 시작 // 모든 async 호출 완료 ← async는 즉시 return // [10초 후] // Task 1 완료 // Task 2 ← Task 1이 끝난 후 실행 // Task 3
핵심: Serial Queue는 "한 번에 하나씩 실행"만 보장. 접수는 언제든 가능.

3️⃣ async vs sync의 진짜 의미

"어떻게 제출됐나(async)"와 "실행 중에 뭘 하나(sync로 멈춤)"는 별개다?

정확합니다. 제출이 async였어도, 일단 실행에 들어가면 안의 sync 때문에 멈춥니다.

let queue = DispatchQueue(label: "test") // async로 제출 (즉시 return) queue.async { // ← 제출 방식: async print("1. 작업 시작") // 실행 중에 sync 호출 (스레드 블로킹) queue.sync { // ← 실행 중 동작: sync (블로킹) print("2. 안쪽 작업") } print("3. 작업 완료") } // async로 제출했지만: // - 호출자는 즉시 return (async의 효과) // - 하지만 실행 중에는 sync 때문에 스레드 블로킹됨

"sync=블로킹, async=논블로킹" 맞나?

절반만 맞습니다.
- sync는 호출자 스레드를 블로킹함 ✅
- async라도 그 안에서 블로킹 호출을 하면 워커 스레드가 막힙니다
- blocking 여부는 큐가 아니라 "어떤 함수를 부르냐"가 결정합니다.

// ❌ 잘못된 생각: "async면 논블로킹이다" DispatchQueue.global().async { // async로 제출했지만... let data = try? Data(contentsOf: url) // 동기 I/O → 워커 스레드 블로킹! Thread.sleep(forTimeInterval: 10) // 스레드 블로킹! // 이 스레드는 10초 동안 다른 작업 못 함 } // ✅ 진짜 논블로킹 Task { // await는 스레드를 반환함 (논블로킹) let (data, _) = try await URLSession.shared.data(from: url) // 대기 중에 스레드가 다른 작업 처리 가능 }
결론: async 블록 내부에서 sync를 호출하면 그 (워커) 스레드는 블록됩니다. (thread explosion / cross-queue 데드락의 메커니즘)

4️⃣ 블로킹 I/O vs 논블로킹 I/O

논블로킹 I/O란?

기다리는 동안 어떤 스레드도 park 안 하고, OS 이벤트 알림으로 완료를 통보받는 방식입니다.
예: URLSession, await

// 블로킹 I/O (동기) let data = try Data(contentsOf: url) // 스레드가 멈춰서 기다림 // 이 스레드는 데이터 받을 때까지 아무것도 못 함 // 논블로킹 I/O (비동기) let (data, _) = try await URLSession.shared.data(from: url) // await: 스레드를 반환하고 다른 작업 처리 // OS가 데이터 받으면 이벤트로 알려줌 // 다시 스레드 받아서 코드 재개

블로킹 호출의 예시는?

- Data(contentsOf:) - 동기 파일/네트워크 읽기
- Thread.sleep()
- DispatchQueue.sync
- NSLock.lock()
- Semaphore.wait()
→ 모두 스레드를 잡아둠 (park)

Swift Concurrency의 장점:
- await 사용 시 자동으로 논블로킹
- Cooperative Thread Pool (CPU 코어 수만큼만 스레드 생성)
- Thread explosion 방지

5️⃣ Blocking vs Deadlock

Thread Explosion은 블로킹이지 데드락이 아니다?

정확합니다!
- Blocking: 느리지만 시간이 지나면 결국 진행됨
- Deadlock: 영원히 진행 불가 (순환 대기)

// Thread Explosion (Blocking, but not Deadlock) let serialQueue = DispatchQueue(label: "serial") for _ in 1...64 { DispatchQueue.global().async { serialQueue.sync { Thread.sleep(forTimeInterval: 10) } } } // 64개 스레드가 serial 큐를 기다림 // → Thread explosion (메모리 오버헤드, 컨텍스트 스위칭) // → Blocking (느림) // → 하지만 640초 후 모두 완료됨 ✅ (Deadlock 아님)
// Deadlock (영원히 진행 안 됨) serialQueue.sync { serialQueue.sync { // 💀 Self-deadlock print("절대 실행 안 됨") } } // → 영원히 기다림 ❌
구분 방법:
- "시간 지나면 풀리는가?" → Yes: Blocking, No: Deadlock
- "자원을 잡고 있는가?" → lock()했지만 unlock() 안 한 상태

6️⃣ Thread Explosion (스레드 폭발)

문제 1: Concurrent Queue + 블로킹 호출

// ❌ 블로킹 API (스레드 점유하고 대기) concurrentQueue.async { let data = try Data(contentsOf: url) // 동기 호출 - 스레드 멈춤 } // → 작업 100개 제출하면 스레드 100개 생성 가능 💥 // ✅ 비동기 API (스레드 반환) concurrentQueue.async { URLSession.shared.dataTask(with: url) { data, _, _ in // 완료 후 콜백 }.resume() }
블로킹:
스레드: [작업 시작]──────대기──────[응답 받음]
        ↑                         ↑
        스레드 점유 중 (아무것도 못함)

비동기:
스레드: [작업 시작][다른 작업...]
                      ↓
콜백:            [응답 받음 → 처리]
        

문제 2: Private Concurrent Queue 남발

// ❌ 각자 스레드 풀 생성 → 스레드 폭발 let queue1 = DispatchQueue(label: "q1", attributes: .concurrent) let queue2 = DispatchQueue(label: "q2", attributes: .concurrent) let queue3 = DispatchQueue(label: "q3", attributes: .concurrent) // ✅ Global Queue 사용 DispatchQueue.global(qos: .userInitiated).async { ... }

해결: Serial Queue는 target을 Global로

// ❌ 독립 serial queue (자체 스레드 관리) let serialQueue = DispatchQueue(label: "serial") // ✅ Global queue를 target으로 (스레드 풀 공유) let serialQueue = DispatchQueue( label: "serial", target: DispatchQueue.global() )
target 없이 Serial Queue 여러 개:
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ Serial A     │   │ Serial B     │   │ Serial C     │
│ └─ 스레드 1  │   │ └─ 스레드 2  │   │ └─ 스레드 3  │
└──────────────┘   └──────────────┘   └──────────────┘
→ 각자 스레드 관리 → 큐 많아지면 스레드 폭발

target: global() 설정:
┌──────────────┐
│ Serial A ────┼───┐
├──────────────┤   │   ┌─────────────────────┐
│ Serial B ────┼───┼──→│ Global Queue        │
├──────────────┤   │   │ └─ 스레드 풀 (공유) │
│ Serial C ────┼───┘   └─────────────────────┘
└──────────────┘
→ 직렬성은 유지, 스레드는 공유
        
핵심:
- Serial Queue의 직렬 실행 보장은 유지됨 (A 작업끼리는 순서대로)
- 실제 스레드는 Global Queue 풀에서 할당 (스레드 수 절약)

블로킹 네트워크 API 예시

// ❌ 블로킹 (스레드 점유) let data = try Data(contentsOf: url) let string = try String(contentsOf: url) // ✅ 비동기 (스레드 반환) URLSession.shared.dataTask(with: url) { ... } await URLSession.shared.data(from: url)

📌 핵심 정리

1. Deadlock의 종류

2. 큐 vs 스레드

3. async vs sync

4. 블로킹 I/O vs 논블로킹 I/O

5. Blocking vs Deadlock