Pull to refresh
1307.45
OTUS
Цифровые навыки от ведущих экспертов

Акторы Swift под капотом

Reading time10 min
Views18K
Original author: swiftrocks.com

Акторы (Actors) — это фича, являющаяся частью структурированного параллелизма (Structured Concurrency) Swift, которая предлагает совершенно новый формат для написания и обработки асинхронного кода. Хотя они и являются чем-то инновационным для языка Swift, сама технология новой не является. Многие языки успели обзавестись поддержкой акторов и async/await раньше, чем Swift, но что интересно, так то, что везде они реализованы одинаково. Только-только получив этот функционал в Swift, мы уже можем многому научиться на опыте разработчиков, использовавших их в других языках.

Как и в других статьях SwiftRocks «X Swift под капотом», цель этой статьи — изучить, как акторы работают под капотом, полагаясь на исходный код Swift в качестве руководства для выяснения того, как они работают внутри компилятора.

Что такое актор?

Актор (actor) предназначен для предотвращения состояний гонки (race conditions) в состояниях асинхронных классов. Хотя это не новая концепция, акторы являются частью гораздо более крупного замысла. Да, теоретически вы можете реализовать все, что делает актор, просто добавив NSLocks в свойства/методы ваших классов, но на практике у них есть несколько важных бонусов. Во-первых, механизм синхронизации, используемый акторами, — это не известные нам блокировки, а новая Cooperative Threading Model (модель кооперативной потоковой обработки ) async/await в которой потоки могут плавно «изменять» контексты для выполнения других фрагментов кода, чтобы избежать простаивающих потоков, а во-вторых, наличие акторов позволяет компилятору проверить многие проблемы параллелизма прямо во время компиляции, давая вам сразу знать если есть какая-либо потенциальная опасность:

actor MyActor {
    var myProp = 0
}
MyActor().myProp

// error: actor-isolated property 'myProp' can only be referenced from inside the actor
// ошибка: на изолированное актором свойство myProp можно ссылаться только изнутри актора

В данном случае одной из причин, по которой к myProp можно получить доступ только изнутри актора, является то, что вы можете использовать акторы только из async-контекста — синхронизация акторов подразумевает, что нет гарантии, что ваш код будет выполняться синхронно. По этой причине каждый метод актора неявно является async, если не указано иное (как показано ниже).

Task {
    await actor.getMyProp()
}

Как работают акторы?

Один важный момент, на который я бы хотел обратить ваше внимание, заключается в том, что большинство языковых фич Swift реализованы на реальном коде Swift, что частично относится и к акторам. По сути акторы являются синтаксическим сахаром для классов, наследующих протокол Actor:

public protocol Actor: AnyObject, Sendable {
  nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

Это автоматически генерируется при компиляции класса актора, и, как и с некоторыми другими фичами Swift, вы можете реализовать его самостоятельно:

final class MyCustomActor: Actor {}

Однако на практике это не будет обычным актором. Хотя этот код работает, компилятор попросит вас вручную реализовать требование unownedExecutor и его механизмы синхронизации. Честно говоря, меня это немного удивило, потому что некоторые другие функции, такие как автоматический синтез Codable, способны автоматически заполнять пробелы, когда вы предоставляете часть реализации, как в этом примере. Как ни крути, в текущем Swift 5.5 вы можете получить полную реализацию актора только с помощью ключевого слова actor.

В протоколе указано, что все акторы также должны быть Sendable, что является еще одной важной новой частью улучшения параллелизма. За этим протоколом нет реального кода, его цель — «пометить», какие типы безопасны для использования в параллельной среде. Несмотря на то, что сами по себе акторы «безопасны», у вас все равно могут быть проблемы с состоянием гонки, если вы, например, использовали ссылочные типы в качестве своего состояния, которые протекли за пределы актора. Чтобы избежать этого, Swift использует Sendable, чтобы указать, какие типы являются потокобезопасными по своей конструкции, что является основой ошибок времени компиляции, о которых мы говорили ранее. Только иммутабельное содержимое, такое как структуры и final классы, может наследоваться от Sendable, при этом в качестве обходного пути нам доступен UnsafeSendable, который пропускает весь статический анализ во время компиляции. Однако, насколько я могу судить, похоже, что реализация Sendable еще не завершена, поскольку в WWDC 2021 было заявлено, что намерение в будущем состоит в том, чтобы не дать акторам возможность утечки не-Sendable типов.

Экзекьюторы

Однако наиболее важным аспектом протокола Actor является его обязательное свойство: неизолированный unownedExecutor. Протокол Executor был добавлен в Swift 5.5 для определения объекта, который может выполнять «job» (задачу), которой в случае акторов являются сами методы:

/// Служба, которая может выполнять задачи.
@available(SwiftStdlib 5.5, *)
public protocol Executor: AnyObject, Sendable {
  func enqueue( job: UnownedJob)
}

Таким же образом SerialExecutor определяет объект, который выполняет задачи последовательно:

/// Служба, которая может выполнять задачи.
@available(SwiftStdlib 5.5, *)
public protocol SerialExecutor: Executor {
  /// Преобразуем это значение экзекьютора в оптимизированную форму, заимствованную
  /// из ссылки на экзекьютор.
  func asUnownedSerialExecutor() -> UnownedSerialExecutor
}

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

let myExecutor = MySerialExecutor()
let unownedSerialExecutor = UnownedSerialExecutor(ordinary: myExecutor)

Вы могли заметить, что это свойство содержит новое ключевое слово: nonisolated, которое позволяет вам «переопределить» проверки акторов во время компиляции Swift. Как мы уже упоминали о Sendable, Swift постарается убедиться, что ваши акторы максимально безопасны, но могут быть случаи, когда это будет ошибкой. Если что-то действительно предназначено для параллельного доступа по запросу, вы можете использовать ключевое слово nonisolated, чтобы избавить его от проверок Swift. В этом случае экзекьютор должен быть отключен от средств защиты, чтобы у нас была фактическая точка входа в актор.

Swift автоматически генерирует экзекьюторы для ваших акторов, но прежде чем увидеть, как это работает, мы должны пронаблюдать экзекьюторы в действии в рамках другой новой фичи: глобальные акторы (Global Actors).

Экзекьюторы в глобальных акторах

Глобальные акторы существуют из-за того факта, что синхронизация состояния не ограничивается локальными переменными, а это означает, что вам может потребоваться глобальный доступ к актору. Вместо того, чтобы заставлять всех писать повсюду синглтоны, фича Global Actors в Swift 5.5 позволяет вам легко указать, что определенный фрагмент кода должен выполняться в рамках определенного глобального актора. Возможно, наиболее важным примером этого является MainActor — глобальный актор, который обеспечивает выполнение всего кода в основном (main) потоке.

@MainActor doSomethingInMain() {
    something()// Всегда будет выполняться в main
}

Можно создать свои собственные глобальные акторы, добавив к актору атрибут @globalActor. По умолчанию Swift будет рассматривать ваш актор как обычный и сгенерирует для вас дефолтный экзекьютор, но, поскольку это требование протокола, вы фактически переопределяете его и создаете свой собственный механизм синхронизации! Именно так и работает MainActor — async/await-потоки основаны не на DispatchQueues, а на новой Cooperative Threading Model, фичи времени выполнения упомянутой в начале этой статьи. Мы рассмотрим это в отдельной статье об async/await, но говоря простым языком, основной (main) поток не является частью этой новой модели, поэтому акторы не могут выполнять код в основном потоке по умолчанию. На практике MainActor достигает этого путем определения кастомного SerialExecutor, который в исходном коде Swift и является самим MainActor.

/// Актор-синглтон, экзекьютор которого эквивалентен main
/// dispatch queue.
@available(SwiftStdlib 5.5, *)
@globalActor public final actor MainActor: SerialExecutor {
  public static let shared = MainActor()

  @inlinable
  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    return asUnownedSerialExecutor()
  }

  @inlinable
  public nonisolated func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    return UnownedSerialExecutor(ordinary: self)
  }

  @inlinable
  public nonisolated func enqueue( job: UnownedJob) {
    enqueueOnMain(job)
  }
}

Когда вы вызываете метод актора, Swift заменяет ваш код вызовом метода enqueue ( :) экзекьютора этого актора. Вот почему акторы могут использоваться только в async-контекстах — вы, по сути, делаете эквивалент DispatchQueue.async! Вот так вызов MainActor выглядит практике:

Task {
    await myMainActorMethod()
}
Однако Swift обрабатывает это как нечто похожее на это:
Task {
    MainActor.shared.unownedExecutor.enqueue {
        myMainActorMethod()
    }
}

Что, в случае MainActor, по сути, является вызовом DispatchQueue.main.

DispatchQueue.main.async {
    myMainActorMethod()
}

Я должен предупредить вас, что это не совсем точно, потому что функциональность акторов (и в особенности экзекьюторов) на практике тесно связана с async/await и их новой Task моделью, но я пока проигнорирую это и исследую подробности этого в отдельной статье о том, как работает async/await.

Короче говоря, хоть Swift генерирует для вас всю реализацию актора, вы все равно можете переопределить его поведение. Лично я не вижу ни одной причины, по которой кому-то может понадобиться это сделать, помимо случая с основным потоком, который Swift уже предоставляет вам, поэтому вам, вероятно, не следует пытаться сделать это самостоятельно.

Дефолтный актор в Swift

Теперь, когда мы понимаем, как используются экзекьюторы, мы готовы исследовать, как на самом деле работают акторы. Поведение актора Swift основано на так называемом дефолтном акторе (default actor), который можно представить как базовый класс, который обрабатывает все потребности актора в синхронизации. Определив пустой актор, вот как он будет выглядеть после того код скомпилируется:

actor MyActor {}

// Скомпилировано:

final class MyActor: Actor {
    var unownedExecutor: UnownedSerialExecutor {
        return Builtin.buildDefaultActorExecutorRef(self)
    }

    init() {
        _defaultActorInitialize(self)
    }

    deinit {
        _defaultActorDestroy(self)
    }
}

В этом случае реализация экзекьютора — это не отдельный объект, как в случае с MainActor, а ссылка на самого актора. Здесь начинаются отличия от других фич, которые мы рассматривали в прошлом: фактическая функциональность актора — это не код Swift, а класс C++:

class DefaultActorImpl : public HeapObject {
public:
  void initialize();
  void destroy();
  void enqueue(Job *job);
  bool tryAssumeThread(RunningJobInfo runner);
  void giveUpThread(RunningJobInfo runner);
}

Причина этого в том, что, как упоминалось ранее, многопоточность в акторах выполняется не с помощью DispatchQueues, а с помощью нового языка и фичи времени выполнения, которые были введены вместе с async/await. Вот почему вам нужна iOS 15 для использования акторов или async/await — это не просто улучшения Swift, они требовали изменений в самой iOS.

Функциональность актора по умолчанию сильно связана с функциональностью async/await, поэтому мы пока пропустим некоторые концепции. Но что касается постановки в очередь, дефолтный актор является по сути конечным автоматом, который содержит связанный список задач:

enum class Status {
  Idle,
  Scheduled,
  Running,
};

struct alignas(2 * sizeof(void*)) State {
  JobRef FirstJob;
  struct Flags Flags;
};

swift::atomic<State> CurrentState;

static void setNextJobInQueue(Job *job, JobRef next) {
  *reinterpret_cast<JobRef*>(job->SchedulerPrivate) = next;
}

Когда задача ставится в очередь, дефолтный актор захватывает ее старое состояние и добавляет новую задачу.

// Фактическая реализация этого метода значительно сложнее.
// Здесь она упрощена для образовательных целей.
void DefaultActorImpl::enqueue(Job *job) {
  auto oldState = CurrentState.load(std::memory_order_relaxed);
  setNextJobInQueue(job, oldState.FirstJob);
}

Фактическое выполнение этих задач тесно связано с async/await, но мы все же немного коснемся этого. Прежде всего, выполнение задачи (job) внутри актора имеет приоритет, который исходит от Task объекта async/await. Когда задача ставится в очередь, ее приоритет корректируется, чтобы гарантировать, что она выполнится перед другими менее приоритетными событиями.

auto oldPriority = oldState.Flags.getMaxPriority();
auto newPriority =
  wasIdle ? job->getPriority()
  : std::max(oldPriority, job->getPriority());
newState.Flags.setMaxPriority(newPriority);

Когда задача ставится в очередь, актор регистрирует задачу в том, что называется глобальным экзекьютором, который, по сути, является реализацией C++ класса, который обрабатывает Cooperative Threading Model async/await. Короче говоря, акторы могут владеть потоками и передавать (yield) их, а задача глобального экзекьютора уведомить актора, когда ему разрешено владеть определенным потоком. Когда это произойдет, дефолтный актор выполнит первую задачу в очереди и передаст поток.

// Обратите внимание, что это не фактическая реализация, а ее очень сильное упрощение.
static void processDefaultActor() {
    auto job = claimNextJobOrGiveUp();
    runJobInEstablishedExecutorContext(job);
    giveUpThread();
}

Кроме того, когда метод актора содержит вызов await, актор фактически передает поток в середине выполнения задачи, позволяя другим акторам выполняться, пока этот в состоянии ожидания. Когда результат будет доступен, новая задача будет поставлена в очередь, чтобы актор подхватил его позже в (возможно) другом потоке. Это то, что модель параллелизма описывает как Actor Reentrancy (повторное вхождение), и именно поэтому вы должны быть осторожны с thread-sensitive содержимым, таким как DispatchSemaphores, в async/await коде — нет гарантии, что поток, запустивший задачу, будет тем, который ее продолжит.

Заключение

Как мы увидели, подкапотная сторона модели параллелизма Swift сосредоточена вокруг планирования работы для различных служб-исполнителей. Хотя это исполнение доступно как высокоуровневый протокол Swift, фактическая реализация актора должна быть механизмом низкого уровня на C++ из-за новых функций времени выполнения, необходимых для async/await. В следующей статье мы рассмотрим async/await в связке с Cooperative Threading Model.

Ссылки и полезные материалы

Спасибо за внимание! Если вы хотите видеть больше подобного контента про Swift/iOS, подпишитесь на меня в Twitter.


Материал подготовлен в рамках курса «iOS Developer. Professional».

Всех желающих приглашаем на бесплатный двухдневный интенсив «GraphQl + iOS». На занятиях мы сделаем свой GraphQL-бекенд на Hesura Cloud и мобильный iOS-клиент с запросами с помощью Apollo и/или URLSession. В первый день интенсива расскажем про GraphQL и Hesura, во второй день прикрепим к заготовке нашего приложения Apollo.

РЕГИСТРАЦИЯ

Tags:
Hubs:
Total votes 4: ↑3 and ↓1+4
Comments3

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS