← 홈으로
Swift Concurrency · Deep Q&A

Swift Concurrency 심화 Q&A

2026-07-05 학습 세션. structured/unstructured 상속 관계, actor executor와 스레드, reentrancy, Sendable 경계, 취소 전파를 문답으로 정리한다.

먼저 핵심 구분 4가지
  • actor는 락이 아니라 "직렬 실행자" — 겹치면 블로킹이 아니라 await suspend
  • @MainActor는 "스레드"가 아니라 "격리 도메인" — 결과적으로 메인 스레드에 묶일 뿐
  • Sendable은 런타임 방어가 아니라 컴파일 타임 검사 — 위반 시 빌드 실패
  • actor reentrancy — 메서드 중간에 await하면 그 틈에 다른 호출이 재진입
기준표

structured / unstructured / detached 상속 관계

종류actor 컨텍스트우선순위task-local취소 자동전파
structured
(async let, TaskGroup)
unstructured
Task {}
❌ (수동 cancel 필요)
detached
Task.detached {}
흔한 오해: "unstructured Task {}는 우선순위를 상속 안 한다" → 틀림. Task {}는 actor 컨텍스트·우선순위·task-local을 모두 상속하고, 취소 자동전파만 안 한다. 넷 다 상속 안 하는 건 Task.detached다.

"Task에 (값을) 넘긴다"가 무슨 말인가?

값이 한 격리 도메인 → 다른 격리 도메인으로 전달되는 것. 3가지 상황 전부 해당한다.

let user = User()          // 현재 컨텍스트의 값
Task {
    process(user)          // ① user가 새 Task(다른 도메인)로 캡처되어 넘어감
}
await actor.save(user)     // ② actor 메서드 인자로 넘김 = 경계 넘김
let r = await actor.load() // ③ actor에서 값을 받아옴 = 경계 넘김

이때 넘어가는 값이 Sendable이 아니면 두 도메인이 같은 객체를 동시에 만질 수 있어 레이스 위험 → 컴파일러가 막는다.

actor가 suspend된 뒤 다른 스레드로 재배정될 수 있나? (3번 → 8번?)

그렇다. actor executor는 "직렬(한 번에 하나)"만 보장하지 "같은 스레드"는 보장하지 않는다.

  • await로 suspend → 재개 시 협력 스레드 풀의 아무 스레드에서 재개될 수 있음 → suspend 전 thread 3, 재개 후 thread 8 가능
  • 그래서 thread-local storage에 의존하면 안 됨 (Concurrency 금기)
  • 예외 @MainActor — executor가 메인 디스패치 큐라 항상 메인 스레드. 일반 actor는 스레드 고정 안 함
핵심: actor가 지키는 건 "상태 접근의 상호배제"지 스레드 동일성이 아니다.

reentrancy — "suspend 전에 balance를 수정하면 문제인가?"

반대다. suspend 전에 검사+수정을 다 끝내면 안전하고, 검사와 수정 사이에 await가 끼면 위험하다.

// ❌ 위험: 검사 → await(재진입 창) → 수정
guard balance >= amount else { return }   // 검사
await log()                               // ⚠️ 이 사이 다른 호출이 balance 바꿈
balance -= amount                         // 낡은 판단으로 수정 → 잔액 음수 가능

// ✅ 안전: await 없이 검사+수정을 한 번에(원자적)
guard balance >= amount else { return }
balance -= amount                         // 사이에 suspend 없음 → 재진입 불가

actor 메서드가 중간에 await를 안 하면(동기적으로 끝나면) 절대 재진입당하지 않는다. 규칙: "불변식이 깨진 구간에 await를 두지 마라."

부모 actor 컨텍스트가 없을 때(ViewModel에서 async let)는 어떻게 되나?

"상속"은 없으면 없는 대로 상속된다 = 자식도 특정 actor에 격리되지 않은 채(글로벌 concurrent 풀에서) 실행.

// ViewModel이 @MainActor가 아니면 → 격리 없음
final class VM {
    func load() async {
        async let a = fetchA()   // 특정 actor 없이 concurrent 풀에서 실행
        async let b = fetchB()
        let (x, y) = await (a, b)
    }
}
  • ViewModel이 @MainActor → 자식도 MainActor 컨텍스트에서 시작(단, 내부에서 nonisolated async 함수를 부르면 그건 actor를 벗어나 실행)
  • ViewModel이 격리 없으면 → 자식도 격리 없음. 문제 아님("격리 없음"도 유효한 상태 = 어느 actor에도 안 묶임)
  • 실무 팁: ViewModel을 @MainActor로 두면 UI 갱신이 안전, 무거운 작업만 async let으로 백그라운드에 던짐

task-local이 무엇인가?

@TaskLocal현재 task와 그 자식 task들에게 암묵적으로 전파되는 값 저장소. thread-local의 task 버전이다.

enum Ctx { @TaskLocal static var requestID: String? }

Ctx.$requestID.withValue("abc-123") {
    Task { print(Ctx.requestID) }  // "abc-123" — 자동 전파
}
  • 용도: 요청 ID·로거·트레이싱 컨텍스트를 인자로 일일이 안 넘기고 전파
  • structured 자식·unstructured Task {}상속, Task.detached는 상속 안 함

"부모 취소 확인(isCancelled)해서 내 것 끝내야 하는 거 아냐?"

맞다. 취소는 2단계로 나뉜다.

  • (a) 누가 취소 신호를 켜나 → structured는 부모 취소 시 자동으로 자식 isCancelled=true. unstructured Task {}는 자동 연결이 없어 누군가 handle.cancel()을 명시 호출해야 켜짐
  • (b) 켜진 걸 보고 실제로 멈추기Task.isCancelled / try Task.checkCancellation()로 확인해 중단. structured/unstructured 공통
정리: "isCancelled 확인해서 끝낸다"는 맞다(=b). 다만 unstructured는 그 isCancelled가 저절로 안 켜지니 누군가 cancel()을 해줘야 한다(=a). 또 unstructured는 "부모"가 없어서, 보는 건 부모 것이 아니라 "현재 자기 Task"의 isCancelled다.
취소는 협조적(cooperative): cancel()은 신호만 켤 뿐 자동으로 안 멈춘다. task 안에서 직접 체크해 빠져나와야 한다.

경계 넘김 예시 코드 — actor 안에서 Task? Task에서 다른 Task로?

핵심 함정: Task {}enclosing actor의 격리를 상속한다. 그래서 actor 안의 Task {}는 그 actor에 격리된 채 실행 → non-Sendable 캡처가 경계 넘김이 아니다(에러 아님). 에러는 격리를 물려받는 Task.detached거나, 두 Task가 공유할 때 난다.
final class Ref { var v = 0 }        // non-Sendable (mutable class)

actor Store {
    func make() {
        let r = Ref()
        Task {                        // Task는 Store 격리를 상속 → r이 actor 안에 머묾
            r.v += 1                  // ✅ 에러 아님 (경계 안 넘음)
        }
        Task.detached {               // detached는 격리 상속 X → actor 밖
            r.v += 1                  // ❌ 에러: non-Sendable r이 actor 밖으로 넘어감
        }
    }
}

// 격리 없는 컨텍스트에서 두 Task가 같은 non-Sendable 공유
func processGlobally() {           // nonisolated (격리 없음)
    let r = Ref()
    Task { r.v += 1 }                 // 단독은 region isolation으로 통과될 수도
    Task { r.v += 2 }                 // ❌ 두 Task가 같은 r 공유 → 확실한 에러
}

정리: "actor 안의 Task {}"는 격리를 상속하므로 안전. 에러는 ⓐ Task.detached로 격리를 벗어나거나 ⓑ 두 도메인이 같은 non-Sendable을 공유할 때. 값 타입(struct)은 복사되어 항상 통과.

함수/클로저가 Sendable = "안 바뀐다"는 뜻인가?

아니다. @Sendable 클로저 = "여러 도메인에서 동시에 실행해도 안전"이라는 뜻. 캡처한 것이 전부 Sendable이고 공유 mutable 상태를 참조 캡처하지 않음을 의미한다.

var counter = 0
let c: @Sendable () -> Void = {
    counter += 1   // ❌ mutable var 참조 캡처 → @Sendable 위반(레이스)
}
let name = "kyu"
let d: @Sendable () -> Void = { print(name) }  // ✅ 불변 값만 캡처 → 안전

"코드가 안 바뀐다"가 아니라 "동시에 여러 번 불려도 캡처가 안전"이다. mutable 변수를 참조로 잡으면 위반.

Task 우선순위 상속 = 부모의 높은 우선순위가 전이되는 것?

맞다. Task {}생성 시점의 현재 우선순위를 상속 → 부모가 .high면 자식도 .high로 시작.

추가 — priority escalation(우선순위 승격): 낮은 우선순위 태스크를 높은 우선순위 태스크가 await로 기다리면, 기다리는 동안 낮은 쪽이 일시 승격된다 → 우선순위 역전(높은 게 낮은 것에 막힘) 방지. "상속"(생성 시점)과 "escalation"(대기 시점)은 별개 메커니즘.
심화

더 깊이 — 5가지

① executor / hop이 스레드에서 어떻게 도나

② reentrancy 방어법

③ @MainActor 심화

④ Sendable 컴파일 검사 — 언제 되고 안 되나

타입Sendable?
struct/enum — 저장 프로퍼티 전부 Sendable✅ 자동
let만 있는 immutable final class
actor✅ 항상(자동)
var 있는 mutable class
내부에서 락으로 직접 동기화한 class⚠️ @unchecked Sendable

@unchecked Sendable = "내가 락으로 안전하게 만들었으니 검사 통과시켜줘"라는 개발자 책임 선언. 진짜 동기화 했을 때만, 남용 금지.

⑤ 구조적 동시성 + actor 관계

후속 심화 (2차 문답)

파고든 14문답 — 격리·스레드·continuation·취소

actor 프로토콜은 Sendable을 채택하는가?

네, 자동으로. 표준 라이브러리가 protocol AnyActor: AnyObject, Sendable로 정의 → 모든 actor는 암묵적으로 Sendable. actor는 상태 접근을 executor로 직렬화하므로 인스턴스를 여러 도메인에 넘겨도 안전하기 때문. (그래서 "공유해야 하는 클래스는 actor로 바꿔라"가 해법)

"async 함수 간 값 전달(서로 다른 격리일 때)"이 무슨 말?

격리가 다른 두 async 함수 사이로 값이 오가는 것. Task/actor뿐 아니라 함수끼리도 동일하게 검사된다.

@MainActor func updateUI(_ u: User) { ... }      // MainActor 격리
nonisolated func fetch() async -> User { ... }   // 격리 없음

@MainActor func flow() async {
    let u = await fetch()     // fetch(격리없음) → flow(MainActor): 경계 A
    updateUI(u)               // 같은 MainActor → 경계 안 넘음
    await other.store(u)     // MainActor → other actor: 경계 B
}

경계 A·B에서 User가 도메인을 건너므로 Sendable이어야 한다.

도메인 간 공유 변수는 Sendable 아니면 컴파일 못 넘긴다가 결론? (+ sending)

기본은 그렇다. 단 예외가 sending — "넘긴 뒤 나는 안 만진다"(소유권 이전)를 증명하면 non-Sendable도 넘길 수 있다.

final class Ref { var v = 0 }   // non-Sendable

func make() -> sending Ref {    // 넘긴 뒤 안 씀을 약속
    let r = Ref(); return r      // ✅ 소유권 이전 → 받는 쪽 독점 → 안전
}
let r = make()
await someActor.take(r)         // take(_ r: sending Ref)이면 OK
// 이후 r을 또 쓰면 ❌ (이미 넘김)

핵심은 "Sendable이냐"가 아니라 "동시에 만지지 않느냐". sending은 동시 접근을 원천 차단.

cooperative thread pool = global shared thread pool?

네, 프로세스 전역에 하나 공유되는 풀. 모든 Task·actor가 이 풀 위에서 돈다.

  • 크기(width) ≈ CPU 코어 수 → 그 이상 스레드를 안 만듦(GCD의 "스레드 폭발" 구조적 방지)
  • GCD global concurrent queue와는 별개의 풀
  • 예외: @MainActor는 이 풀이 아니라 메인 스레드

continuation으로 저장한다는 게 정확히 뭔가?

컴파일러는 async 함수를 상태 기계로 바꿔 await마다 조각낸다.

func work() async {
    let a = load()        // ── 조각 0
    await step()          // ── suspension point
    use(a)                // ── 조각 1
}
  • suspend 시 "조각 1부터 실행, 지역변수 a=값"continuation 객체(힙)에 저장하고 스레드 반납
  • step()이 끝나면 런타임이 이 continuation을 풀의 아무 스레드에 얹어 조각 1부터, a를 복원해 이어 실행
  • continuation = "중단된 함수의 나머지 + 그 시점 지역 상태" 스냅샷
비유: 책 읽다 책갈피 끼우고(continuation) 자리 뜸(스레드 반납) → 나중에 아무 자리에나 앉아(다른 스레드) 책갈피 편 데서 계속.

"thread-local storage 쓰지 마라"가 뭔말?

TLS = 스레드마다 각자 갖는 저장 공간(Thread.current.threadDictionary 등).

Thread.current.threadDictionary["id"] = "42"  // thread 3에 저장
await something()                              // thread 8로 재개
Thread.current.threadDictionary["id"]          // nil! (thread 8엔 없음)

await 후 다른 스레드로 재개되면 값이 사라진다. 문맥값이 필요하면 TLS 대신 @TaskLocal(Task를 따라다님) 사용.

"락을 걸고 await 하지 마라"가 뭔말?

lock.lock()
await something()   // ⚠️ 위험
lock.unlock()
  • lock()은 thread 3에서, unlock()은 thread 8에서 → 많은 락은 "건 스레드=푸는 스레드"를 요구 → 런타임 오류
  • 락 잡은 채 suspend하면 다른 Task가 같은 락 요구 → 데드락
  • 동기화가 필요하면 락 대신 actor를 써라

nonisolated async = VM에서 그냥 async 함수 쓴다는 말?

아니다. nonisolated는 키워드 — actor/@MainActor 멤버인데도 그 격리에서 빼내는 선언.

@MainActor final class VM {
    var items: [Item] = []              // MainActor 격리
    func onMain() { items = [] }         // (격리 O) 메인 스레드
    nonisolated func compute() async -> Int {  // 격리에서 빠짐
        return heavyCalc()               // self.items 직접 접근 불가(하려면 await)
    }
}

멤버의 격리 소속을 바꾸는 선언이지 "그냥 async 호출"이 아니다.

nonisolated async면 네트워크가 백그라운드에서 = Task.detached 쓴 상황?

아니다. 다른 축이다.

  • nonisolated async = 그 함수의 격리 소속(어느 actor에도 안 묶임 → 협력 풀에서 실행)
  • Task.detached = Task 생성 방식(컨텍스트/우선순위/task-local 안 물려받음)

@MainActor VM에서 await fetch()를 부르는데 fetch가 nonisolated async면 → detached 없이도 fetch 본문은 백그라운드 풀에서 돈다(SE-0338: 호출자 actor에서 hop-off). "fetchA가 nonisolated async면 concurrent 풀에서 실행" — 정확히 이 말이 맞다.

structured는 자식이 따로 취소 안 해도 취소되지?

네. 부모 취소 시 자식(async let, TaskGroup)의 isCancelled자동 true. 단 여전히 협조적 — 자식 코드가 isCancelled/checkCancellation()을 봐야 실제로 멈춘다. "신호는 자동 전파, 멈춤은 코드가 협조".

"onCancel 클로저는 취소한 스레드에서 즉시 실행되므로 @Sendable"이 뭔말?

await withTaskCancellationHandler {
    await work()
} onCancel: {
    connection.close()   // ← 이 클로저
}
  • onCancelcancel()을 호출한 그 스레드에서 즉시 실행 → work()가 도는 스레드와 다르고 동시일 수 있음
  • 두 스레드가 동시 실행 가능하므로 @Sendable이어야 하고 공유 상태 레이스 조심
  • 그래서 onCancel엔 원자적이고 짧은 일(연결 끊기, 플래그)만

"바깥에서 누가 cancel, 안에서 isCancelled 본다"가 맞나? / Task.isCancelled는 자기 것?

둘 다 맞다.

  • 바깥: Task 핸들을 잡은 쪽이 handle.cancel()(신호 켜기)
  • 안: Task.isCancelled / try Task.checkCancellation()로 확인해 중단
  • Task.isCancelled현재 실행 중인 자기 Task의 취소 상태를 본다(static이지만 "지금 이 코드가 속한 Task")

[정정] actor 안의 Task는 컨텍스트를 상속하니 non-Sendable 캡처도 괜찮은 것 아닌가?

주인님 지적이 맞다. 앞선 예시가 틀렸다. Task {}는 enclosing actor의 격리를 상속하므로, actor 안의 Task {}는 그 actor에 격리된 채 실행 → non-Sendable을 캡처해도 경계를 안 넘어 에러가 아니다.

에러가 나는 경우: ⓐ Task.detached(격리 상속 X → actor 밖) ⓑ 격리 없는 컨텍스트에서 두 Task가 같은 non-Sendable을 공유(단독은 region isolation으로 통과될 수 있으나 공유는 확실한 에러). 자세한 코드는 위 Q7 참고.

ViewModel이 actor 아닐 때도 Task 클로저는 Sendable해야 하나?

네, 항상. Task {}/Task.detached {} 클로저는 @Sendable @escaping — VM이 actor든 @MainActor든 격리 없든 무관.

final class VM {                 // 격리 없음
    var name = ""
    func go() {
        Task { print(name) }         // self(VM) 캡처 → VM이 Sendable 아니면 경고/에러 가능
    }
}

VM이 @MainActor면 Task가 그 격리를 상속해 self 접근 안전. 격리 없고 non-Sendable이면 [weak self]나 필요한 값만 값 타입으로 꺼내 캡처.

심화 세션 + 저수준 CS (2026-07-11~12)

Swift Concurrency 심화 문답 + 스레드/컨텍스트 스위칭

GCD로도 비동기 되는데 async/await는 왜 등장했나? (핵심 Q1)

5가지 문제를 해결한다:

  • ① 스레드 폭발 방지 — 블로킹 대신 suspend, cooperative pool(코어 수)
  • ② 콜백 지옥 → 순차 코드
  • ③ 에러 전파try/throw
  • ④ 구조적 동시성 & 취소 전파 — Task 트리 자동 취소 (GCD는 취소가 제한적)
  • ⑤ 데이터 레이스 컴파일 타임 방지 — Sendable/actor로 빌드 단계에서 잡음 (GCD가 절대 못 하는 것)

await는 스레드에 무엇을 하나? sync/세마포어와 뭐가 다른가?

await(suspension point)에서 스레드를 pool에 반납(양보)하고 continuation을 저장 → 그 스레드로 다른 작업 실행. 블로킹 안 함. 반면 sync/세마포어는 그 스레드를 붙잡고 대기(블로킹) → 처리량 유지하려 새 스레드 생성 → 폭발.

Concurrency 쓰면 데이터 레이스가 안 나나? actor 명시 안 하면 기본 격리는?

조건부 예. 컴파일러가 강제하는 안전한 API만 쓰면 없지만, @unchecked Sendable·nonisolated(unsafe)·전역 var로 우회하면 개발자 책임. Swift 6 strict에서 완전히 잡음. reentrancy 같은 논리 버그는 컴파일러가 못 잡음.

명시 안 한 일반 함수·타입은 nonisolated(격리 없음) — @MainActor 아님, 자기 격리 도메인 없음.

actor는 락(lock)인가? 락과 뭐가 다른가? (핵심 Q2)

락이 아니다. 내부적으로 serial executor로 접근을 직렬화하지만, 락처럼 스레드를 블로킹하지 않고 겹치면 suspend시킨다.

락(mutex)actor
대기스레드 블로킹suspend(스레드 반납)
재진입보통 불가/데드락await서 재진입 허용(reentrancy)
강제성수동(실수 가능)컴파일러 강제

CheckedContinuation이란? (콜백 ↔ async 다리)

completion handler/델리게이트 API를 async로 감싸는 도구. continuation = "재개 티켓".

func fetchUser() async -> User {
    await withCheckedContinuation { continuation in
        oldFetchUser { user in
            continuation.resume(returning: user)  // 콜백 오면 재개
        }
    }
}

진동벨 비유: async=진동벨 받고 대기(suspend), continuation=진동벨, resume=벨 울림(재개). 딱 한 번만 resume — 0번=영원히 대기(누수), 2번=크래시. Checked는 이 위반을 런타임 검사, Unsafe는 검사 안 함(빠름).

Sendable / @unchecked Sendable / nonisolated(unsafe) — 정의와 붙이는 위치

  • Sendable = 격리 경계를 넘어도 레이스 안 나는 타입 마커
  • @unchecked Sendable = 레이스 가능성 있지만 개발자가 락 등으로 보장 → 타입(class)에
  • nonisolated(unsafe) = 격리에서 빼며 검사 끔 → mutable 저장 프로퍼티/전역변수에
  • nonisolated(unsafe 없이) = 함수/불변 프로퍼티에
final class Cache: @unchecked Sendable {      // 타입에
    nonisolated(unsafe) var dict: [String:Int] = [:]  // 프로퍼티에
}

actor reentrancy와 "불변식 깨진 채 await 금지"

reentrancy = actor 메서드가 await로 suspend된 동안 다른 호출이 재진입 가능.

guard balance >= n else { return }  // 검사(불변식 아직 미완)
await log()                        // ⚠️ 이 틈에 다른 인출 재진입
balance -= n                        // 낡은 판단 → 음수 가능

불변식(항상 참이어야 할 조건)이 깨진 중간 상태에서 await 하지 마라. 검사+수정을 await 없이 원자적으로, 또는 await 뒤 재검사.

무거운 동기 IO? URLSession/Data(contentsOf:)? 취소는?

  • 무거운 동기 IO = 스레드를 블로킹하는 동기 입출력 — Data(contentsOf:), String(contentsOf:), 동기 파일/DB. Data(contentsOf:)는 URLSession API 아님(Foundation 동기 파일읽기용, 네트워크 금지)
  • URLSession(dataTask/data(from:)) = 논블로킹 → 스레드 안 붙잡음. Task 취소 시 네트워크 요청 실제로 취소되어 에러(응답 안 기다림)
  • 취소는 협조적: cancel()은 신호만. DispatchWorkItem·Task 모두 실행 중이면 못 멈춤 → 내부에서 isCancelled/checkCancellation() 직접 체크. 취소 미지원 작업은 완료 후 결과 버리고 CancellationError

nonisolated 규칙 + isolated↔nonisolated 접근 방향

자동?규칙
let(Sendable)자동 ✅명시 없이 외부서 await 없이 접근
func(순수계산)자동 ❌명시적 nonisolated 필요
varnonisolated(unsafe) + 동기화

접근 방향 — "안에서 밖은 자유, 밖에서 안은 제한": isolated func → nonisolated 변수 OK(권한 넓음). nonisolated 동기 func → isolated var는 ❌(async면 await). @MainActor 프로퍼티를 nonisolated(unsafe)로 빼면 백그라운드서 동기 접근(await=비동기와 구분) 가능 — hot path 최적화, 실무선 드묾.

[저수준] 스레드 수 많으면 왜 느린가? continuation 재개는 왜 싼가?

블로킹된 스레드는 놀지 못하고 묶임 → GCD가 처리량 위해 새 스레드 생성 → 폭발. 스레드 많으면:

  • 컨텍스트 스위칭 폭증 — 코어 8개에 스레드 100개를 시분할 → 레지스터 저장/복원 + 커널 개입 반복
  • 메모리(스레드마다 스택), 캐시 thrashing(서로 캐시 밀어냄 → 미스↑), 스레드 고갈 데드락
OS 스레드 컨텍스트 스위칭continuation 재개
주체커널 스케줄러(선점형)user-space 워커(협력적)
비용커널 트랩·레지스터 전체·캐시 영향(비쌈)힙에서 함수상태 로드·함수 점프(쌈)
스레드 수많을수록 자주코어 수 고정 → 거의 없음

스레드가 바뀌어도 새 스레드 생성/OS 강제 전환이 아니라 워커 풀 안 작업 재분배. continuation이 재개 상태(지역변수+재개지점)를 힙에 담아 커널 개입 없이 이어감.

[저수준] PC/SP/FP, 레지스터 vs 캐시, 스택, 캐시 미스

  • PC=현재 명령어 주소, SP=스택 top, FP=현재 프레임 기준점. 함수 호출 시 새 프레임 push(saved FP + 리턴주소). 셋 다 레지스터
  • 레지스터=CPU가 직접 연산(코어마다, 가장 빠름) vs 캐시=메모리 자동 복사본(L1/L2 코어별, L3 공유). 흐름: 레지스터←캐시←RAM
  • 스택은 스레드별, 데이터/코드/힙은 프로세스 공유. async suspend 상태는 스택 아닌 힙(continuation)에 → 스레드 반납 가능
  • 컨텍스트 스위칭: 스레드 A 레지스터는 A의 TCB에 저장 → A는 항상 A로 복원(async 함수 재개=다른 워커가 힙 상태 로드, 다른 층)
  • 캐시 미스: 필요한 데이터/코드가 캐시에 없음 → RAM에서 재로드(수백 사이클, 10~30배). 스위칭 잦으면 서로 캐시 밀어내 미스↑

[심화] 블로킹하면 같은 스레드로 재개하니 컨텍스트 스위칭이 없는 것 아닌가?

반대다. 블로킹은 오히려 컨텍스트 스위칭을 유발한다. CPU 코어는 놀 수 없다.

① T1이 blocking 호출 → T1 잠듦
② OS: 코어 놀릴 수 없으니 T1 내림(레지스터→T1 TCB) → T2 올림  ← 스위칭 1회
③ T1 자는 동안 CPU는 T2 돌림
④ T1 블록 풀림 → T2 내리고 T1 올림(T1 TCB→복원)              ← 스위칭 또 1회

"같은 스레드(T1)로 재개"는 맞지만, 그 사이 CPU가 다른 스레드를 돌렸으므로 스위칭 2번. 게다가 T1이 자는 동안 GCD가 새 스레드 생성 → 폭발.

은행 비유: 직원 A가 서류 기다리면(블로킹) 창구에서 물러나고 대기직원 B가 앉음(스위칭). 서류 오면 A 다시 앉음(또 스위칭). 손님 많고 다들 기다리면 직원 더 고용(스레드 생성) → 창구 2개에 직원 10명이 자리 교대만. Concurrency는 A가 자리를 완전히 비우고(suspend) B가 다른 손님 받음 → 직원 수 안 늘림.

[심화] TCB란? 무엇이 코어별/공유/스레드별인가?

TCB(Thread Control Block) = OS 커널이 각 스레드 관리하려 만든 자료구조. 스레드 상태(running/ready/blocked), 저장된 레지스터 컨텍스트, SP, 우선순위, 소속 프로세스 포인터 등.

무엇범위
레지스터하드웨어코어마다
L1 / L2 캐시하드웨어코어마다(전용)
L3 캐시하드웨어전체 코어 공유
TCB소프트웨어(커널 자료구조, RAM)스레드마다
  • 레지스터는 코어당 하나 → 현재 실행 중인 스레드 값으로 교체. 각 스레드 값은 자기 TCB에 보관
  • 컨텍스트 스위칭(레지스터 저장/복원)은 커널 모드에서 스케줄러가 수행(user↔kernel 트랩 비용)
  • 레지스터=자동 저장/복원(가지고 다님), 캐시=저장 안 함(두고 감, 돌아오면 미스로 재로드)

[심화] 메모리 영역 권한 / GCD 블로킹 vs URLSession 스레드 변화

영역권한
코드(.text)읽기+실행(RX), write 금지
데이터(.data)/.bss전역·static var → RW
.rodata상수(let·문자열) → 읽기전용

GCD 블로킹의 스레드 변화 vs URLSession/async — 다른 층:

  • GCD 블로킹 = OS 스레드 컨텍스트 스위칭. 스레드가 자기 TCB에 저장/복원, 블록 후 같은 스레드로 복원
  • URLSession(논블로킹)/async = 작업(콜백/continuation)이 다른 스레드에 배정. 상태는 클로저 캡처/continuation(힙). "스레드가 자기로 재개"가 아니라 "작업이 옮겨감"

[심화] 델리게이트를 withCheckedContinuation으로? continuation은 클래스?

completion handler와 달리 델리게이트는 결과가 다른 메서드로 오므로 continuation을 프로퍼티에 저장해뒀다 콜백에서 resume한다.

class LocationFetcher: NSObject, CLLocationManagerDelegate {
    private var continuation: CheckedContinuation<CLLocation, Error>?

    func currentLocation() async throws -> CLLocation {
        try await withCheckedThrowingContinuation { cont in
            self.continuation = cont        // 티켓 저장
            manager.delegate = self
            manager.requestLocation()        // 요청만 시작
        }
    }
    func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
        continuation?.resume(returning: locs.first!)  // 콜백서 재개
        continuation = nil                           // 두 번 resume 방지
    }
}
  • continuation은 struct(값 타입)다. 단 내부적으로 재개 토큰을 감싸 "정확히 한 번만 resume" 규칙(0번=누수, 2번=크래시). 실제 중단 상태(async frame)는 런타임이 힙에서 관리
  • 일반 await(actor·async 호출)는 런타임이 continuation을 자동 생성·재개("알아서 관리"=자동 suspend/resume, 타임아웃·자동삭제 아님). resume 안 하면 영원히 suspend(누수), CheckedContinuation은 그걸 로그로 감지만
2026-07-18 학습노트

스레드 컨텍스트 스위칭 vs suspend / continuation / executor

먼저 핵심 구분
  • thread ≠ task — thread=OS 실행 단위(코어에 붙음), task=스레드 위에서 굴러가는 가벼운 논리적 일감. 스레드 수 ≪ task 수
  • suspend는 스레드 전환이 아니다 — "같은 스레드 + 다른 task"이지 "다른 스레드 + 다른 task"가 아니다
  • OS 컨텍스트 스위칭(커널) vs async suspend/resume(유저 공간) — 완전히 다른 층
  • executor가 어느 task를 어느 스레드에 올릴지 결정한다 — actor를 안 써도 항상 존재(global concurrent executor)

OS 스레드 컨텍스트 스위칭이란?

async 함수가 suspend되면 스레드 컨텍스트 스위칭이 일어나나?

아니다. suspend되면 task가 스레드에서 떨어져 나가지만, 스레드는 잠들지 않는다. 같은 스레드가 계속 살아서 큐에서 다음 task를 꺼내 이어서 실행한다.

항목OS 스레드 컨텍스트 스위칭async suspend/resume
주체커널 스케줄러유저 공간 런타임
저장 대상레지스터 전체 → TCB (+ L1/L2 캐시 오염)살아남을 지역변수 + 재개지점 → 힙(continuation)
스레드스레드A 재우고 스레드B 깨움스레드는 계속 돌며 다음 task를 집음
비용비쌈(커널 진입, 캐시 미스)쌈(함수 호출 수준)

suspend 시 리턴주소·지역변수는 어디에 저장되나? continuation이란?

resume될 때 같은 스레드로 돌아오나?

actor를 안 써도 executor가 스레드를 결정하나?

그렇다. 모든 async task는 항상 어떤 executor 위에서 돈다.

즉 "actor를 안 쓰면 executor가 없다"가 아니라 "기본 global executor를 쓴다".

suspend되면 "코어1의 스레드1 → 스레드2"로 스레드가 바뀌는 것 아닌가?

아니다. 스레드는 안 바뀐다. 스레드1이 실행하는 task가 A → B로 바뀔 뿐이다.

상상(틀림):  코어1: [스레드1 / taskA] ──suspend──▶ [스레드2 / taskB]
실제(맞음):  코어1: [스레드1 / taskA] ──suspend──▶ [스레드1 / taskB]  ← 스레드 그대로, task만 교체

suspend 후 스레드가 집는 "다음 task"는 누가 주나?

executor(런타임)가 준다. global executor가 "실행 가능한 job 큐"를 관리하고, 손이 빈 스레드가 그 큐에서 dequeue한다. 스레드가 아무거나 잡는 게 아니라 executor가 관리하는 준비된 큐를 소비한다.

continuation은 언제 만들어지나?

"살아남을 지역변수"와 "살아남지 않을 지역변수"의 차이는?

컴파일러가 각 변수의 "마지막 사용 지점"을 분석 → suspend 지점을 넘어서까지 쓰이는 변수만 continuation에 저장. 나머지는 스택이 풀릴 때 버린다(힙을 가볍게 유지).

func fetch() async {
    let a = computeA()
    log(a)                        // a의 마지막 사용 (await 이전)
    let user = await fetchUser()  // suspend 지점
    let c = user.name + "!"       // a 안 씀 → a는 continuation에 저장 안 함(버림)
}
// 만약 let c = a + user.age 였다면 a가 await를 가로질러 쓰이므로 continuation에 저장됨

핵심 예시 — await 이후에도 쓰이는 지역변수는 async frame에 저장됨:

func makeUser() async -> User {
    let prefix = "user:"                            // await 이후에도 쓰임
    let user = await withCheckedContinuation { ... } // suspend 지점
    return User(name: prefix + user.name)           // ← prefix가 여기서 쓰이므로
}

재개(resume)와 executor의 순서는?

흐름: await 대상 완료 → continuation.resume(값) → executor 큐에 enqueue → 스레드에 배정 → 재개지점부터 실행

  1. await 대상(fetchUser)이 끝나면 그 작업을 끝낸 쪽이 continuation.resume(returning: value)를 호출한다(executor가 부르는 게 아님).
  2. resume(value)가 하는 일: ① 리턴값을 continuation 저장 공간에 써넣고 ② 그 task를 runnable로 만들어 executor 큐에 job으로 올린다(enqueue).
  3. executor가 손이 빈 스레드에 그 job을 배정 → 스레드가 재개지점(await 다음 줄)부터 실행하며 써둔 값을 읽는다.
재개지점 = "await 바로 다음"이지 함수 처음(a, b)이 아니다.

executor의 정체는 무엇인가?

"job(작업 조각)을 실제로 어느 스레드에서 돌릴지 책임지는 객체." 두 능력: enqueue(job)으로 큐에 넣고, 그 job을 실제 스레드에 태워 실행.

한 줄: "어떤 task를 언제 어느 스레드에 올릴지 결정하는 스케줄러." 스레드는 executor가 시키는 job을 실행하는 일꾼.

suspend/resume되는 "task"의 정체는? async 함수 호출마다 새 task가 생기나?

Task {                       // task X 생성
    let g = await fetch()    // fetch는 task X 안에서 실행 (새 task 아님)
}
func fetch() async {
    let c = await fetchUser()  // fetchUser도 task X 안 (새 task 아님)
}

따라서 caller → fetch → fetchUser는 전부 하나의 task X 위에서 돈다.

호출 사슬에서 실제로 언제, 어디서 suspend되나?

func fetch() async {
    let a = 1
    let b = 2
    let c = await fetchUser()   // ★ await 표시 지점
    let d = 6
}

continuation은 함수 호출 시 생기나, suspend 시 생기나? 호출 사슬 전체가 보존되나?

[fetchUser 재개지점] → [fetch 재개지점: let d] → [호출부 재개지점: let bb]
최적화: async 함수가 실제로 suspend하지 않고 바로 반환하면 이 힙 저장을 거의 생략하는 빠른 경로를 탄다.

leaf가 resume하는 건 누구의 continuation인가? (두 층 구분)

func fetchUser() async -> User {
    await withCheckedContinuation { cont in     // cont = fetchUser 자신의 continuation
        legacyFetch { user in cont.resume(returning: user) }
    }
}

두 개의 continuation이 존재한다:

완료 순서(한 층씩 위로 전파):

  1. 네트워크 콜백 도착 → cont.resume(user) → fetchUser의 continuation 깨움.
  2. fetchUser가 재개되어 user를 반환 → 그 반환이 fetch의 continuation을 깨움.
  3. fetch가 let c에 값 받고 let d로 진행.

resume 한 방의 동작을 한 층으로 정리하면?

  1. 완료한 쪽(콜백 등)이 cont.resume(returning: value) 호출. (executor가 부르는 게 아님)
  2. resume(value)가 하는 일: ① value를 continuation 저장 공간에 써넣고 ② 그 task를 runnable로 만들어 executor 큐에 enqueue.
  3. executor가 손 빈 스레드에 배정 → 스레드가 멈췄던 지점(await 다음)부터 재개하며 써둔 value를 읽는다.

await f() vs Task { await f() }의 차이는?

Task { let u = await fetchUser(); print(u) }  // 새 task
print("여기는 안 기다리고 먼저 실행될 수 있음")

Task { }는 엄밀히 unstructured task(부모의 우선순위·actor는 상속하나 구조적 자식은 아님). 진짜 구조적 child task는 async let / TaskGroup.

중첩 suspend 시 continuation은 몇 개 저장되나?

물리적 suspend는 가장 안쪽(leaf) 하나에서 발생. 그 순간 task 전체가 멈추므로 호출 사슬 전체의 재개지점이 보존된다.

두 continuation이 같은 task인가? 둘 다 "기다리는데" resume하면 어떻게 진행되나?

fetch → fetchUser → withCheckedContinuation (여기서만 멈춤)

withCheckedContinuation 클로저 안에서 await를 쓸 수 있나?

아니다. withCheckedContinuation { cont in ... }의 클로저는 동기 클로저 (CheckedContinuation)->Void. 안에서 await 불가(컴파일 에러).

용도: 콜백 기반 옛 API를 async로 감쌀 때. 콜백 안에서 cont.resume(returning:) 호출.

func fetchUser() async -> Int {
    let d = await withCheckedContinuation { cont in
        legacyFetch { value in cont.resume(returning: value) }
    }
    return d + 5
}

단순히 대기만 하려면 continuation 없이 try? await Task.sleep(...) 자체가 suspend 지점.

3단 중첩(Task → fetch → fetchUser → leaf)에서 continuation이 3개 생기나, task당 하나인가?

leaf suspend 시 보존되는 것:

프레임재개지점살아남는 지역변수
Task t 클로저print(aa+e+15)aa
fetch()return c+10+a+ba, b
fetchUser()return d+5(없음)

async frame을 continuation이라고 봐도 되나? (용어 구분)

용어정체개수
async frame
(async context / coroutine frame)
힙에 할당된 함수별 상태 저장 공간: 재개 함수 포인터(재개지점) + 살아남을 지역변수 + 부모 frame 포인터 + 반환/에러 슬롯호출 사슬 함수마다 하나(여러 개)
continuation
(일반 개념)
"이 지점부터 남은 계산 = 재개지점 + 필요한 상태". async frame이 이걸 구현함async frame과 1:1 대응
CheckedContinuation / UnsafeContinuation
(Swift 타입)
withCheckedContinuation이 주는 명시적 핸들 객체. .resume() 메서드로 leaf frame을 깨움. frame 자체가 아니라 frame을 제어하는 손잡이명시적 suspend(leaf)당 하나
결론: "async frame ≈ continuation(개념)" → 맞음. "async frame = CheckedContinuation 타입" → 아님(후자는 frame을 깨우는 API 핸들).

suspend를 가로질러 지역변수를 많이 살리면 어떤 영향?

URLSession completion handler — dataTask{}.resume() 하면 결과 오는 동안 다른 코드가 진행되는 이유는? 스레드가 바뀌나?

URLSession.shared.dataTask(with: url) { data, resp, err in
    // 나중에 실행됨
}.resume()
// 이 줄은 즉시 다음으로 진행

관련 문서