Swift 6 어때요? (1): 스레드, 블록, 태스크
바이너리 좀 뜯어 봤습니다
Ian • iOS Engineer
- Mobile
이 글은 채널톡 iOS 팀이 2025년 겨울부터 진행한 Swift 6 스터디를 정리하는 Swift 6 어때요? 시리즈의 첫 번째 글입니다.
(1) 스레드, 블록, 태스크: 바이너리 좀 뜯어 봤습니다 (현재)
(3) actor, isolation, reentrancy: SIL 좀 뽑아 봤습니다 (TBD)
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는 보장해 주지 않습니다. 그래서 도입을 아예 목표에서 지웠습니다. 이 스터디를 Swift 6 도입을 위한 준비 운동이 아니라, 언어의 동작을 SIL과 바이너리까지, low level로 파고드는 것 자체를 목적으로 설정했습니다.
프로덕션 적용은 그 뒤에 따라왔습니다. 스터디를 마치고 나니 이거 되겠는데 싶은 지점들이 보였고, 파일럿으로 하나씩 적용해 본 결과가 맨 위의 getBaseTutorial()입니다.
이 시리즈는 스터디의 기록입니다. 각 시대는 무엇을 작업 단위로 삼았고, 왜 그럴 수밖에 없었을까요? getBaseTutorial() 하나에는 십수 년치의 비동기 역사가 쌓여 있습니다. 애플 플랫폼이 스레드를 대하는 원리는 지금까지 세 번 바뀌었고, 세 원리는 지금도 저희 코드베이스 안에서 공존합니다. 이 혼재가 안전한지 판단하려면 그 답부터 알아야 합니다. 그래서 세 원리를 오래된 것부터 하나씩 보려고 합니다.
스레드의 시대
시작은 스레드 그 자체였습니다. NSThread로 스레드를 직접 만들어 쓰던 시절입니다. 이 시대의 원리는 단순합니다. 스레드는 개발자가 직접 소유하고 조작하는 자원입니다. 만들고, 우선순위를 정하고, 언제 끝낼지 정하는 것까지 전부 코드로 통제했습니다. 코어가 사실상 하나였고 만들 스레드도 몇 개 되지 않던 환경에서는 그것으로 충분히 통제 가능한 모델이었습니다.
블록의 시대
코어가 사실상 하나라는 전제는 2004년 무렵 무너집니다. 클럭 경쟁이 전력의 벽에 부딪히자 업계는 멀티코어로 방향을 틀었고("The Free Lunch Is Over", 2005), 적정 스레드 수는 기계의 코어 수에 따라 달라졌습니다. 그런데 코어 수는 기계마다 달라서, 스레드를 손으로 세는 모델은 원리적으로 성립하지 않게 되었습니다.
2009년 GCD의 답은 작업 단위를 바꾸는 것이었습니다. 개발자는 스레드 소유를 포기하고, 블록을 큐에 던집니다. 스레드는 시스템이 관리합니다.
이 전환에는 언어를 고치는 비용이 따랐습니다. 블록을 값으로 만들어 건네는 문법이 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 비용을 치릅니다.
thread explosion을 막을 방법이 시스템에는 없으므로, 가이드는 사람의 몫이 됩니다.
semaphore로 큐를 건너 기다리지 않습니다.
동기 I/O를 큐에 던지지 않습니다.
태스크의 시대
어떤 큐 라이브러리를 새로 만들어도, 블록이 무엇을 기다리는지는 여전히 블록 안에 갇혀 있습니다. 이 정보를 기계가 보는 곳으로 꺼내려면 언어가 바뀌어야 했습니다.
새 작업 단위를 이해하려면 블록이 왜 중간에 멈출 수 없었는지부터 봐야 합니다. 실행 중인 블록의 진행 상태, 그러니까 지역변수들과 어디까지 실행했는지는 전부 스레드의 스택에 있습니다. 그리고 스택은 스레드의 소유물입니다. 블록이 스레드를 반납하면 스택에 있던 상태를 보관할 곳이 없어서, 블록의 선택지는 둘뿐이었습니다.
끝까지 실행됩니다.
스레드를 붙든 채 기다립니다.
기다리는 동안 스레드를 반납하려면 두 가지가 필요합니다.
지금까지의 값을 스레드 밖, 즉 힙에 보관할 자리가 있어야 합니다.
어디서부터 다시 시작할지 가리키는 resume 지점이 있어야 합니다.
사실 이 두 가지는 completion handler로 이미 하던 일입니다. 함수의 나머지 절반을 클로저로 잘라내 넘기고 이어서 쓸 값들은 캡쳐해서 힙으로 보냈습니다.
async/await는 함수를 나누는 일을 컴파일러에게 넘깁니다. await는 가독성을 위한 장식이 아니라 여기서 suspend될 수 있으니 이 지점에서 함수를 자르라는 표시이고, 컴파일러는 실제로 그 지점에서 함수를 자릅니다. 함수는 await를 경계로 나뉘고, partial function 사이에 살아남아야 하는 값들은 컴파일러가 힙의 컨텍스트로 옮겨 줍니다.
저희가 스터디에서 확인한 방식대로, 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이 이 partial function입니다. await는 하나인데 partial function이 왜 둘인지는 뒤에서 보려고 합니다. partial function 사이의 값들이 담기는 힙 컨텍스트는 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)
블록의 시대에 가이드는 컨벤션이었습니다. 규칙은 문서에만 있어서 어겨도 컴파일 타임에는 아무 일도 일어나지 않았고, 문제는 런타임에서 크래시나 앱이 멈춘다는 것이었어요.
태스크의 시대에 가이드는 runtime contract이고, 컴파일러가 강제하는 부분과 사람이 지켜야 하는 부분으로 나뉩니다.
기다림은
await으로 적어야 하고, 적지 않으면 컴파일되지 않습니다.async 함수 안에서
Thread.sleep이나DispatchSemaphore.wait처럼 SDK가 async 문맥에서 쓸 수 없다고 표시한 API를 직접 호출하면, Swift 6 language mode에서는 컴파일 에러가 납니다.같은 blocking을 동기 함수로 한 번 감싸 부르거나
DispatchQueue.main.sync처럼 표시되지 않은 API로 기다리면 컴파일러는 보지 못하고, 크기가 정해진 풀에서는 이런 blocking 몇 개가 겹치는 것만으로 풀 전체가 설 수 있습니다.
runtime contract에는 blocking 금지가 들어 있지만, 컴파일러가 잡아 주는 것은 일부이고 나머지는 여전히 사람의 몫입니다.
잘린 함수가 만든 새 질문
(1) await resume partial function이 resume될 때, 그 코드는 어느 스레드에서 실행되나요?
블록의 시대에는 답이 자명했습니다. 블록은 내가 던진 큐에 속했습니다. main 큐에 던졌으면 main에서 돕니다. 그런데 태스크의 시대에는 어느 스레드에서 실행될지 알 수 없습니다. 한 태스크가 반납한 스레드를 다른 태스크가 가져다 쓰므로, partial function은 자신이 어디에 속하는지를 스스로 들고 다녀야 합니다.
앞의 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가 붙어 있고, 돌아갈 executor는 %0에 값으로 들어 있습니다. work()는 nonisolated 함수라 %0이 none이어서 global executor에서 이어 실행되고, @MainActor 함수였다면 %0에 MainActor가 들어 있어 MainActor의 executor에서 이어 실행됩니다.
앞에서 await는 하나인데 partial function이 둘이었던 것은 work()에 resume 지점이 둘이기 때문입니다.
fetchValue()가 끝나고 돌아오는 지점입니다.hop_to_executor를 지나 목적지 executor에서 이어 실행하는 지점입니다.
@MainActor를 붙여 다시 컴파일해도 partial function은 둘이므로, 분할은 컴파일 시점에 정해집니다.
다시, getBaseTutorial()
맨 위의 getBaseTutorial()에는 두 시대가 겹쳐 있습니다.
태스크의 시대:
actor,await블록의 시대:
ConcurrentDispatchQueueScheduler, 그리고 블록을 큐에 던지는 세계 위에 세워진 RxSwift두 시대의 접점:
withSafeContinuation
블록은 끝까지 실행되거나, 기다리는 동안 스레드를 붙들고 있습니다. 태스크는 await 지점에서 잘리고, 이어서 쓸 값들을 힙의 컨텍스트에 남긴 뒤 스레드를 반납합니다. 작업 단위가 다르니, 한쪽에서 통하던 컨벤션을 그대로 가져가면 다른 쪽의 runtime contract를 어기게 됩니다.
getBaseTutorial()이 안전한 근거도, 위험해질 수 있는 조건도 전부 작업 단위의 차이에서 나옵니다. 조건을 하나씩 따지는 일은 뒤의 글에서 하겠습니다.
여기까지 오는 동안 같은 일이 두 번 있었습니다. 2009년에는 블록을 값으로 건네려고 C에 문법을 얹었고, 2021년에는 함수를 자르려고 Swift에 async와 await를 넣었습니다. 두 번 다 라이브러리로는 안 됐습니다. 큐는 블록 안을 볼 수 없었고, 함수를 자르는 일은 컴파일러만 할 수 있었기 때문입니다.
getBaseTutorial()을 두고 아직 답하지 못한 것이 하나 남습니다. resume된 partial function이 어느 executor에서 실행될지는 무엇이 정하느냐는 것입니다. hop_to_executor는 저희 스터디가 가장 오래 붙잡고 있었던 명령어이고, 다음 글에서는 hop_to_executor의 목적지인 executor가 무엇인지부터 보려고 합니다.
