Акторы в Swift (Actors in swift)

Uladzislau Komar
8 min readMay 9, 2023

--

На конференции WWDC21 компания Apple представила новую фичу под названием Actors, которая решает фундаментальную проблему при написании асинхронного кода — состояний гонки.

Напомню, что состояние гонки возникает, когда два или более разных потока одновременно обращаются к одним и тем же данным, и по крайней мере одно из этих обращений является записью. Чтобы решить эту проблему, в свифт вводится новый удобный инструмент — актор.

Но что такое актор как сущность? Это не класс и не структура. Вот некоторые характеристики актора:

  • Он является ссылочным типом.
  • Он может иметь свойства, методы, инициализаторы и деинициализаторы.
  • Он не поддерживает наследование.
  • Его общедоступные свойства и методы не могут быть получены прямым доступом извне; мы должны использовать await и только в асинхронном контексте.
  • Он может выполнять только один метод одновременно, независимо от того, как мы обращаемся к актору.

Вспомним: состояние гонки (race condition)

Давайте создадим условия, при которых мы получим состояние гонки.

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

Один из возможных вариантов решения этой проблемы было использование замков или барьеров:

Состояние гонки — это серьезная проблема, которая может привести к неожиданным результатам в асинхронном коде. Использование замков и барьеров может помочь решить эту проблему, но такой подход не всегда эффективен и безопасен. Акторы, в свою очередь, обеспечивают безопасный доступ к общим данным без использования замков и барьеров, что делает их более эффективным и безопасным решением.

Акторы в действии

Давайте заменим наше ключевое слово class на actor.

Однако теперь мы получим ошибку другого типа:

В чем же ошибка? Дело в том, что обращение к актору теперь требует специального асинхронного контекста исполнения.

Как я писал в начале статьи: “Обращение к полям актора происходит только в асинхронном контексте”. А достигнуть его можно или с помощью конструкции Task или с помощью асинхронной функции.

В нашей первой версии с применением замков, мы должны были бы дождаться, пока блокировка станет доступной, прежде чем новый поток сможет получить доступ к переменной.

Однако новый подход к асинхронности в swift (async/await) не блокирует потоки.

Давайте рассмотрим следующий код, в который мы добавим регион с парком машин. И добавим в функцию возвращаемое значение.

Можете не переживать, ошибка никуда не исчезла :)

Actor-isolated instance method ‘addCar’ can not be referenced from a non-isolated context

Чтобы исправить это, нам нужно добавить к вызову метода ключевое словом await, а также сделать контекст исполнения асинхронным у метода addCars:

Ошибка пропала.

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

Единственное исключение из этого правила — это когда компилятор знает, что мы являемся частью изолированного контекста актора (actor’s isolation context). Иными словами, если мы уже находимся внутри какого-то актора, то у нас нет необходимости помечать обращение к полям await’ом. Например:

Однако бывают случаи, когда есть методы, которые никак не изменяют состояния ваших свойств внутри актора. И каждый раз помечать вызов таких методов с помощью ключевого слова await будет неудобно, как минимум. На этот случай есть выход.

Необходимо пометить такой метод ключевым слово nonisolated. Однако стоит учесть, что компилятор одобрит только те операции, которые действительно не изменят состояние ваших свойств даже в теории. Для таких случаем хорошо подойдет константа (let). С переменной (var), такого сделать не получится:

Как работают акторы, когда мы обращаемся к их методам

Почему же, когда мы вызываем какой-то метод экземпляра актора, не происходит race condition?

Для иллюстрации данного механизма я немного поменяю условия работы прошлого кода. Теперь у нас есть актор, который добавляет значение в переменную с названием машины. Класс регион имеет метод, который делает все то же самое. Код ниже:

Результат исполнения операций — 20 машин в автопарке. Мы не столкнулись с race condition. Теперь заглянем, как этот механизм работает изнутри.

Мы вызываем метод addCar из разных асинхронных контекстов. Эти методы попадают в что-то наподобие стека, откуда один за другим вызываются в Акторе, что и гарантирует безопасное обращение к свойству актора. Но так ли это всегда и почему это подобие стека?

Re-entrancy (или повторный вход)

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

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

Такой случай может наступить, например, если внутри исполнения метода актора мы натыкаемся на ключевое слово await.

Иллюстрация ниже, когда методы актора ставятся на паузу и ожидают внутри себя другую асинхронный метод/функцию. Актор обрабатывает другие запросы в это время.

Возникшая проблема имеет свое наименование — actor reentrancy (повторный вход актора [но я бы не советовал переводить такие термины :) ])

Давайте создадим импровизированное хранилище загрузки изображений в кеш, чтобы смоделировать проблему actor reentrancy. Будем делать запрос возвращения изображений по их ID. Если изображение уже загружено, то возвращаем загруженную картинку, если нет, то загружаем из интернета и возвращаем загруженную. Также добавим принты, чтобы видеть последовательность вызовов в консоли.

Код работает так, как и ожидается. Повторный запрос возвращает нам уже загруженную картинку. Консоль выглядит так:

Load the image from the internet
Fetch image from cache

Но что, если загрузка картинок будет происходить одновременно (асинхронно)? Давайте проверим. Для этого изменим немного нашу таску.

Посмотрим на консоль.

Load the image from the internet
Load the image from the internet

Не совсем то, что мы хотели получить. Происходит загрузка двух одинаковых изображений. Это и есть проблема actor reentrancy. Возникла она из-за того, что внутри метода актора у нас есть другая асинхронная задача — запрос в интернет (URLSession). Соответственно актор отложил эту работу до ее завершения и взял из “стека” другой метод для его исполнения.

Решить эту проблему можно разными способами. Сейчас попробую разобрать один из них.

Решение будет заключаться в добавлении состояния нашей картинки (загружается или загружена) в словарь в качестве значения. Состояние будет сделано с помощью перечислений (с ассоциированными значениями).

Алгоритм работы:

  1. В словаре ЕСТЬ значение состояния загрузки (Loading state).
  • Если состояние равно “в процессе загрузки”, то мы дожидаемся ответа и возвращаем этот ответ
  • Если состояние равно “загружено”, то мы возвращаем картинку

2. В словаре НЕТ значения состояния загрузки (Loading state)

  • Создаем таску на загрузку картинки, где Success = Data, Failure = Error (Task — это дженерик).
  • Создаем ключ-значение с состоянием (Loading state) “в процессе загрузки”, где ассоциированное значение перечисления — таска
  • Дожидаемся завершения загрузки изображения из интернета
  • Меняем статус значения в словаре с соответствующим ключем на “загружено”

В коде это будет выглядеть следующим образом:

Код я пометил принтами, чтобы проследить, что происходит. Консоль будет выглядеть следующим образом:

Image is in the loading state
Wait for Loading
Async task is in process
Image is in the complete state

Опишу, что происходит:

  1. При первом обращении (firstImage property) к методу актора мы проверяем наличие значения в нашем словаре. Его нет, соответственно мы пропускаем блок if.
  2. Создается асинхронная таска на загрузку изображения. Асинхронная работа таски начинается сразу без каких-либо триггеров (как, например, .resume в URLSession)
  3. Далее мы добавляем в словарь ключ со значением “loading”, куда передаем в ассоциированное значение нашу таску.
  4. В этот момент уже происходит второе асинхронное обращение (secondImage property). На данный момент значение состояния уже есть и оно равно “loading” с ассоциированным значением task.
  5. Далее во втором асинхронном обращении мы проверяем чему равно значение состояния. Оно равно loading. Далее мы ожидаем возврата значения нашей таски и по ее завершении мы вернем значение этой таски (оно равно Data)
  6. Как только Таска завершит свое исполнение она возвращает свое значение data:
  • В первом асинхронном обращении изменяется состояние нашего значения словаря на “загружено” с ассоциированным значением загруженной картинки. Далее мы возвращаем это значение из функции.
  • Во втором асинхронном обращении возвращается результат Task и срабатывает return.

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

Также вы можете почитать про actor reentrancy здесь (англ.):
- https://swiftsenpai.com/swift/actor-reentrancy-problem/
- https://www.donnywals.com/using-swifts-async-await-to-build-an-image-loader/

Глобальные акторы (Global Actors)

Может возникнуть ситуация, когда вы захотите запустить часть вашего кода в определенном акторе, даже если этот код не является частью этого актора. Это именно тот случай, когда нам необходимо запустить ваш код в главном потоке, который мы можем получить с помощью аттрибута @MainActor.

Чтобы запустить код на главном потоке, мы можем использовать следующую конструкцию:

Конструкция чем-то напоминает DispatchQueue.main.async {}.

Однако существуют более удобные конструкции для запуска функций асинхронно на главной очереди. Для этого мы будем использовать атрибут @MainActor. Например:

Таким образом, используя @MainActor мы сможем безопасно изменять UI нашего приложения.

Если нам необходимо быть уверенными, что результат вернется на главную очередь, мы можем воспользоваться двумя способами “вставки” атрибута:

Или

В данном случае эти два варианта эквивалентны. Я лично предпочитаю использовать второй. Но это не значит что в некоторых случаях эти два варианта могут быть аналогичными. В первом случае вся функция будет обработана на главном потоке, а во втором — только асинхронный запрос. Это значит, что если во втором случае поместить что-то до Task и вызвать эту функцию в асинхронном контексте, то эта обработка будет вызвана именно в этом асинхронном контексте, а уже сам Task на главной очереди.

Чтобы продемонстрировать это, я с помощью GCD вызвал данную функцию на другом потоке и проследить с помощью брейкпоинтов, на каком потоке мы находимся.

Итак. fetchImage вызывается у меня не на главном потоке.

Соответственно, т.к. принт находится на другом потоке, то он не относится к исполнению функции, помеченной как MainActor. Однако если бы у нас вся функция была бы помечена как @MainActor, то принт бы вызывался на основном потоке.

Запрос в интернет — асинхронная работа под капотом, потому он исполняется тоже на другом потоке.

Ну и, наконец, присваивание картинки UI компоненту происходит синхронно на основном потоке.

Атрибуты глобального актора могут быть применены к:

  • Функции
  • Объекты (классы, структуры)
  • Замыкания
  • Свойства

Вы также можете создать свои глобальные акторы. Пример:

Единственное требование к созданию глобального актора будет добавить синглтон.

PS — Хочу обратить ваше внимание также на то, что весь UIViewController помечен атрибутом @MainActor.

Документация Apple

Спасибо за прочтение. Буду рад вашему лайку.

Связаться со мной можно через мой LinkedIn, если у вас есть вопросы или вы заметили ошибку.

Владислав Комар
iOS Developer.

--

--