2026-07-05 학습 세션. structured/unstructured 상속 관계, actor executor와 스레드, reentrancy, Sendable 경계, 취소 전파를 문답으로 정리한다.
await suspendawait하면 그 틈에 다른 호출이 재진입| 종류 | actor 컨텍스트 | 우선순위 | task-local | 취소 자동전파 |
|---|---|---|---|---|
| structured (async let, TaskGroup) | ✅ | ✅ | ✅ | ✅ |
unstructuredTask {} | ✅ | ✅ | ✅ | ❌ (수동 cancel 필요) |
detachedTask.detached {} | ❌ | ❌ | ❌ | ❌ |
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 가능@MainActor — executor가 메인 디스패치 큐라 항상 메인 스레드. 일반 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) } }
@MainActor면 → 자식도 MainActor 컨텍스트에서 시작(단, 내부에서 nonisolated async 함수를 부르면 그건 actor를 벗어나 실행)@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" — 자동 전파 }
Task {}는 상속, Task.detached는 상속 안 함"부모 취소 확인(isCancelled)해서 내 것 끝내야 하는 거 아냐?"
맞다. 취소는 2단계로 나뉜다.
isCancelled=true. unstructured Task {}는 자동 연결이 없어 누군가 handle.cancel()을 명시 호출해야 켜짐Task.isCancelled / try Task.checkCancellation()로 확인해 중단. structured/unstructured 공통cancel()을 해줘야 한다(=a). 또 unstructured는 "부모"가 없어서, 보는 건 부모 것이 아니라 "현재 자기 Task"의 isCancelled다.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로 시작.
await로 기다리면, 기다리는 동안 낮은 쪽이 일시 승격된다 → 우선순위 역전(높은 게 낮은 것에 막힘) 방지. "상속"(생성 시점)과 "escalation"(대기 시점)은 별개 메커니즘.await actor.foo() → 현재 컨텍스트에서 그 actor의 executor로 hop, 반환 시 hop back(호출자가 격리돼 있으면)@MainActor로 넘어가면 메인 큐로 디스패치(비용 있음). 루프마다 hop하면 느림 → 묶어서 한 번에nonisolated: actor 멤버지만 격리에서 빼는 키워드. mutable 상태를 안 건드리는(순수 계산·let 상수) 메서드에 붙이면 await 없이 동기 호출 가능@MainActor로, 무거운 계산만 nonisolated/백그라운드로| 타입 | Sendable? |
|---|---|
struct/enum — 저장 프로퍼티 전부 Sendable | ✅ 자동 |
let만 있는 immutable final class | ✅ |
actor | ✅ 항상(자동) |
var 있는 mutable class | ❌ |
| 내부에서 락으로 직접 동기화한 class | ⚠️ @unchecked Sendable |
@unchecked Sendable = "내가 락으로 안전하게 만들었으니 검사 통과시켜줘"라는 개발자 책임 선언. 진짜 동기화 했을 때만, 남용 금지.
async let/TaskGroup 자식은 부모의 격리·우선순위·task-local·취소를 상속/전파nonisolated async 호출 시 offloadactor 프로토콜은 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가 이 풀 위에서 돈다.
@MainActor는 이 풀이 아니라 메인 스레드continuation으로 저장한다는 게 정확히 뭔가?
컴파일러는 async 함수를 상태 기계로 바꿔 await마다 조각낸다.
func work() async { let a = load() // ── 조각 0 await step() // ── suspension point use(a) // ── 조각 1 }
step()이 끝나면 런타임이 이 continuation을 풀의 아무 스레드에 얹어 조각 1부터, a를 복원해 이어 실행"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에서 → 많은 락은 "건 스레드=푸는 스레드"를 요구 → 런타임 오류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 쓴 상황?
아니다. 다른 축이다.
@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() // ← 이 클로저 }
onCancel은 cancel()을 호출한 그 스레드에서 즉시 실행 → work()가 도는 스레드와 다르고 동시일 수 있음"바깥에서 누가 cancel, 안에서 isCancelled 본다"가 맞나? / Task.isCancelled는 자기 것?
둘 다 맞다.
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]나 필요한 값만 값 타입으로 꺼내 캡처.
GCD로도 비동기 되는데 async/await는 왜 등장했나? (핵심 Q1)
5가지 문제를 해결한다:
try/throwawait는 스레드에 무엇을 하나? 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) — 정의와 붙이는 위치
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:)? 취소는?
Data(contentsOf:), String(contentsOf:), 동기 파일/DB. Data(contentsOf:)는 URLSession API 아님(Foundation 동기 파일읽기용, 네트워크 금지)cancel()은 신호만. DispatchWorkItem·Task 모두 실행 중이면 못 멈춤 → 내부에서 isCancelled/checkCancellation() 직접 체크. 취소 미지원 작업은 완료 후 결과 버리고 CancellationErrornonisolated 규칙 + isolated↔nonisolated 접근 방향
| 자동? | 규칙 | |
|---|---|---|
let(Sendable) | 자동 ✅ | 명시 없이 외부서 await 없이 접근 |
func(순수계산) | 자동 ❌ | 명시적 nonisolated 필요 |
var | — | nonisolated(unsafe) + 동기화 |
접근 방향 — "안에서 밖은 자유, 밖에서 안은 제한": isolated func → nonisolated 변수 OK(권한 넓음). nonisolated 동기 func → isolated var는 ❌(async면 await). @MainActor 프로퍼티를 nonisolated(unsafe)로 빼면 백그라운드서 동기 접근(await=비동기와 구분) 가능 — hot path 최적화, 실무선 드묾.
[저수준] 스레드 수 많으면 왜 느린가? continuation 재개는 왜 싼가?
블로킹된 스레드는 놀지 못하고 묶임 → GCD가 처리량 위해 새 스레드 생성 → 폭발. 스레드 많으면:
| OS 스레드 컨텍스트 스위칭 | continuation 재개 | |
|---|---|---|
| 주체 | 커널 스케줄러(선점형) | user-space 워커(협력적) |
| 비용 | 커널 트랩·레지스터 전체·캐시 영향(비쌈) | 힙에서 함수상태 로드·함수 점프(쌈) |
| 스레드 수 | 많을수록 자주 | 코어 수 고정 → 거의 없음 |
스레드가 바뀌어도 새 스레드 생성/OS 강제 전환이 아니라 워커 풀 안 작업 재분배. continuation이 재개 상태(지역변수+재개지점)를 힙에 담아 커널 개입 없이 이어감.
[저수준] PC/SP/FP, 레지스터 vs 캐시, 스택, 캐시 미스
[심화] 블로킹하면 같은 스레드로 재개하니 컨텍스트 스위칭이 없는 것 아닌가?
반대다. 블로킹은 오히려 컨텍스트 스위칭을 유발한다. CPU 코어는 놀 수 없다.
① T1이 blocking 호출 → T1 잠듦 ② OS: 코어 놀릴 수 없으니 T1 내림(레지스터→T1 TCB) → T2 올림 ← 스위칭 1회 ③ T1 자는 동안 CPU는 T2 돌림 ④ T1 블록 풀림 → T2 내리고 T1 올림(T1 TCB→복원) ← 스위칭 또 1회
"같은 스레드(T1)로 재개"는 맞지만, 그 사이 CPU가 다른 스레드를 돌렸으므로 스위칭 2번. 게다가 T1이 자는 동안 GCD가 새 스레드 생성 → 폭발.
[심화] TCB란? 무엇이 코어별/공유/스레드별인가?
TCB(Thread Control Block) = OS 커널이 각 스레드 관리하려 만든 자료구조. 스레드 상태(running/ready/blocked), 저장된 레지스터 컨텍스트, SP, 우선순위, 소속 프로세스 포인터 등.
| 무엇 | 범위 | |
|---|---|---|
| 레지스터 | 하드웨어 | 코어마다 |
| L1 / L2 캐시 | 하드웨어 | 코어마다(전용) |
| L3 캐시 | 하드웨어 | 전체 코어 공유 |
| TCB | 소프트웨어(커널 자료구조, RAM) | 스레드마다 |
[심화] 메모리 영역 권한 / GCD 블로킹 vs URLSession 스레드 변화
| 영역 | 권한 |
|---|---|
| 코드(.text) | 읽기+실행(RX), write 금지 |
| 데이터(.data)/.bss | 전역·static var → RW |
| .rodata | 상수(let·문자열) → 읽기전용 |
GCD 블로킹의 스레드 변화 vs URLSession/async — 다른 층:
[심화] 델리게이트를 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 방지 } }
아니다. suspend되면 task가 스레드에서 떨어져 나가지만, 스레드는 잠들지 않는다. 같은 스레드가 계속 살아서 큐에서 다음 task를 꺼내 이어서 실행한다.
| 항목 | OS 스레드 컨텍스트 스위칭 | async suspend/resume |
|---|---|---|
| 주체 | 커널 스케줄러 | 유저 공간 런타임 |
| 저장 대상 | 레지스터 전체 → TCB (+ L1/L2 캐시 오염) | 살아남을 지역변수 + 재개지점 → 힙(continuation) |
| 스레드 | 스레드A 재우고 스레드B 깨움 | 스레드는 계속 돌며 다음 task를 집음 |
| 비용 | 비쌈(커널 진입, 캐시 미스) | 쌈(함수 호출 수준) |
await 지점이 상태 번호가 된다.withCheckedContinuation/withUnsafeContinuation은 이 continuation을 직접 손에 쥐어주는 API(콜백 기반 옛 API를 async로 감쌀 때 continuation.resume(returning:) 호출).@MainActor 격리 코드는 resume 시 반드시 메인 스레드. actor 격리 코드는 그 actor의 executor로.그렇다. 모든 async task는 항상 어떤 executor 위에서 돈다.
@MainActor → 메인 스레드 executor.아니다. 스레드는 안 바뀐다. 스레드1이 실행하는 task가 A → B로 바뀔 뿐이다.
상상(틀림): 코어1: [스레드1 / taskA] ──suspend──▶ [스레드2 / taskB] 실제(맞음): 코어1: [스레드1 / taskA] ──suspend──▶ [스레드1 / taskB] ← 스레드 그대로, task만 교체
await로 멈추면 스레드1은 잠들지 않고, executor 큐에서 다음 실행 가능한 task를 꺼내 이어서 실행한다. 커널 개입 없음, 레지스터 TCB 저장/복원 없음.executor(런타임)가 준다. global executor가 "실행 가능한 job 큐"를 관리하고, 손이 빈 스레드가 그 큐에서 dequeue한다. 스레드가 아무거나 잡는 게 아니라 executor가 관리하는 준비된 큐를 소비한다.
fetchUser가 끝날 때 생기는 게 아님).컴파일러가 각 변수의 "마지막 사용 지점"을 분석 → 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가 여기서 쓰이므로 }
prefix는 suspend를 가로질러 재개 후 return에서 쓰이므로 continuation(async frame)에 저장되어 살아남는다.흐름: await 대상 완료 → continuation.resume(값) → executor 큐에 enqueue → 스레드에 배정 → 재개지점부터 실행
fetchUser)이 끝나면 그 작업을 끝낸 쪽이 continuation.resume(returning: value)를 호출한다(executor가 부르는 게 아님).resume(value)가 하는 일: ① 리턴값을 continuation 저장 공간에 써넣고 ② 그 task를 runnable로 만들어 executor 큐에 job으로 올린다(enqueue)."job(작업 조각)을 실제로 어느 스레드에서 돌릴지 책임지는 객체." 두 능력: enqueue(job)으로 큐에 넣고, 그 job을 실제 스레드에 태워 실행.
await someFunc()는 새 task를 만드는 게 아니라 "현재 task 안에서 함수를 호출"하는 것.Task { }, TaskGroup, async let 뿐.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 위에서 돈다.
func fetch() async { let a = 1 let b = 2 let c = await fetchUser() // ★ await 표시 지점 let d = 6 }
a, b는 동기적으로 먼저 실행. await fetchUser()에서 fetchUser 안으로 들어가 실행.fetchUser 내부의 진짜 대기 지점(예: 네트워크 continuation)에서 일어난다. 그 순간 task 전체가 suspend. await 표시는 "여기서 멈출 수도 있다"는 지점 표시일 뿐.[fetchUser 재개지점] → [fetch 재개지점: let d] → [호출부 재개지점: let bb]
func fetchUser() async -> User { await withCheckedContinuation { cont in // cont = fetchUser 자신의 continuation legacyFetch { user in cont.resume(returning: user) } } }
두 개의 continuation이 존재한다:
await fetchUser()에서 결과를 기다리며 멈춘 지점.완료 순서(한 층씩 위로 전파):
cont.resume(user) → fetchUser의 continuation 깨움.let c에 값 받고 let d로 진행.cont.resume(returning: value) 호출. (executor가 부르는 게 아님)resume(value)가 하는 일: ① value를 continuation 저장 공간에 써넣고 ② 그 task를 runnable로 만들어 executor 큐에 enqueue.let t = Task{...}; await t.value.Task { let u = await fetchUser(); print(u) } // 새 task
print("여기는 안 기다리고 먼저 실행될 수 있음")
Task { }는 엄밀히 unstructured task(부모의 우선순위·actor는 상속하나 구조적 자식은 아님). 진짜 구조적 child task는 async let / TaskGroup.
물리적 suspend는 가장 안쪽(leaf) 하나에서 발생. 그 순간 task 전체가 멈추므로 호출 사슬 전체의 재개지점이 보존된다.
fetch → fetchUser → withCheckedContinuation (여기서만 멈춤)
cont.resume(user) 한 번이면: task가 leaf 재개지점에서 runnable → executor가 스레드 배정 → withCheckedContinuation이 user 반환 → fetchUser가 user 반환 → fetch의 let c로 이어짐 → let d 진행.아니다. 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 지점.
Task t). 하지만 재개 상태(async 프레임 = 재개지점+지역변수)는 사슬의 함수마다 하나씩 보존됨(여러 개).leaf suspend 시 보존되는 것:
| 프레임 | 재개지점 | 살아남는 지역변수 |
|---|---|---|
| Task t 클로저 | print(aa+e+15) | aa |
| fetch() | return c+10+a+b | a, b |
| fetchUser() | return d+5 | (없음) |
.resume() 직접 호출하는 그 객체).cont.resume(1) → d=1 → fetchUser return 6 → fetch let c=6, return 18 → Task let e=18, print(3+18+15)=36.| 용어 | 정체 | 개수 |
|---|---|---|
| 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)당 하나 |
URLSession.shared.dataTask(with: url) { data, resp, err in
// 나중에 실행됨
}.resume()
// 이 줄은 즉시 다음으로 진행
resume()을 호출한 스레드는 멈추거나 전환되지 않고 즉시 리턴, 다음 코드를 계속한다(컨텍스트 스위칭 없음).DispatchQueue.main.async 필요.try await URLSession.shared.data(from:))은 위 continuation 메커니즘 적용.