Swift 6 어때요? (1): 스레드, 블록, 태스크
바이너리 좀 뜯어 봤습니다
Ian • iOS Engineer
- Mobile
Channel Works의 프로덕션 코드에는 아래와 같은 함수가 있습니다.
actor 안에서 RxSwift 체인이 GCD 스케줄러 위에서 돌아갑니다. 서로 다른 시대에 나온 비동기 기술 셋이 한 함수에 겹쳐 있습니다. 언뜻 위험해 보이는 조합이지만, 저희는 이 코드가 안전하다고 확신합니다. 컴파일러가 통과시켜 줘서가 아닙니다. 이 코드가 런타임에서 정확히 어떻게 동작하는지 알기 때문입니다.
이 확신은 그냥 생긴 것이 아닙니다. 2025년 겨울, 저희 팀은 Swift 6 스터디를 시작하며 이렇게 선포했습니다.
목표 :
"Swift 6 어때요?"라고 물어봤을 때 "아 음 그거요, 이건 별론데요, 이건 좋던데요" 이야기할 수 있게 된다.
목표가 아닌 것 : 프로덕트 코드에 적용하는 것.
적용이 목표가 아니라니, 회사 스터디로는 이상한 선언입니다. 하지만 순서에 이유가 있습니다. 저희 팀은 기본을 중요하게 여깁니다. 기술을 쓰려면 그 기술이 정확히 어떻게 동작하는지부터 이해해야 한다고 믿고, 동작을 설명할 수 없는 코드는 프로덕션에 두지 않으려 합니다. 라이브러리도 예외는 아닙니다. 그런데 Swift Concurrency는 그 기준을 채운 상태로 들일 수 있는 기술이 아니었습니다. RxSwift를 걷어내고 한 번에 갈아탈 수는 없으니, 어떤 경로로 가든 RxSwift와 Swift Concurrency가 한 코드베이스에서 함께 실행되는 과도기를 지나야 합니다. 맨 위의 코드가 바로 그 과도기의 모습입니다.
Swift 6이 보증하는 data race safety는 컴파일러가 보는 코드까지만 적용되고, RxSwift와 Swift Concurrency의 interoperability는 보장해 주지 않습니다. 그래서 도입을 아예 목표에서 지웠습니다. 이 스터디는 도입을 위한 준비 운동이 아니라, 언어의 동작을 SIL과 바이너리까지, low level로 파고드는 것 자체가 목적인 탐구였습니다.
프로덕션 적용은 그 뒤에 따라왔습니다. 스터디를 마치고 나니 이거 되겠는데 싶은 지점들이 보였고, 파일럿으로 하나씩 적용해 본 결과가 맨 위의 저 코드입니다.
이 시리즈는 그 탐구의 기록입니다. 첫 글의 질문은 이것입니다. 각 시대는 무엇을 작업 단위로 삼았고, 왜 그럴 수밖에 없었을까요? 저 함수 하나에는 십수 년치의 비동기 역사가 쌓여 있습니다. 애플 플랫폼이 스레드를 대하는 원리는 지금까지 세 번 바뀌었고, 세 원리는 지금도 저희 코드베이스 안에서 공존합니다. 이 혼재가 안전한지 판단하려면 그 답부터 알아야 합니다. 그래서 세 원리를 오래된 것부터 하나씩 봅니다.
스레드의 시대
시작은 스레드 그 자체였습니다. NSThread로 스레드를 직접 만들어 쓰던 시절입니다. 이 시대의 원리는 단순합니다. 스레드는 개발자가 직접 소유하고 조작하는 자원입니다. 만들고, 우선순위를 정하고, 언제 끝낼지 정하는 것까지 전부 코드로 통제했습니다. 코어가 사실상 하나였고 만들 스레드도 몇 개 되지 않던 환경에서는 그것으로 충분히 통제 가능한 모델이었습니다.
블록의 시대
그 전제가 2004년 무렵 무너집니다. 클럭 경쟁이 전력의 벽에 부딪히자 업계는 멀티코어로 방향을 틀었고("The Free Lunch Is Over", 2005), 적정 스레드 수는 기계의 코어 수에 따라 달라졌습니다. 그런데 코어 수는 기계마다 다릅니다. 스레드를 손으로 세는 모델은 원리적으로 성립하지 않게 된 것입니다.
2009년 GCD의 답은 작업 단위를 바꾸는 것이었습니다. 개발자는 스레드 소유를 포기하고, "Block"을 큐에 던집니다. 스레드는 시스템이 관리합니다.
이 전환에는 언어를 고치는 비용이 따랐습니다. 블록을 값으로 만들어 건네는 문법이 C에는 없었기 때문입니다. C에서 코드를 건네는 유일한 수단인 함수 포인터는 컨텍스트를 담지 못합니다. 그래서 컨텍스트 구조체를 정의하고, malloc으로 담고, 반대편의 별도 함수에서 풀어내는 수작업이 매번 필요했습니다.
이 방식의 흔적은 지금도 SDK 헤더에 남아 있습니다.
blocks가 없었다면 GCD의 API 전체가 이 _f 모양이었을 것입니다. 그래서 애플은 GCD를 쓰게 하려고 C 언어에 클로저 문법을 확장으로 얹었고, 같은 해에 둘을 함께 출시했습니다. 위의 수작업은 한 줄이 됩니다.
표준으로 만드려는 시도도 있었습니다. 2009년 N1370으로 표준화 위원회에 올렸고 2010년 N1451로 C11에 정식 제안했지만, 표결은 찬성 6, 반대 5, 기권 4로 합의에 이르지 못했습니다. 2016년 N2030까지 세 번 시도했고 모두 채택되지 않아, blocks는 지금도 clang 확장으로 남아 있습니다. 표준을 기다리는 대신 자기 컴파일러를 고쳐서 출시한 것입니다.
기억해 둘 복선입니다. 동시성 문제는 라이브러리 선에서 끝나지 않고 언어를 건드리게 만듭니다.
그런데 이 작업 단위에는 구조적 한계가 하나 있습니다. 큐는 블록 안을 볼 수 없습니다. 큐에게 블록은 불투명한 함수 포인터일 뿐입니다. 블록이 네트워크를 기다리는지 semaphore에 걸려 있는지 큐는 알지 못합니다. 큐가 아는 것은 "던져준 스레드가 돌아오지 않는다"뿐입니다. 코어를 놀릴 수는 없으니 시스템의 선택지는 하나로 좁혀집니다. 스레드를 더 만드는 것입니다. 블록들이 연쇄적으로 기다리면 스레드는 수십, 수백 개로 불어납니다. thread explosion입니다. 스레드마다 스택 메모리가 들고, 커널은 그 사이를 오가며 context switching 비용을 치릅니다.
이걸 막을 방법이 시스템에는 없으므로, 가이드는 사람의 몫이 됩니다. "semaphore로 큐를 건너 기다리지 마세요", "동기 I/O를 큐에 던지지 마세요". 가이드가 개발자의 머릿속에만 있는 한 thread explosion은 시스템이 막을 수 없는 문제였습니다.
태스크의 시대
어떤 큐 라이브러리를 새로 만들어도, 블록이 무엇을 기다리는지는 여전히 블록 안에 갇혀 있습니다. 이 정보를 기계가 보는 곳으로 꺼내려면 언어가 바뀌어야 했습니다. blocks의 일이 12년 만에 훨씬 큰 규모로 반복된 것입니다.
새 작업 단위를 이해하려면 블록이 왜 중간에 멈출 수 없었는지부터 봐야 합니다. 실행 중인 블록의 진행 상태, 그러니까 지역변수들과 어디까지 실행했는지는 전부 스레드의 스택에 있습니다. 그리고 스택은 스레드의 소유물입니다. 블록이 스레드에서 내려오는 순간 그 상태는 갈 곳이 없습니다. 그래서 블록의 선택지는 둘뿐이었습니다. 끝까지 실행되거나, 스레드를 붙든 채 기다리거나.
그러니 기다리는 동안 스레드에서 내려오려면 두 가지가 필요합니다. 지금까지의 값들을 스레드 밖, 즉 힙에 보관할 자리. 그리고 어디서부터 다시 시작할지 가리키는 resume 지점. 사실 이 두 가지는 completion handler로 이미 하던 일입니다. 함수의 나머지 절반을 클로저로 잘라내 넘기고, 이어서 쓸 값들은 캡처에 실어 힙으로 보냈습니다. 콜백 지옥이라고 부르던 구조는 사람이 함수를 손으로 쪼갠 결과였습니다.
async/await는 그 쪼개기를 컴파일러에게 넘깁니다. await는 가독성을 위한 장식이 아니라 "여기서 suspend될 수 있으니, 여기서 잘라라"라는 표시이고, 컴파일러는 실제로 그 지점에서 함수를 자릅니다. 함수는 await를 경계로 나뉘고, 조각 사이에 살아남아야 하는 값들은 컴파일러가 힙의 컨텍스트로 옮겨 줍니다. 저희가 콜백으로 하던 일과 같은 일입니다. 다만 이제는 자동이고, 코드는 순서대로 쓰인 모양 그대로입니다.
말로만 하면 비유처럼 들리니, 저희가 스터디에서 확인한 방식대로 증거를 보겠습니다. await가 하나 있는 함수를 준비합니다.
컴파일한 바이너리에서 심볼을 뽑아 이름을 되돌리면 이렇게 나옵니다.
$ nm cutdemo.o | swift demangle
T cutdemo.work() async -> Swift.Int
t (1) await resume partial function for cutdemo.work() async -> Swift.Int
t (2) suspend resume partial function for cutdemo.work() async -> Swift.Int
U _swift_task_alloc
U _swift_task_dealloc
U _swift_task_switch하나로 작성한 work()가 바이너리에는 진입부와 두 개의 partial function으로 존재합니다. 크래시 로그에서 한 번쯤 보셨을 await resume partial function이 바로 이 조각입니다. await는 하나인데 조각이 왜 둘인지는 잠시 뒤에 다시 보겠습니다. 조각 사이의 값들이 담기는 힙 컨텍스트는 swift_task_alloc이 할당하고, await 지점에 도달하면 함수는 swift_task_switch를 호출하며 스레드에서 내려옵니다. 두 심볼 모두 위 바이너리가 링커에 요구하는 목록에 그대로 찍혀 있습니다.
이 쪼개기의 효과는 함수 하나에서 끝나지 않습니다. 이제 런타임은 이 작업이 무엇을 기다리는지, 끝나면 어디서부터 resume하면 되는지 압니다. 블록 시대에 스레드를 늘리는 것 말고는 방법이 없게 만들었던 바로 그 맹점이 사라진 것입니다. 기다리는 태스크는 스레드를 잡고 서 있는 대신 반납하고, 스레드는 다음 태스크를 집어 듭니다. 스레드를 늘려서 대응할 이유가 사라졌습니다. 그래서 Swift Concurrency는 스레드 수를 불려 가는 대신, 크기가 정해진 풀 위에서 태스크를 바꿔 가며 실행합니다.
물론 조건이 있습니다. 이 모델은 풀의 스레드들이 blocking 없이 계속 forward progress를 만들 때만 성립합니다. 표준 라이브러리 소스에는 이 조건이 문서 주석으로 명시되어 있습니다.
a fixed size pool of threads … should not be used for blocking operations which do not guarantee forward progress (
GlobalConcurrentExecutor.swift)
그래서 두 시대의 가이드는 성격이 다릅니다.
블록의 시대에 가이드는 컨벤션이였습니다. 규칙은 문서에 있었고, 어겨도 컴파일 타임에는 아무 일도 일어나지 않았습니다. 문제는 런타임에서 크래시나 앱 멈춤으로 나타났습니다.
태스크의 시대에 가이드는 계약이고, 계약의 절반은 문법이 강제합니다. 기다림은 await으로 적어야 하고, 적지 않으면 컴파일되지 않습니다. 하지만 나머지 절반은 여전히 문서에 있습니다. await로 적는 대신 스레드를 쥐 채 기다리는 것, 즉 blocking은 컴파일러가 보지 못합니다. 그리고 크기가 정해진 풀에서는 blocking 몇 개가 겹치는 것만으로 풀 전체가 설 수 있습니다. Swift 6이 컴파일 에러로 승격시킨 것은 data race와 isolation 위반까지입니다. blocking을 하지 말라는 조항은 계약서에 있지만, 지키는 것은 여전히 사람의 몫입니다.
잘린 함수가 만든 새 질문
그런데 함수가 조각나는 순간, 전에는 없던 질문이 하나 생깁니다. (1) await resume partial function이 resume될 때, 그 코드는 어느 스레드에서 실행되나요?
블록의 시대에는 답이 자명했습니다. 블록은 내가 던진 큐에 속했습니다. main 큐에 던졌으면 main에서 돕니다. 그런데 태스크의 시대에 스레드는 익명입니다. 반납되고, 빌려지고, 다음 태스크를 집어 드는 공용 자원일 뿐입니다. 그러니 잘린 조각은 자신이 어디에 속하는지를 스스로 들고 다녀야 합니다.
실제로 그렇습니다. 아까 그 work()의 SIL을 열면 이런 구조가 보입니다.
%0 = enum $Optional<any Actor>, #Optional.none!enumelt
...
%5 = apply %4() : $@convention(thin) @async () -> Int // await fetchValue()
hop_to_executor %0resume 지점에 hop_to_executor 명령이 붙어 있습니다. suspend가 끝나면 원래의 executor로 돌아가 이어가라는 뜻이고, 돌아갈 곳은 함수에 값으로 들려 있습니다. work()는 nonisolated 함수라 그 값이 none이지만(아무 스레드에서 이어가도 된다는 뜻입니다), @MainActor 함수였다면 %0에는 MainActor가 들어 있고 hop의 목적지는 메인 스레드가 됩니다. 아까 남겨둔 질문도 여기서 풀립니다. 컴파일러는 resume 지점마다 함수를 자르는데, work()에는 그 지점이 둘입니다. fetchValue()가 끝나 돌아오는 자리가 하나, hop_to_executor를 지나 제 executor에서 이어가는 자리가 하나입니다. resume 지점 하나가 곧 절단 지점 하나이고, 그래서 await는 하나인데 조각은 둘입니다. 이것은 바이너리에 새겨진 컴파일 시점의 사실입니다. @MainActor를 붙여 다시 컴파일해도 조각 수는 둘 그대로입니다. 그 둘째 조각으로 넘어갈 때 실제로 스레드를 갈아타는 비용이 드는지는 별개의 문제이고, hop_to_executor가 그 자리에서 무엇을 하느냐에 달려 있습니다.
다시, getBaseTutorial()
맨 위의 getBaseTutorial()로 돌아가 보겠습니다.
이제 그 함수에 무엇이 겹쳐 있는지 이름을 붙일 수 있습니다. actor와 await는 태스크의 시대이고, ConcurrentDispatchQueueScheduler는 블록의 시대입니다. RxSwift는 블록을 큐에 던지는 세계 위에 세워진 라이브러리이니 이쪽도 블록 편입니다. 한 함수 안에서 두 시대가 맞닿아 있고, withSafeContinuation이 그 접점입니다.
두 시대를 잇는 일이 왜 조심스러운지도 이제 말할 수 있습니다. 블록은 끝까지 실행되거나, 기다리는 동안 스레드를 붙들고 있습니다. 태스크는 await 지점에서 잘리고, 이어서 쓸 값들을 힙의 컨텍스트에 남긴 뒤 스레드를 반납합니다. 작업 단위가 다르니, 한쪽에서 통하던 컨벤션을 그대로 가져가면 다른 쪽의 계약을 어기게 됩니다. 위 함수가 안전한 근거도, 위험해질 수 있는 조건도 전부 이 차이에서 나옵니다. 조항을 하나씩 따지는 일은 뒤의 글에서 하겠습니다.
여기까지 오는 동안 같은 일이 두 번 있었습니다. 2009년에는 블록을 값으로 건네려고 C에 문법을 얹었고, 2021년에는 함수를 자르려고 Swift에 async와 await를 넣었습니다. 두 번 다 라이브러리로는 안 됐습니다. 큐는 블록 안을 볼 수 없었고, 함수를 자르는 일은 컴파일러만 할 수 있었기 때문입니다.
그런데 저 함수를 두고 아직 답하지 못한 것이 하나 남습니다. 잘린 조각이 어디에 속하는지는 무엇이 정하고, 그 판단은 누가 내리는가입니다. hop_to_executor는 저희 스터디가 가장 오래 붙잡고 있었던 명령어입니다. 스레드가 익명이 된 세계에서 "이 코드가 어디에 속하는가"를 다루는 새 개념을 Swift는 isolation이라고 부릅니다. 그리고 이 개념이 어떻게 공유 상태를 지키고 data race를 컴파일 타임에 잡아내는지가, 저희가 Swift 6으로 넘어간 진짜 이유와 닿아 있습니다. 다음 글에서 그 개념을 해부합니다. 미리 결론을 한 문장 적어 두면 이렇습니다. isolated는 타입이 아니라 실행 컨텍스트입니다.
