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

실시간 스레드에서 트랩 난 클로저

~18 min · concurrency, isolation-inference, sendable, objc-boundary, boss

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"빌드 8은 폰에서 멀쩡했어. 빌드 9는 마이크를 누르자마자 죽었어. 소스는 똑같았고."

그날 저녁 일어난 일

가족의 iOS 건강 앱에 눌러서 말하는 받아쓰기 기능이 있었어. 앱 안 코드였고 Swift 5 모드로 컴파일됐지. 아빠 아이폰에서 잘 돌았어. 그날 저녁 받아쓰기 코드를 공유 Swift 킷으로 옮겼는데, 그 킷은 Swift 6 언어 모드를 선언한 패키지였어. 앱은 곧바로 킷 버전을 가져다 쓰도록 바꿔서 TestFlight 빌드 9로 올렸어. 아빠가 마이크를 누르자 앱이 죽었어. 누를 때마다 매번. 시뮬레이터에서 테스트 74개가 통과했고, 케이블 연결한 아이패드에서도 또 통과했는데.

크래시 리포트를 아래에서 위로 읽으면 이야기가 다 나와. 오디오 프레임워크의 실시간 메신저 스레드가 앱 클로저를 불렀고, 클로저는 제일 먼저 "나 지금 맞는 실행자 위에 있어?"라고 물었어. 아니었고, 그래서 트랩이 났어.

EXC_BREAKPOINT (SIGTRAP)   thread: RealtimeMessenger.mServiceQueue
_dispatch_assert_queue_fail
dispatch_assert_queue
_swift_task_checkIsolatedSwift
swift_task_isCurrentExecutorWithFlagsImpl
Forge  0x9e998
AVFAudio  AVAudioNodeTap::TapMessage::RealtimeMessenger_Perform()

어떻게 된 거냐면

  1. 탭은 @MainActor 클래스의 메서드에서 설치됐어.
  2. Swift 6 모드에선 메인 액터 격리 문맥 안에서 쓴 @Sendable 아닌 클로저가 메인 액터 격리로 추론돼.
  3. 컴파일러가 끝까지 검사 못 하는 API에 격리된 클로저를 넘기면, Swift 6가 클로저 입구에 런타임 격리 검사를 넣어. 소스엔 그런 걸 요청한 흔적이 하나도 없어.
  4. AVAudioNodeTapBlock은 가져온 Objective-C 블록 타입이고 @Sendable이 아니라서, 컴파일 단계에선 아무것도 막지 않았어.
  5. AVAudioEngine은 탭을 실시간 오디오 스레드에서 불러. 탭이란 게 원래 그러라고 있는 거니까. 검사는 실패하고, SIGTRAP이 떨어져.

Swift 5 모드에선 같은 줄이 기껏해야 경고고, 런타임 검사도 없어. 빌드 8이 살아남은 이유가 그거야.

고친 것과 드러난 것

클로저가 물려받을 격리를 없애. 타입에 @Sendable을 적거나, nonisolated 팩토리에서 만들어서 넘겨. 붙잡은 sink엔 이미 @unchecked Sendable이 붙어 있었지만 소용없었어. 격리는 클로저에 붙어 있지, 클로저가 붙잡은 것에 붙어 있는 게 아니야. 같은 파일에 같은 모양 클로저가 세 개 더 있었는데, 탭이 먼저 불려서 거기까지 안 갔을 뿐이야. 음성 인식 결과 핸들러 하나와 권한 콜백 두 개였어(@MainActor 타입의 static 멤버도 격리되니까 거기선 nonisolated static이 답이야). 그리고 인식 콜백을 정직하게 @Sendable로 만들자, 컴파일러가 인식 결과 객체를 메인 액터로 넘기는 걸 거부했어. 처음부터 있던 진짜 데이터 경합이었지. 지금 콜백은 Sendable한 사실(받아쓴 String, isFinal Bool)만 읽어서 그것만 넘겨.

어떤 테스트도 못 잡은 이유, 여기서만 터지는 이유

오디오 엔진을 켠 테스트가 없었어. 그래서 시뮬레이터에서도 케이블로 연결한 아이패드에서도 탭이 불리지 않았고, 검사까지 가지도 않았어. 이런 버그가 런타임 트랩까지 가는 흔한 길은 가져온 Objective-C나 C 블록 타입이야. 검사받는 Swift 안에선 컴파일러가 Sendable 아닌 클로저를 경계 너머로 보내는 걸 거부하거든. 같은 런타임 검사는 @preconcurrency 준수를 거쳐 불리는 메서드처럼, 컴파일러가 검사하지 못한 코드에서 들어오는 호출도 지켜. 가족이 가져온 규칙은 이거야. 패키지가 C나 Objective-C API에 넘기는 클로저는 전부 리뷰 대상이야. 그리고 회귀 테스트는 detached 태스크에서 블록을 불러서 격리가 없다는 걸 확인해.

Code

고장 난 탭과 고친 탭(iOS, Swift 6 모드), 둘 다 컴파일돼·swift
import AVFoundation

final class BufferSink: @unchecked Sendable {        // honestly lock-protected
    private let lock = NSLock()
    private var buffers: [AVAudioPCMBuffer] = []
    func append(_ buffer: AVAudioPCMBuffer) { lock.lock(); buffers.append(buffer); lock.unlock() }
}

@MainActor
final class Recorder {
    private let engine = AVAudioEngine()
    private let sink = BufferSink()

    func startBroken() throws {
        let input = engine.inputNode
        let sink = self.sink
        // Inferred @MainActor under Swift 6: a runtime check traps on the audio thread.
        input.installTap(onBus: 0, bufferSize: 1024, format: input.outputFormat(forBus: 0)) { buffer, _ in
            sink.append(buffer)
        }
        try engine.start()
    }

    func startFixed() throws {
        let input = engine.inputNode
        input.installTap(onBus: 0, bufferSize: 1024, format: input.outputFormat(forBus: 0),
                         block: Recorder.tap(into: sink))
        try engine.start()
    }

    // No isolation to inherit: built outside the main actor, typed @Sendable.
    nonisolated static func tap(into sink: BufferSink) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
        { buffer, _ in sink.append(buffer) }
    }
}

External links

Exercise

네 Swift 6 코드(아니면 오디오나 파일 감시 기능이 붙은 Spark)를 뒤져서, @MainActor 타입 안에서 Objective-C나 C API에 넘기는 클로저를 찾아. 오디오 탭, 인식 핸들러, FSEvents 콜백, AppKit/UIKit 권한 요청의 완료 핸들러 같은 거. 각각 어느 스레드가 부르는지, 클로저가 @Sendable인지 아니면 nonisolated 문맥에서 만들어졌는지 판단해. 하나를 고치고, 고친 블록을 Task.detached에서 불러서 메인 액터 격리가 없다는 걸 증명하는 테스트를 써봐.
Hint
위험한 모양은 @MainActor 메서드 안에 바로 쓴 클로저 리터럴을 프레임워크에 넘기는 거야. 프레임워크가 자기 큐나 실시간 스레드에서 콜백한다고 문서에 적어뒀으면, 그 클로저는 메인 액터에 격리되면 안 돼.

Progress

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

댓글 0

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

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