Перейти к содержанию

1. Actor и Executor

Стоит сказать, что Actor, как и Task не выполняют никаких задач. Это просто обёртки для удобной работы в коде. На самом деле за выполнение задач отвечают ActorExecutor и TaskExecutor. Данных исполнителей мы можем подменять и писать свою реализацию. Но прежде чем что-то подменять, давайте посмотрим как вообще определяется исполнитель задачи?

Как выбирается исполнитель?

actorExecutor.png

Данная схема расположена в исходниках swift. Давайте подробно разберём, что происходит на этой схеме и как она работает

Изоляция

Да, первым этапом очень важно проверить изоляцию. Наличие изоляции означает, что мы находимся в Actor, иначе исполняется простая задача. Проверка изолции осуществляется с помощью простого макроса

#isolation -> (any Actor)?
Данный макрос возвращает nil, если изоляции нет, либо тип Actor, который изолирует этот метод.

Изоляция сущуствует

Если изоляция существует, то далее мы проверяем установлен ли кастомный исполнитель для ACTOR. Первым делом мы смотрим именно на исполнитель Actor, если он установлен, то используем его.

Иначе, мы смотрим на исполняемую задачу и определяем есть ли у этой задачи переопределённый исполнитель, если есть то используем его, иначе используем дефолтный исполнитель (который определён для Actor)

Изоляции нет

В этом случае достаточно проверить переопределён ли исполнитель у задачи и если да, то использовать его, иначе использовать глобальный исполнитель.

Итог

После прочтения информации выше, необходимо осознать следующие вещи: 1. Есть исполнители, предназначенные для задач 2. Есть исполнители, предназначенные для Actor 3. По умолчанию Actor исполняет всё на глоабльном исполнителе. Он оптимизирован под капотом (избегает лишних switch context между потоками) 4. Мы можем писать своих исполнителей и они будут использоваться вместо дефолтных Но тогда возникает логичный вопрос, а для чего нам вообще требуется написание своих исполнителей, ведь итак всё работает

Кастомные исполнители

В начальных статьях, я говорил о том, что в парадигме Structured Concurrency выделяется столько потоков, сколько ядер в системе. Но что произойдёт если один или несколько потоков будут заняты тяжелой работой. Тогда резко увеличится нагрузка на оставшиеся потоки, произойдёт деградация всей системы и мы вместо выигрыша в производительности получим её снижение.

Как раньше уже писал, что в таких задачах необходимо дёргать Task.yeild, чтобы прерывать работу большой и тяжелой функции и пропускать вперёд другие задачи. Но что если потоки вообще будут заблокированы, как быть тогда?

Вот именно в этот момент стоит задуматься о вынесении таких сложных задач на исполнитель со своей логикой. Также вы можете создать свой исполнитель и например передать в несколько Actor. И подменить в каждом Actor исполнителя, тогда такие экторы будут связаны одним исполнителем

ПОЖАЛУЙСТА, не используйте кастомные исполнители без нужды и явного понимания их работы.

Приведём примеры кастомных исполнителей:

Пример 1. Мы можем научить Actor работать с задачами последовательно. Как серийная очередь

private final class ActorQueueExecutor: SerialExecutor {

    private let queue = DispatchQueue(label: "my_qeueue")

    func enqueue(_ job: consuming ExecutorJob) {
        let unownedJob = UnownedJob(job)
        let unownedExecutor = asUnownedSerialExecutor()

        queue.async {
            unownedJob.runSynchronously(on: unownedExecutor)
        }
    }
   
    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }
}

/// Использование
actor EventStorage {

    private let executor = ActorQueueExecutor()

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        self.executor.asUnownedSerialExecutor()
    }
}

Написание своего исполнителя

У каждого actor мы можем переопределить unownedExecutor и выставить свой. Но вот реализацию своего исполнителя уже необходимо продумывать и реализовывать аккуратно.

Рассмотрим подробнее реализацию ActorQueueExecutor: Для того, чтобы написать свою реализацию -- нам необходимо сделать наследование от протокола SerialExecutor. Далее вам нужно будет реализовать 2 функции:

private final class ActorQueueExecutor: SerialExecutor {

    func enqueue(_ job: consuming ExecutorJob) { ... }
   
    func asUnownedSerialExecutor() -> UnownedSerialExecutor { ... }
}
Здесь возникает вопрос, а зачем и для чего мы перегоняем

  • SerialExecutor -> UnownedSerialExecutor

  • ExecutorJob -> UnownedJob

На самом деле это колоссально важный момент оптимизации:

UnownedSerialExecutor - это оптимизированный тип. По сути это unowned ссылка, позволяющая избежать ненужного подсчета ссылок. Чтобы обеспечить эту оптимизацию - есть ещё также ряд ограничений: - Если Actor находится в живом состоянии, то и его исполнитель также должен быть жив - Если это разные объекты, то исполнитель должен держать сильной ссылкой Actor

Аналогичная ситуация происходит с UnownedJob, то есть данные обёртки необходимы, чтобы оптимизировать подсчёт ссылок и не нагружать инстанс ARC.

Далее мы должны у unownedJob вызвать метод runSynchronously, если этот метод не будет вызван, то возникнут проблемы с памятью, начнутся утечки.

Вызвав метод runSynchronously, произойдёт исполнение задачи на заданном экзекьюторе. То есть в момент вызова этой функции джоба запустится на вызывающем потоке и заблокирует его до тех пор, пока не закончится выполнение джобы.

Из документации:

Использование этого метода предполагает, что разработчик сам определит где, как и когда он будет использоваться. То есть мы определяем условия исполнения, а затем, вызывая метод runSynchronously уже исполняем работу на заданных условиях

Таким образом мы можем написать исполнитель и для задач.

Пример 2. Напишем пример исполнителя задач, чтобы все задачи запускаемые на таком исполнителе проходили на главном потоке.

final class MainQueueTaskExecutor: TaskExecutor {

    func enqueue(_ job: consuming ExecutorJob) {
        let unownedJob = UnownedJob(job)
        self.enqueue(unownedJob)
    }
   
    func enqueue(_ job: UnownedJob) {
        let unownedExecutor = asUnownedTaskExecutor()
        DispatchQueue.main.async {
            job.runSynchronously(on: unownedExecutor)
        }
    }

    func asUnownedTaskExecutor() -> UnownedTaskExecutor {
        UnownedTaskExecutor(ordinary: self)
    }
}

// Применеие
let executor = MainQueueTaskExecutor()
Task.detached(executorPreference: executor) {
    print(2)
}

Почти полная аналогия с ActorQueueExecutor, но в данном случае мы наследуемся от TaskExecutor, методы которого немного отличаются, по сути один метод, который мы переопределяли в примере 1, разбили на 2 части. В остальном всё осталось как и раньше.

В конце хочется сказать, что существует больше исполнителей, чем мы рассмотрели: - Global Executor - Global Concurrent Executor - Task + TaskExecutor - Task + TaskGroupExecutor - Cooperative Global Executor

Всё это является различными оптимизациями для разных ситуаций. Каждый из данных исполнителей мы детально разберём.

А пока важно понять следующие вещи из этой статьи:

Мы можем переопределять и писать своих исполнителей как для задач, так и для Actor. Получается, что в этом плане исполнители являются гибкими и настраиваемыми. Таких возможностей не было даже в GCD. После прочтения текущей статьи, важно осознать возможность подменить исполнителя.

Для лучшей практики попробуйте самостоятельно сделать следующие задачи:

  1. Напишите свой исполнитель, который будет являться аналогом MainActor

  2. Напишите исполнитель для глобального Actor, который будет исполнять последовательно задачи, одна за другой, попробуйте написать тесты