Swift 6 어때요? (2): executor, job, hop_to_executor
런타임 소스 좀 읽어 봤습니다
Ian • iOS Engineer
- Mobile
이 글은 채널톡 iOS 팀이 2025년 겨울부터 진행한 Swift 6 스터디를 정리하는 Swift 6 어때요? 시리즈의 두 번째 글입니다. 지난 글에서는 스레드, 블록, 태스크 세 시대가 스레드를 다루는 방식을 비교했고, 이번 글에서는 태스크를 실제로 실행하는 executor를 다룹니다.
(2) executor, job, hop_to_executor: 런타임 소스 좀 읽어 봤습니다 (현재)
(3) actor, isolation, reentrancy: SIL 좀 뽑아 봤습니다 (TBD)
Swift Concurrency에는 async/await, Task, actor, isolated, Sendable, continuation처럼 열 개가 넘는 키워드가 있습니다. 각각 별도의 규칙과 이유가 붙어 있어 처음에는 하나씩 외워야 하는 것처럼 보입니다.
저희 스터디의 첫 발표인 async/await에서 hop_to_executor를 처음 알게 됐고, 시간이 좀 더 지나 actor와 global actor, MainActor 스터디에서는 어느 키워드든 결국 executor로 job을 보내는 이야기가 된다는 확신이 생겼습니다. 이어진 data race 탐지, continuation, nonisolated 주제에서 hop이 있는 것과 없는 것으로 isolation 경계가 생기는 것을 보면서 executor가 핵심 개념이라는 데 팀원 모두가 동의하게 되었습니다.
이 글은 hop의 목적지인 executor가 무엇인지, job이 어느 executor로 가는지, executor가 왜 스레드를 늘리지 않는지, 그리고 hop_to_executor가 실제로 무엇을 하는지를 런타임 소스로 설명하려고 합니다.
실행 모델
지난 글에서 확인한 내용을 다시 적어 둡니다.
async 함수는 하나의 함수로 컴파일되지 않고, 컴파일러가 resume 지점마다 함수를 잘라 여러 개의 partial function으로 만듭니다.
partial function 사이에서 살아남아야 하는 값은 스레드의 스택이 아니라 heap에 할당된 async context에 저장되므로, 함수는 await에서 스레드를 반납하고 나중에 다른 스레드에서 이어서 실행될 수 있습니다.
이어서 실행할 때 어느 executor 위에서 실행할지는 partial function이 값으로 들고 있습니다.
값이 가리키는 executor로 옮기는 명령이 SIL의
hop_to_executor입니다.
열 두개의 hop_to_executor
글을 쓰면서 다시 세어 봤습니다. 단순 await부터 withTaskGroup, for await까지 API 열두 가지를 한 파일에 넣고 SILGen 직후의 SIL을 뽑았더니, await가 있는 async 함수와 클로저 전부에 hop_to_executor가 들어가 있었습니다. 합계는 41개입니다.
측정은 Swift 6.2.3의 Swift 6 language mode 기본 설정(
NonisolatedNonsendingByDefault꺼짐)에서 수행했습니다. 41개는 필수 최적화 패스를 거치기 전의 수치로 패스를 거치면 22개가 남습니다.
대표적인 경우 다섯 가지를 골라 원본과 SILGen 출력을 나란히 놓으면 아래와 같습니다. SIL은 각 함수의 진입부에서 목적지 executor로 hop하는 명령까지만 싣고 이후는 ~로 생략했습니다.
다섯 경우 모두 함수에 진입하자마자 hop이 놓이고, 다른 isolation의 코드를 호출할 때는 호출 직전에 목적지로 한 번 더 hop합니다. 목적지가 Optional<any Actor>의 none(global executor)인지, actor 인스턴스 %0인지, 타입에서 꺼낸 MainActor.shared인지만 다릅니다.
Executor
앞에서 본 SIL에는 명령 이름에도, 목적지에도 Executor가 들어 있었습니다. 그렇다면 Executor는 무엇일까요?
Executor는 job을 받아서 실행하는 객체이고, 어느 스레드에서 언제 실행할지는 Executor의 구현이 정합니다. 표준 라이브러리의 프로토콜 선언은 아래와 같습니다.
Executor프로토콜의 요구사항은 사실상enqueue(_ job: consuming ExecutorJob)하나입니다.Executor를 상속한SerialExecutor는 actor가 사용합니다.TaskExecutor는 Task의 선호 executor를 지정할 때 사용합니다.
어느 프로토콜에도 스레드는 등장하지 않습니다.
저희는 custom executor와 global executor의 구현을 읽고 나서, 스레드가 프로토콜 선언에 없다는 사실을 근거로 Executor를 worker thread 위의 추상화 계층으로 이해하게 되었습니다.
job은 어느 Executor로 가나
job이 어느 Executor로 가는지는 isolation이 정하고, 런타임의 swift_task_enqueue 구현에 세 분기로 적혀 있습니다.
isolation이 없는 job은 global executor로 가고, Task에 task executor preference가 있으면 해당 executor로 갑니다.
default actor에 격리된 job은 actor 자신의 큐로 갑니다.
MainActor와 custom executor를 가진 actor의 job은 해당 executor 객체의enqueue로 넘어갑니다.
두 번째와 세 번째 분기를 가르는 것은 Actor 프로토콜이 요구하는 unownedExecutor 프로퍼티입니다.
actor로 선언하면 컴파일러가 이 프로퍼티를 합성하므로 따로 적지 않아도 됩니다. 이 actor가 default actor입니다. 만약 프로퍼티를 직접 구현해 다른 SerialExecutor를 돌려주면 custom executor를 가진 actor가 되어, job은 세 번째 분기로 이동합니다.
Executor와 thread explosion
세 갈래 가운데 global executor는 job이 아무리 많아도 스레드를 늘리지 않습니다. 구조 두개와, 실험 하나로 이를 확인하려고 합니다.
첫째는
Executor프로토콜이enqueue로 job을 받을 뿐 스레드를 만들 수단이 없다는 것둘째는 Apple 플랫폼의 global executor가 job을 libdispatch의 cooperative queue에 넘길 뿐이라는 것입니다.
실험은 다음과 같습니다. 스터디에서 확인하고 싶었던 것이 있었습니다. 지난 글에서 본 대로, DispatchQueue에서는 기다리는 블록마다 스레드가 늘어나 thread explosion으로 이어졌는데, Swift Concurrency에서 Task 100개가 한꺼번에 기다려도 스레드가 늘지 않는다는 것을 문서가 아니라 실험으로 확인해야 했습니다.
실험 환경은 iPhone 12(6코어: 성능 코어 2개, 효율 코어 4개), iOS 18.7.3, Swift 6.2.3입니다.
withTaskGroup으로 child task 100개를 만들고, 각 task가 2초를 기다리되 한쪽은 Task.sleep으로 suspend하고 다른 쪽은 Thread.sleep으로 스레드를 붙들게 했습니다. Thread.sleep을 async 문맥에서 호출하면 Swift 6 language mode에서는 컴파일 에러가 나므로, Swift 5 language mode의 Release 빌드로 실행했습니다.
Task.sleep쪽은 100개가 곧바로 시작되어 2.13초에 끝났고, 로그에 찍힌 스레드는 8개였습니다. task가 suspend되면서 스레드를 반납했기 때문에 스레드 8개로 100개를 처리한 것입니다.Thread.sleep쪽은 12개가 시작되고 2초간 아무 로그가 없다가 다음 12개가 시작되는 식으로 18.02초가 걸렸고, 스레드는 코어 수인 12개에서 늘지 않은 채 나머지 88개가 큐에서 기다렸습니다.
두 결과가 보여 주는 것은 아래와 같습니다.
global executor가 job이 suspend 또는 blocking을 하더라도 스레드를 늘리지 않고 job을 기다리게 한다.
blocking 쪽에서 88개가 기다린 18초는 스레드를 반납하지 않는 job이 global executor 전체를 느리게 만든다.
위 실험적 사실의 근거는 여러 곳에서 찾을 수 있습니다. 예를 들어, global executor는 enqueue에서 런타임 내부 함수인 swift_dispatchEnqueueGlobal을 호출하는데, 이 함수 위의 주석에 Swift 런타임이 global executor에 요구하는 네 가지가 적혀 있습니다. 두번째는 특히 explosion 금지입니다.
We really want four things from the service:
Enqueuing work should have minimal runtime and memory overhead.
Adding work should never result in an "explosion" where many more threads are created than the available cores.
Jobs should run on threads with an appropriate priority.
Thread priorities should temporarily elevatable to avoid priority inversions.
Of these, the first two are the most important. … if heavy use of it leads to thread explosions and memory exhaustion, programmers will have no choice but to stop using it.
executor가 스레드를 늘리지 않는다면, 남는 걱정은 hop_to_executor가 hop마다 스레드를 옮긴다면 executor가 아낀 스레드를 hop이 쓰게 되는것이 아닌가? 입니다.
hop이 실제로 무엇을 하는지는 다음 문단에서 swift_task_switch의 소스로 보려고 합니다.
hop_to_executor의 비용
hop_to_executor는 executor를 바꾸는 명령이지 스레드를 옮기는 명령이 아니고, 현재 스레드에서 목적지 executor의 job을 그대로 실행할 수 있으면 스레드를 옮기지 않습니다.
저희도 처음에는 hop이 많아지면 그만큼 비용이 들어 성능이 떨어질 것이라고 생각했습니다. hop이 곧 스레드 전환을 유발할 것이라는 것 때문이었는데요. resume할 partial function과 필요한 값은 heap의 async context에 있어서 특정 스레드에 묶여 있지 않으므로 전제가 성립하지 않습니다. 모든 hop이 스레드 context switching을 일으키지는 않는다는 것을 런타임 소스에서 확인했습니다.
hop_to_executor가 도착하는 런타임 함수 swift_task_switch의 구현은 세 단계로 나뉘어 있습니다.
현재 executor가 목적지와 호환되면 아무것도 바꾸지 않고 resume 함수를 같은 스레드에서 바로 호출합니다(①).
목적지 executor에 실행 중인 job이 없으면 현재 스레드가 목적지 executor를 맡아 같은 스레드에서 이어서 실행합니다(②).
목적지 executor가 다른 job을 실행하고 있을 때만 partial function을 목적지 executor의 큐에 enqueue하고 현재 스레드를 반납합니다(③). job이 큐에서 기다리는 것은 ③ 뿐입니다.
기다리는 동안에도 스레드는 멈추지 않고 큐에서 다음 job을 가져갑니다.
정리
executor는 job을 받아 실행하는 객체이고, 어느 스레드에서 실행할지는 executor의 구현이 정합니다.
global executor는 job이 아무리 쌓여도 스레드를 늘리지 않고, 대신 코드가 스레드를 blocking으로 붙들지 않기를 요구합니다.
hop_to_executor는 executor를 바꾸는 명령이지 스레드를 옮기는 명령이 아니고, 목적지 executor가 바쁠 때만 job을 큐에 enqueue합니다.
지난 글에서 저희 스터디의 목표는 "Swift 6 어때요?"라는 질문에 이건 별론데요, 이건 좋던데요 하고 답할 수 있게 되는 것이라고 하였는데요. executor에 대해서는 저희도 이제 답할 수 있습니다. job이 아무리 늘어나도 executor가 스레드를 늘리지 않는 것은 좋았습니다.
다음 글에서는 isolation이 어느 executor를 가리키는 값인지, reentrancy가 왜 executor가 비는 순간에 생기는지를 actor의 SIL로 이어 가려고 합니다.
