본문 바로가기
C.W.K.
Lesson 06 of 07 · published

모든 await는 누군가 닫을 수 있는 문이야

~17 min · concurrency, reentrancy, cancellation, continuations, deadlines

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"사용자가 권한 창이 떠 있는 동안 정지를 눌렀어. 그러고 나서 권한 창에 대답했더니 마이크가 열렸어."

일시 중단은 세상을 멈추는 버튼이 아니야

await는 일시 중단 지점이야. 네 함수가 기다리는 동안 다른 코드가 돌아. 같은 actor로 들어오는 다른 호출도 예외가 아니야. 다시 이어갈 땐 await 전에 확인했던 상태가 더는 참이 아닐 수 있어. 이게 actor 재진입이고, 일부러 이렇게 만든 거야. 한 호출이 네트워크를 기다리는 동안 모든 일을 거부하는 actor는 스스로 교착 상태에 빠질 테니까.

실전 규칙은 이거야. await 뒤에는 다음 줄이 전제하는 걸 매번 다시 확인해. 기다리는 대상에 사람이 대답할 때(권한 대화상자, 파일 선택기, 공유 시트, 알림) 제일 중요해. 사람은 대답하는 데 몇 분씩 걸릴 수도 있고, 그사이 기다림을 시작한 객체가 멈추거나 해체되거나 새로 만들어질 수 있거든.

멈추지 않은 정지

가족의 받아쓰기 턴은 start() 안에서 시스템 권한 창 두 개를 기다렸어. 그 단계에서도 정지 버튼은 이미 살아 있었고, stop()은 착실하게 상태를 idle로 바꾸고 전부 정리했어. 이어서 사용자가 대화상자에 대답하자 continuation이 재개됐고, start()는 아무 일 없었다는 듯 하던 일을 이어갔어. 오디오 세션, 탭, 엔진까지 전부. 사용자가 멈춘 턴이 마이크를 열었고, 상태엔 "듣는 중"이라고 떴어. 새로 깐 앱에서 첫 받아쓰기를 하자마자 만날 수 있는 버그였어. 사람이 딱 머뭇거리는 순간이니까.

고치는 데 새 상태는 필요 없었어. 늦게 오는 콜백을 거르려고 세대 카운터가 이미 있었거든. start()는 첫 await 전에 자기 세대를 잡아두고 await마다 확인해. stop()은 세대를 올려서 그 전의 start를 전부 낡은 걸로 만들고. 취소 핸들러만으론 부족했을 거야. 사용자가 결국 대답하면 continuation은 정상적으로 재개되니까.

첫 회귀 테스트는 아무것도 증명 못 했어. 정지 뒤에 "듣는 중이 아님"을 확인했는데, 고장 난 코드에서도 통과했어. 시뮬레이터엔 한국어 음성 모델이 없어서 start()가 몇 줄 뒤에 어차피 빠져나갔거든. 테스트가 시뮬레이터를 재고 있었던 거야. 단언을 가드만 만들어낼 수 있는 state == .idle로 바꾸자, 옛 코드에선 실패하고 고친 코드에선 통과했어.

continuation, 데드라인, 단언문

  • checked continuation은 딱 한 번 재개돼야 해. 두 번 재개하면 트랩이 나고, 한 번도 안 하면 멈춘 태스크가 영원히 새.
  • 어떤 콜백은 끝내 안 와. 메일에서 온 PDF를 기다리던 공유 확장이 한 시간 동안 살아 있었어. 기기에서 로드 완료 콜백이 끝까지 안 불렸거든. 이런 기다림엔 전부 데드라인이 필요해. 태스크 그룹은 안 돼. 그룹은 모든 자식을 기다리는데, 문제는 바로 안 돌아오는 자식이잖아. 먼저 끝나는 쪽이 이기는 경주로 해. 태스크 둘, 락 걸린 문 하나, continuation 하나.
  • XCTAssertEqual(await store.state(id), .queued)는 컴파일이 안 돼. XCTest 단언문 인자는 await를 못 쓰는 autoclosure야. 값을 먼저 let으로 꺼내고 그걸로 단언해.

Code

사람이 대답하는 await 뒤엔 매번 다시 확인해·swift
@MainActor
public final class TurnController {
    private var generation = 0
    public private(set) var state = "idle"

    public func start(askPermission: () async -> Bool) async {
        generation += 1
        let mine = generation
        state = "starting"
        let granted = await askPermission()          // a person answers this; anything can happen meanwhile
        guard mine == generation else { return }     // stopped while we waited: do nothing
        state = granted ? "listening" : "denied"
    }

    public func stop() {
        generation += 1                              // every earlier start is now stale
        state = "idle"
    }
}
끝나지 않을 수도 있는 일에 거는 선착순 데드라인·swift
import Foundation

public enum Deadline {
    public struct TimedOut: Error {}

    // Not a task group: a group waits for every child, including the one that never returns.
    public static func race<T: Sendable>(
        seconds: Double, _ work: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            let gate = Gate()
            Task {
                do { let value = try await work(); if gate.claim() { continuation.resume(returning: value) } }
                catch { if gate.claim() { continuation.resume(throwing: error) } }
            }
            Task {
                try? await Task.sleep(for: .seconds(seconds))
                if gate.claim() { continuation.resume(throwing: TimedOut()) }
            }
        }
    }

    final class Gate: @unchecked Sendable {             // resume exactly once
        private let lock = NSLock()
        private var done = false
        func claim() -> Bool { lock.lock(); defer { lock.unlock() }; if done { return false }; done = true; return true }
    }
}

External links

Exercise

주입할 수 있는 askPermission 클로저를 받는 TurnController를 Spark에 넣어. 테스트는 이렇게 짜. 네가 쥔 continuation을 기다리는 권한 클로저로 턴을 시작하고, 기다리는 동안 stop()을 불러. 그 뒤에 continuation을 true로 재개하고, 상태가 여전히 idle인지 확인해. 세대 가드를 지우고 테스트가 실패하는지 확인해. 마지막으로 끝나지 않는 로드를 Deadline.race로 감싸서 데드라인 안에 TimedOut을 던지는지 확인해봐.
Hint
권한 대답은 withCheckedContinuation을 변수에 담아두고 테스트가 나중에 재개하는 식으로 조종해. 단언은 환경이 우연히 만들어낼 수도 있는 조건 말고, 가드만 만들어낼 수 있는 조건(state == "idle")에 걸어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.