В данной части из серии статей мы погрузимся чуть глубже в недра Swift Concurrency. Как определяется поток, на котором будет выполняться Task? Почему в рамках одной Task поток может меняться? Что такое Executor и на какие типы он делится? За что отвечает TaskExecutor, а за что SerialExecutor? Как определить текущий Executor по коду? Как использовать свой самописный Executor? В этой статье ответим на эти (и многие другие) вопросы.
Статьи из серии
Swift concurrency. Executors, Actors и их связь с потоками
Оглавление
Компоненты Swift Concurrency
Все предыдущие части мы в основном пользовались инструментами SC, особо не задумываясь о том, что происходит под капотом.
Swift Concurrency абстрагирует работу с потоками, но иногда разработчикам нужен более низкоуровневый контроль Кусочками работа с потоками уже описывалась в предыдущих статьях, но давайте тут соберем все это в кучу, так как далее эти знания точно пригодятся.
Мы знаем, что выполнение кода в итоге все равно происходит на потоках. Тут ничего фундаментального нового SC не вводит (хотя Apple и любит присваивать известным терминам свои нейминги). Также мы знаем, что в рамках одной Task
поток может меняться между suspension points. Но на основе чего происходит выбор потока? Для ответа на этот вопрос, сначала разберемся, из каких компонентов состоит цепь исполнения SC.
Некоторые компоненты нам уже знакомы, про некоторые что-то слышали, а некоторые вообще увидим впервые. Давайте структурируем наши знания:
Task
, child task - базовые единицы SC, с которыми работает разработчик и в рамках которых выполняются асинхронные функции.
A task is to asynchronous functions, what a thread is to synchronous functions
Job
— часть таски (набор методов/инструкций) между точками прерываний (await/suspension points). КаждаяTask
состоит минимум из однойJob
.Job
— фундаментальная часть SC.Executor
— тот самый «системный планировщик», о котором уже вскользь упоминалось в предыдущих статьях. Он отвечает за распределениеJob
по потокам.Cooperative thread pool — пулл потоков, где их количество равно количеству ядер у девайса.
Теперь объединяя все вместе: мы, в лице разработчиков, создаем Task. Она в свою очередь делиться на Job's, которые попадают в executor для дальнейшей разбивки по потокам из thread pool.

Напомню, что очень важным нюансом является тот факт, что количество потоков в Cooperative thread pull равно количеству ядер на устройстве.
Создавать Task
мы умеем — создали уже добрый десяток. А что касательно Executor
и Job
? Если говорить про Executor'ы, то у них уже есть реализации, которые работают без нашего ведома (логично, иначе мы бы уже давно их создавали). Они бывают следующих типов:
Global concurrent executor — дефолтный планировщик, который раскидывает Job'ы по потокам из Cooperative Thread Pool. В большинстве случаев он и планирует все наши джобы (конкуррентно). Выше на диаграмме как раз пример работы с ним.
Serial executors — каждый actor имеет свой serial executor, который так же выполняет джобы на потоках из Cooperative Thread Pool, только последовательно/серийно (подробнее про actor'ы чуть позже).
Main Actor executor — особый serial executor, который выполняет джобы на main thread.
Но помимо дефолтных у нас есть возможность и создать свой executor, благодаря чему получится посмотреть, как работает вся эта схема изнутри. Давайте не будем медлить и накидаем наивную реализацию кастомного executor'а.
Реализация наивного Executor
На данный момент существует 2 протокола для реализации кастомных Executor'ов.
TaskExecutor. Для использования в иерархии Task.
SerialExecutor. Для использования в Actor.
Изначально реализуем первый:
// 1
@available(iOS 18.0, *)
final class CustomExecutor: TaskExecutor {
// 2
func enqueue(_ job: consuming ExecutorJob) {
// 3
job.runSynchronously(on: asUnownedTaskExecutor())
}
// 4
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
1. TaskExecutor появился недавно - минимальная версия начинается с iOS 18. Использовать в бою его еще долго не сможем, но посмотреть, как работает система на нем, достаточно удобно.
2. Для соответствия протоколу необходимо реализовать метод enqueue
, который как раз и принимает параметр в виде Job
, который является частью выполнения какой-либо Task.
Ключевое слово
consuming
появилось тоже относительно недавно. Оно не относится к SC, но будет полезно подсветить, что оно значит.consuming
указывает, что функция получает исключительное владение объектом, и вызывающий код теряет возможность использовать его после передачи в функцию. Противоположность —borrowing
. Подробнее можете ознакомится с ними в этом пропозале. Для нас здесь важно, чтоJob
после передачи в нашу функцию не имеет возможности использоваться где-то во вне. Мы получаем исключительное право на владение.
3. Джобы состоят из набора функций, которые необходимо где-то выполнить. Метод runSynchronously
как раз и выполняет джобу. В нашей наивной реализации выполняем ее сразу, без каких-то предварительных действий.
4. Данный метод также требуется протоколом для получения бесхозной ссылки на текущий Executor. Ей мы уже воспользовались на предыдущем этапе, так как для выполнения runSynchronously
требуется подкинуть туда как раз ссылку на текущий executor.
Заранее обложим метод логами:
@available(iOS 18.0, *)
final class CustomExecutor: TaskExecutor {
func enqueue(_ job: consuming ExecutorJob) {
let jobDescription = job.description
print("\(#function) before job.runSynchronously \(jobDescription)")
print(Thread.current)
job.runSynchronously(on: asUnownedTaskExecutor())
print("\(#function) after job.runSynchronously \(jobDescription)")
print(Thread.current))
}
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
Далее наш executor нужно как-то использовать для выполнения Job
в Task
. До этого мы создавали Task
через дефолтный инициализатор ( Task {}
), который использовал по дефолту Global concurrent executor. Чтобы назначить наш Executor
, воспользуемся иным инициализатором:
print("Before task \(Thread.current)")
// 1
Task.detached(executorPreference: CustomExecutor()) {
print("Task work item")
}
print("After task \(Thread.current)")
Используем
detached
, чтобы не зависеть от контекста. У обычного инициализатора есть аналогичный параметр (executorPreference). Не забываем, что эти инициализаторы тоже@available(iOS 18.0)
, как и реализацияTaskExecutor
.
Запускаем наш код и видим:
Before task <_NSMainThread: 0x60000241cac0>{number = 1, name = main}
enqueue(_:) before job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x60000241cac0>{number = 1, name = main}
Task work item
enqueue(_:) after job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x60000241cac0>{number = 1, name = main}
After task <_NSMainThread: 0x60000241cac0>{number = 1, name = main}
Проанализируем логи построчно:
Я запускал данный код в main потоке, соответственно и увидел что перед созданием Task я нахожусь на main.
После создания Task мы сразу получаем лог в консоль из нашего executor о том, что он получил в работу его первую Job.
Мы находимся в том же потоке, в котором и создали таску. Первый вызов на enqueue джобы происходит синхронно.
Тут наша джоба выполняется (вызывается наш принт внутри Task).
Видим принт о том что джоба выполнена.
Поток остается прежним.
Видим лог, выставленный после инициализации Task.
У нас в примере создалась всего одна джоба, так как внутри нет suspension points (вызовов await). Давайте же это исправим и посмотрим на обновленные логи:
print("Before task \(Thread.current)")
Task.detached(executorPreference: CustomExecutor()) {
print("?")
try await Task.sleep(for: .seconds(1))
print("?")
}
print("After task \(Thread.current)")
// 1
Before task <_NSMainThread: 0x6000037fc000>{number = 1, name = main}
// 2
enqueue(_:) before job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x6000037fc000>{number = 1, name = main}
?
enqueue(_:) after job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x6000037fc000>{number = 1, name = main}
// 3
After task <_NSMainThread: 0x6000037fc000>{number = 1, name = main}
// 4
enqueue(_:) before job.runSynchronously ExecutorJob(id: 1)
<NSThread: 0x6000037fdf00>{number = 5, name = (null)}
?
enqueue(_:) after job.runSynchronously ExecutorJob(id: 1)
<NSThread: 0x6000037fdf00>{number = 5, name = (null)}
Лог перед созданием Task не поменялся. Мы все еще создаем из Main.
Во время создания Task в executor залетает первая джоба на выполнение принта с зеленым квадратом.
Далее видим лог, выставленный после инициализации Task. Сама же Task еще не завершилась, она бросила поток, тк в данный момент она на паузе (мы дошли до вызова sleep).
В executor залетает еще одна джоба, которая создается уже после
try await sleep
(suspension point). Данная джоба выполняет принт красного квадрата. Обратите внимание что поток поменялся, мы уже не на Main.
На этом примере можно увидеть то самое разделение Task
на джобы между suspension points (await). И то, что поток между ними может отличаться. Но важно понимать, что наличие await - не признак создания дополнительной джобы, как и не признак того, что сейчас произойдет фактическая приостановка выполнения.
Например:
nonisolated func asyncWorkItem() async {
print(#function)
}
print("Before task \(Thread.current)")
Task.detached(executorPreference: CustomExecutor()) {
print("?")
// Поменяли вызов Task.sleep на вызов нашей асинхронной функции
await asyncWorkItem()
print("?")
}
print("After task \(Thread.current)")
В результате выполнения этого кода увидим:
Before task <_NSMainThread: 0x600001e84a40>{number = 1, name = main}
enqueue(_:) before job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x600001e84a40>{number = 1, name = main}
?
asyncWorkItem()
?
enqueue(_:) after job.runSynchronously ExecutorJob(id: 1)
<_NSMainThread: 0x600001e84a40>{number = 1, name = main}
After task <_NSMainThread: 0x600001e84a40>{number = 1, name = main}
Лог зеленого квадрата, асинхронной функции и красного квадрата выполнился в рамках одной джобы, не смотря на явный await посреди таски. Произошло это из-за того, что наша асинхронная функция по факту не имеет прерываний, из-за чего всю работу проще и правильнее выполнить за один прогон.
Executor на определенной очереди
Executor
, реализованный нами выше, фактически не имеет никакого смысла: он буквально не делает ничего (кроме вывода полезных для понимания его работы логов). Он принимает Job
и выполняет ее в этом же потоке, блокируя его. Это можно увидеть на простом примере:
print("Before task")
Task.detached {
print("Task work item")
}
print("After task")
Скрою ответ под спойлером, чтобы вы могли проверить себя. (Простой ведь пример?)
Вывод в консоли
Before task
After task
Task work item
Тут мы не указали наш executor, поэтому Task
выполнилась на Global concurrent executor, который планирует джобу на определенный поток (не выполняет ее сразу здесь и сейчас). Из-за этого мы и видим, что тело таски выполняется в последнюю очередь.
Однако если мы присвоим Task
наш CustomExecutor
:
print("Before task")
Task.detached(executorPreference: CustomExecutor()) {
print("Task work item")
}
print("After task")
Внимательные читатели уже заметили данный нюанс выше, когда смотрели логи. Для остальных - снова шанс подумать:
Вывод в консоли
Before task
Task work item
After task
(Почистил вывод от логов экзекьютора)
Наш наивный executor выполняет Job
сразу, как она поступает в него (а она поступает сразу после инициализации Task
с этого же потока). Поэтому и вывод тела Task
у нас синхронный.
Данная реализация Executor
'а не имеет никакого предназначения (кроме обучающего), поэтому давайте накидаем более приближенный к реальности пример:
@available(iOS 18.0, *)
final class QueueExecutor: TaskExecutor {
private let queue: DispatchQueue
init(queue: DispatchQueue) {
self.queue = queue
}
func enqueue(_ job: UnownedJob) {
// Выполняем каждую job на очереди из инициализатора
queue.async {
print("job.runSynchronously on \(Thread.current)")
job.runSynchronously(on: self.asUnownedTaskExecutor())
}
}
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
Теперь каждый раз при попадании новой Job
в наш executor будем перенаправлять ее на предопределенную очередь. Воспользоваться можно, к примеру, вот так:
print("Before task \(Thread.current)")
Task.detached(executorPreference: QueueExecutor(queue: .main)) {
print("?")
try await Task.sleep(for: .seconds(1))
print("?")
}
print("After task \(Thread.current)")
И в логах увидим, что обе Job
выполнились на main thread - получился некий аналог @MainActor
'а.
Before task <_NSMainThread: 0x600001708040>{number = 1, name = main}
After task <_NSMainThread: 0x600001708040>{number = 1, name = main}
job.runSynchronously on <_NSMainThread: 0x600001708040>{number = 1, name = main}
?
job.runSynchronously on <_NSMainThread: 0x600001708040>{number = 1, name = main}
?
И еще, в отличии от предыдущего Executor
'а, лог "after task" вызывается после непосредственного выполнения Job
, как и в случае глобального Executor
(при инициализации Task {}
).

Вместо main можем передать в него глобальную очередь - в таком случае поведение будет похоже на global concurrent executor:
Task(executorPreference: QueueExecutor(queue: .global())) {
print("?")
try await Task.sleep(for: .seconds(1))
print("?")
}
Вывод:
job.runSynchronously on <NSThread: 0x6000017083c0>{number = 7, name = (null)}
?
job.runSynchronously on <NSThread: 0x600001720080>{number = 9, name = (null)}
?
По результату вывода поток снова меняется, так как Job
'ы планируются уже на глобальной очереди (отдельно каждая). И итоговый поток может отличатся от джобы к джобе. Именно поэтому и в дефолтном Executor
'e поток между await
может поменяться (планирование там значительно сложнее нашего примера, но причина смены потока схожа).

Actor и его executor
Мы еще так и не познакомились с Actor
'ом на практике за данную серию статей, но это не помешает ознакомиться с его концепцией (и почему его джобы выполняются на отдельном Executor
'е). Actor
на практике мы пощупаем в следующей статье, но и тут коснемся его, чтобы охватить полностью все вариации Executor
'ов.
Собственно, изначально пару слов об Actor
для тех, кто совсем не знает, что это такое:
Actor
- это reference тип (под капотом просто класс с некоторыми ограничениями), который защищает свои изменяемые данные от Data Race (от записи одной области памяти из разных потоков). Другими словами - потокобезопасный объект. Безопасность внутреннего состояния у Actor происходит за счет того, что все вызовы его функций происходят последовательно.
Посмотрим на примере:
actor SafeArray<T> {
private var array: [T] = []
func append(_ element: T) {
array.append(element)
}
func removeLast() -> T {
array.removeLast()
}
}
В данном примере мы обернули классический массив в наш Actor
, что добавило ему потокобезопасность (по дефолту этого нет). Теперь попробуем воспользоваться им:
let array = SafeArray<Int>()
array.append(1) // error: Call to actor-isolated instance method 'append' in a synchronous nonisolated context
Воспользоваться так просто не получилось. Потокобезопасность Actor
'а накладывает некоторые ограничения на использования объекта. На одно из них мы и натолкнулись. Изолированный метод вне самого Actor
становится async
, из-за чего нам требуется асинхронный контекст для его вызова:
let array = SafeArray<Int>()
Task.detached {
await array.append(1)
}
Внутренние методы требуют вызова await
как раз из-за того, что Actor
гарантирует последовательное их выполнение. Без await
метод выполнился бы здесь (в потоке вызова) и сейчас (в момент вызова). В таком случае у нас не было бы гарантии, что работа с объектом безопасна, так как в этот же момент времени он мог бы меняться из другого места и потока. Поэтому ключевое слово await
нам подсказывает, что метод может выполниться позже, и возможно потребуется бросить текущий поток и передать его для выполнения других задач из очереди (тк вызов await не блокирующий).
Давайте убедимся в последовательности операций. Для этого немного подтюним пример:
actor SafeArray<T> {
private var array: [T] = []
func append(_ element: T) {
// 1
print("Will append \(element)")
// 2
Thread.sleep(forTimeInterval: 0.1)
array.append(element)
// 1
print("Did append \(element)")
}
}
Добавил логи о старте и окончании операции .
Добавил симуляцию долгого выполнения (с помощью блокирующего поток
Thread.sleep
).
let array = SafeArray<Int>()
Task {
// С помощью TaskGroup параллелим работу с array
await withTaskGroup { group in
for i in 0..<10 {
group.addTask { await array.append(i) }
}
await group.waitForAll()
}
}
И в консоли увидим:
Will append 0
Did append 0
Will append 1
Did append 1
Will append 2
Did append 2
Will append 3
Did append 3
Will append 4
Did append 4
Will append 5
Did append 5
Will append 6
Did append 6
Will append 8
Did append 8
Will append 9
Did append 9
Will append 7
Did append 7
По данному логу можно удостовериться, что все операции вызываются последовательно. Об этом говорит тот факт что нет более одного will или did подряд. Следующая операция начинается строго после завершения предыдущей. Порядок от 0 до 9 не соблюден, но это ожидаемо, так как запуск child task у группы происходит без гарантии соблюдения порядка добавления.
За счет чего достигается последовательное выполнение? Вспоминаем схему из начала статьи. За планирование и выполнение Job
отвечают Executor
'ы. И вот как раз специфичный Actor Executor (Serial Executor) и реализует всю подкапотную магию последовательного выполнения. Job
'ы у Actor
'а по дефолту как раз и попадают на Serial Executor (вместо Global concurrent executor).

Дефолтный Serial Executor также использует для выполнения потоки из кооперативного пула, но с сохранением последовательного выполнения.
Мы, как и в случае с обычным Executor
'ом у Task
, можем назначить свою имплементацию для Executor
Actor
'а. Для этого нам нужно реализовать протокол SerialExecutor
. Начнем снова с наивной реализации:
@available(iOS 18.0, *)
final class CustomSerialExecutor: SerialExecutor {
func enqueue(_ job: consuming ExecutorJob) {
job.runSynchronously(on: asUnownedSerialExecutor())
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
Реализация аналогична той, что мы делали для TaskExecutor
. Также доступна только с iOS 18.
Для того чтобы наш Actor
использовал этот Executor
нужно сделать следующие изменения:
// 1
@available(iOS 18.0, *)
actor SafeArray<T> {
// 2
private let executor = CustomSerialExecutor()
// 3
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
private var array: [T] = []
func append(_ element: T) {
print("Will append \(element)")
Thread.sleep(forTimeInterval: 0.1)
array.append(element)
print("Did append \(element)")
}
}
Тоже помечаем весь actor доступным только с 18 iOS - на более ранних версиях нельзя назначить кастомные
Executor
.Собственно, наш Executor. Это необязательное условие: executor можно прокидывать извне либо сделать его синглтоном. Но учитывайте, что в случае синглтона один executor будет использоваться сразу несколькими actor'ами - это нужно учитывать в реализации.
Тут мы определяем значение протокола Actor. Это уже обязательное действие, если вы хотите использовать свой Executor.
Ну и, собственно, весь код. Давайте теперь запустим его на том же примере и посмотрим логи:
Will append 4
Will append 9
Will append 0
Will append 3
Will append 2
Will append 8
Will append 7
Will append 1
Will append 5
Will append 6
Did append 0
Did append 4
Did append 6
Что-то тут не так. Операции выполняются параллельно. А на четвертой операции добавления код вообще выбросил EXC_BAD_ACCESS.

Много думать, из-за чего это произошло, не нужно. Наш executor не имеет абсолютно никакой логики по синхронизации. Он выполняет джобы сразу же, как они в него приходят (и что самое важное — на том же потоке). А в нашем примере они летят из разных потоков благодаря TaskGroup
(можете убедиться сами, просто добавив лог в executor). Вот мы и ловим гонку данных и, соответственно, крэш. Получается, что Actor
без нормального executor'а - это уже совсем не Actor
. С нашим «никаким» executor'ом просто теряет весь свой смысл.
Давайте поправим эту проблему с помощью старой доброй serial очереди:
@available(iOS 18.0, *)
final class CustomSerialExecutor: SerialExecutor {
private let queue = DispatchQueue(label: "com.example.serialExecutorQueue")
func enqueue(_ job: UnownedJob) {
queue.async {
job.runSynchronously(on: self.asUnownedSerialExecutor())
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
После запуска примера уже с такой реализацией executor'а наши логи снова примут ожидаемый вид, а крэши исчезнут.
Пример чисто академический. Не используйте данную реализацию в боевых проектах. Она менее оптимальна, чем дефолтный serial executor у actor'ов. Дефолтный не создает новые потоки, так как того что использует потоки из пула, выстраивая последовательное выполнение на них.
Работа нашего CustomSerialExecutor на диаграмме:

Как определяется executor
Резюмируем наш разговор про Executor
'ы их определением в коде. У нас есть асинхронная функция. Как определить, на каком executor'е она будет выполнена? Начнем с простого и будем накручивать наш пример:
@available(iOS 18.0, *)
func asyncWorkItem(id: Int) async {
// 1
withUnsafeCurrentTask { task in
guard let task else { return }
// 2
print(id, task.unownedTaskExecutor ?? globalConcurrentExecutor)
}
}
// 3
Task {
await asyncWorkItem(id: 1)
}
С помощью глобальной функции
withUnsafeCurrentTask
можно получить ссылку на текущуюTask
, в рамках которой выполняется функция. Эту функцию можно вызвать и в синхронной (обычной) функции, вернет онаTask
только в случае если функция выполняется в рамках асинхронного контекста.Принтим текущий
unownedTaskExecutor
. Он будет неnil
только в случае, если мы присвоим наш executor для выполнения. В обратном случае можно считать, что текущий executor равенglobalConcurrentExecutor
.Пример в изначальном виде.
При запуске данного кода увидим в консоли:
Swift._DefaultGlobalConcurrentExecutor
Мы не присваивали свой Executor
, поэтому Task
выполняется на глобальном. Давайте теперь присвоим наш Executor:
Task {
await asyncWorkItem(id: 1)
// 1
await withTaskExecutorPreference(CustomExecutor()) {
await asyncWorkItem(id: 2)
// 2
async let workItem: Void = asyncWorkItem(id: 3)
await workItem
}
await asyncWorkItem(id: 4)
}
Executor можно менять "на ходу" внутри
Task
с помощью этой функции. Это полезно, если не требуется присваивать Executor для всей таски.Child Task'и тоже наследуют Executor (c TaskGroup будет аналогично).
Вывод:
Swift._DefaultGlobalConcurrentExecutor
UnownedTaskExecutor(executor: (Opaque Value))
UnownedTaskExecutor(executor: (Opaque Value))
Swift._DefaultGlobalConcurrentExecutor
Только второй id выполнился в рамках нашего Executor'а - тут в целом все логично. А что делать, если мы, наоборот, хотим сбросить кастомный Executor "на ходу"? Делаем так:
// 1
Task(executorPreference: CustomExecutor()) {
await asyncWorkItem(id: 1)
// 2
await withTaskExecutorPreference(globalConcurrentExecutor) {
await asyncWorkItem(id: 2)
async let workItem: Void = asyncWorkItem(id: 3)
await workItem
}
await asyncWorkItem(id: 4)
}
Присваиваем наш Executor для выполнения всей Task.
Для сброса просто переприсваиваем
globalConcurrentExecutor
с помощью той же функцииwithTaskExecutorPreference
.
Вывод:
UnownedTaskExecutor(executor: (Opaque Value))
Swift._DefaultGlobalConcurrentExecutor
Swift._DefaultGlobalConcurrentExecutor
UnownedTaskExecutor(executor: (Opaque Value))
Пока не добавились Actor
'ы - все достаточно просто. Присвоен taskExecutor - он будет планировать Job
'ы. Не присвоен - будет планировать globalConcurrentExecutor
. Теперь добавляем Actor'ы:
// 1
@available(iOS 18.0, *)
actor MyActor {
func asyncWorkItem(id: Int) async {
withUnsafeCurrentTask { task in
guard let task else { return }
print(id, task.unownedTaskExecutor ?? globalConcurrentExecutor)
}
}
}
// 2
@available(iOS 18.0, *)
actor MyActorWithCustomExecutor {
private let executor = CustomSerialExecutor()
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
func asyncWorkItem(id: Int) async {
withUnsafeCurrentTask { task in
guard let task else { return }
print(id, task.unownedTaskExecutor ?? globalConcurrentExecutor)
}
}
}
Task {
await asyncWorkItem(id: 1)
await withTaskExecutorPreference(CustomExecutor()) {
await asyncWorkItem(id: 2)
async let workItem: Void = asyncWorkItem(id: 3)
await workItem
// 3
await MyActor().asyncWorkItem(id: 4)
await MyActorWithCustomExecutor().asyncWorkItem(id: 5)
}
await asyncWorkItem(id: 6)
// 4
await MyActor().asyncWorkItem(id: 7)
await MyActorWithCustomExecutor().asyncWorkItem(id: 8)
}
Изолируем нашу функцию внутри actor'а.
Аналогичное действие, только с присвоением executor'а для actor'а.
Вызов двух экземпляров внутри блока с CustomExecutor preference.
Вызов двух экземпляров вне блока с CustomExecutor preference.
Давайте смотреть логи. В этот раз нас интересуют id: 4, 5, 7, 8
Swift._DefaultGlobalConcurrentExecutor
UnownedTaskExecutor(executor: (Opaque Value))
UnownedTaskExecutor(executor: (Opaque Value))
UnownedTaskExecutor(executor: (Opaque Value))
UnownedTaskExecutor(executor: (Opaque Value))
Swift._DefaultGlobalConcurrentExecutor
Swift._DefaultGlobalConcurrentExecutor
Swift._DefaultGlobalConcurrentExecutor
В целом логично: в 4 и 5 мы видим переданный executor, в 7 и 8 не видим, но тут есть нюансы. Как вы помните, у executor'ов actor'ов иной протокол (SerialExecutor
), мы же в свою очередь пробрасываем TaskExecutor
. Как это работает?
Механизм следующий:
Если у actor'а присвоен внутренний
SerialExecutor
, тоTaskExecutor
полностью игнорируется. В нашем примере это лог с id 5. У него вывелся UnownedTaskExecutor из-за того, что мы выполняемся все еще в рамкахTask
(и принтим executor из нее). По факту для этого случая он никак не используется. С id 8, думаю, тоже вопросов нет (так как там и конфликтов между Executor'ами нет)В случае если мы не задали для
Actor
ниTaskExecutor
, ниSerialExecutor
— выполнение будет на дефолтномSerialExecutor
. В нашем случае это id 7. Опять же: DefaultGlobalConcurrentExecutor в логах - этоTaskExecutor
, планирование для actor осуществляет не он.Самый неоднозначный кейс — id 4. У Actor из этого примера нет своего SerialExecutor, но прокинут TaskExecutor. В данном случае поведение весьма специфично: дефолтный
SerialExecutor
гарантирует последовательное выполнение, аTaskExecutor
определяет поток выполнения. В итоге два executor'а работают в паре и каждый отвечает за свою зону ответственности. То есть передачаTaskExecutor
(который не поддерживает последовательное выполнение) не сломает потокобезопасность выполнения для нашего actor (можете проверить это на нашем SafeArray).
Итого определять executor для любого случая можно по следующей диаграмме:

Итоги
В данной части мы заглянули чуть глубже в механизм работы swift concurrency, узнали (надеюсь) много новых объектов. Думаю теперь Executor раскрылся из состояния черного ящика во что-то более осязаемое. Чем больше мы знаем о реализации, тем больше мы сможем контролировать и прогнозировать использование инструментов (а прогнозирование очень важно, особенно когда мы работаем в многопоточной среде).
Bardakan
Нужно более подробную статью, а то возникает куча вопросов. Почему например здесь task не содержит executor, actor содержит custom executor, а используется все равно default executor
kymacat Автор
В случае с id 8 task не содержит executor, так как в него явно он и не передавался.
task.unownedTaskExecutor
равен не nil только в случае если он явно присвоен (черезwithTaskExecutorPreference
либо черезTask(executorPreference:)
). В этом случае он nil.Actor да, содержит executor, он и будет использоваться (не default executor). Ты вероятно подумал про default executor из-за лога, но в данном случае лог отвечает только за вывод текущего TaskExecutor. Но он не в любом случае будет использоваться
Когда он будет использоваться, а когда не будет, как раз обрисовал в диаграмме ниже по статье