Стратегии Async/Await и MainActor

Почти в каждой статье об async/await на Swift появляются комментарии о @MainActor. Как применять его, чтобы любые обновления пользовательского интерфейса происходили в основном потоке?

Где именно должен быть атрибут @MainActor? В классе? Только в асинхронной функции? Внутри вложенного блока задач? В отдельных функциях, предназначенных исключительно для обновления потока? Может, задействовать что-то вроде await MainActor.run? Или DispatchQueue.main.async и просто забыть про все это?

У многих свое мнение, и мы не исключение.

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

Модель представления

Вот очень простая модель представления с двумя издателями, инициализатором, функциями load и process как элементом управления для понимания различий между обычной функцией и помеченной как асинхронная:

class ContentViewModel: ObservableObject {

@Published var accounts: [Account]
@Published var message: String?

let loader: AccountLoader

init(loader: AccountLoader) {
self.accounts = []
self.loader = loader
}

func load() async {
do {
accounts = try await loader.load()
message = nil
} catch {
message = "Unable to load"
}
}

func process(_ accounts: [Account]) {
self.accounts = accounts
}
}

В службе AccountLoader просто содержится функция func load() async throws -> [Account], вызываемая для получения данных.

SIL  —  промежуточный язык Swift

Приводим код SIL, сгенерированный для определения класса модели представления:

class ContentViewModel : ObservableObject {
@Published @_projectedValueProperty($accounts) var accounts: [Account] { get set _modify }
var $accounts: Published<[Account]>.Publisher { get set }
@_hasStorage final var _accounts: Published<[Account]> { get set }
@Published @_projectedValueProperty($message) var message: String? { get set _modify }
var $message: Published<String?>.Publisher { get set }
@_hasStorage @_hasInitialValue final var _message: Published<String?> { get set }
@_hasStorage final let loader: AccountLoader { get }
init(loader: AccountLoader)
func load() async
func process(_ accounts: [Account])
typealias ObjectWillChangePublisher = ObservableObjectPublisher
@objc deinit
}

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

Это только общее определение класса (кода сгенерировано намного больше), но этих 25 строк Swift достаточно для генерирования 798 строк кода SIL.

Вызов модели представления

Родительским представлением  —  тоже очень простым  —  вызывается функция load из модификатора task:

struct ContentView: View {
@StateObject var viewModel = ContentViewModel(loader: AccountLoader())
var body: some View {
Text("Number of accounts = \(viewModel.accounts.count)")
.task {
await viewModel.load()
}
}
}

Код выше рабочий, но при его запуске в симуляторе получим одно из новых фиолетовых сообщений об ошибке Xcode в строках, обозначенных #1 и #2 в исходной модели представления:

func load() async {
do {
accounts = try await loader.load() // #1
message = nil // #2
} catch {
message = "Unable to load"
}
}

Сообщение: “Publishing changes from background threads is not allowed” («Публикация изменений из фоновых потоков не допускается»).

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

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

Как ее устранить?

MainActor

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

MainActor  —  особая разновидность актора, который также привязан к основному потоку.

А @MainActor  —  это новая аннотация от Apple для указания Swift, что в коде, который содержится в конкретных классах, функциях или задачах, должен применяться глобальный MainActor и сам код должен всегда выполняться в основном потоке.

Посмотрим, как этим решается обозначенная проблема.

MainActor в асинхронной функции

Простейшее решение  —  аннотировать этой @MainActor асинхронную функцию:

class ContentViewModel: ObservableObject {
...

@MainActor
func load() async {
do {
accounts = try await loader.load() // #1
message = nil // #2
} catch {
message = "Unable to load"
}
}

...
}

Когда из асинхронной функции загрузчика учетных записей возвращается значение, перед их присвоением Swift при необходимости «запрыгивает» обратно в основной поток.

Вот определение класса SIL:

class ContentViewModel : ObservableObject {
@Published @_projectedValueProperty($accounts) var accounts: [Account] { get set _modify }
var $accounts: Published<[Account]>.Publisher { get set }
@_hasStorage final var _accounts: Published<[Account]> { get set }
@Published @_projectedValueProperty($message) var message: String? { get set _modify }
var $message: Published<String?>.Publisher { get set }
@_hasStorage @_hasInitialValue final var _message: Published<String?> { get set }
@_hasStorage final let loader: AccountLoader { get }
init(loader: AccountLoader)
@MainActor func load() async
func process(_ accounts: [Account])
typealias ObjectWillChangePublisher = ObservableObjectPublisher
@objc deinit
}

Оно такое же, только функция @MainActor func load() async аннотирована точно так, какой могла быть получена из исходного кода.

Покажем также код SIL, сгенерированный для функции load, но не паникуйте. Понадобятся лишь строки, предваряемые комментариями:

sil hidden @$s20ContentViewModelFunc0abC0C4loadyyYaF : $@convention(method) @async (@guaranteed ContentViewModel) -> () {
// %0 "self" // users: %36, %35, %18, %17, %15, %14, %6, %1
bb0(%0 : $ContentViewModel):
debug_value %0 : $ContentViewModel, let, name "self", argno 1, implicit // id: %1
// #1 Получаем MainActor
%2 = metatype $@thick MainActor.Type // user: %4
// function_ref static MainActor.shared.getter
%3 = function_ref @$sScM6sharedScMvgZ : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // user: %4
%4 = apply %3(%2) : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // users: %20, %5, %24, %12
// #2 Убеждаемся в применении main actor
hop_to_executor %4 : $MainActor // id: %5
%6 = ref_element_addr %0 : $ContentViewModel, #ContentViewModel.loader // user: %7
%7 = load %6 : $*AccountLoader // users: %25, %13, %9, %10, %8
strong_retain %7 : $AccountLoader // id: %8
%9 = class_method %7 : $AccountLoader, #AccountLoader.load : (AccountLoader) -> () async throws -> [Account], $@convention(method) @async (@guaranteed AccountLoader) -> (@owned Array<Account>, @error Error) // user: %10
// #3 Вызываем асинхронную функцию
try_apply %9(%7) : $@convention(method) @async (@guaranteed AccountLoader) -> (@owned Array<Account>, @error Error), normal bb1, error bb3 // id: %10

// %11 // user: %15
bb1(%11 : $Array<Account>): // Preds: bb0
// #4 Убеждаемся в применении main actor
hop_to_executor %4 : $MainActor // id: %12
strong_release %7 : $AccountLoader // id: %13
%14 = class_method %0 : $ContentViewModel, #ContentViewModel.accounts!setter : (ContentViewModel) -> ([Account]) -> (), $@convention(method) (@owned Array<Account>, @guaranteed ContentViewModel) -> () // user: %15
%15 = apply %14(%11, %0) : $@convention(method) (@owned Array<Account>, @guaranteed ContentViewModel) -> ()
%16 = enum $Optional<String>, #Optional.none!enumelt // user: %18
%17 = class_method %0 : $ContentViewModel, #ContentViewModel.message!setter : (ContentViewModel) -> (String?) -> (), $@convention(method) (@owned Optional<String>, @guaranteed ContentViewModel) -> () // user: %18
%18 = apply %17(%16, %0) : $@convention(method) (@owned Optional<String>, @guaranteed ContentViewModel) -> ()
br bb2 // id: %19

bb2: // Preds: bb1 bb3
strong_release %4 : $MainActor // id: %20
%21 = tuple () // user: %22
return %21 : $() // id: %22

// %23 // users: %38, %37, %27, %26
bb3(%23 : $Error): // Preds: bb0
// #5 Убеждаемся в применении main actor
hop_to_executor %4 : $MainActor // id: %24
strong_release %7 : $AccountLoader // id: %25
strong_retain %23 : $Error // id: %26
debug_value %23 : $Error, let, name "error", implicit // id: %27
%28 = string_literal utf8 "Unable to load" // user: %33
%29 = integer_literal $Builtin.Word, 14 // user: %33
%30 = integer_literal $Builtin.Int1, -1 // user: %33
%31 = metatype $@thin String.Type // user: %33
// function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
%32 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %33
%33 = apply %32(%28, %29, %30, %31) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %34
%34 = enum $Optional<String>, #Optional.some!enumelt, %33 : $String // user: %36
%35 = class_method %0 : $ContentViewModel, #ContentViewModel.message!setter : (ContentViewModel) -> (String?) -> (), $@convention(method) (@owned Optional<String>, @guaranteed ContentViewModel) -> () // user: %36
%36 = apply %35(%34, %0) : $@convention(method) (@owned Optional<String>, @guaranteed ContentViewModel) -> ()
strong_release %23 : $Error // id: %37
strong_release %23 : $Error // id: %38
br bb2 // id: %39
} // завершаем sil-функцию '$s20ContentViewModelFunc0abC0C4loadyyYaF'

Под комментарием #1 видим строки кода для получения ссылки на глобальный класс main actor и общий экземпляр main actor, используемый во всем приложении.

Все волшебство  —  в инструкции под комментарием #2 hop_to_executor %4 : $MainActor: ею вызывается метод для возвращения к основному потоку, если мы еще не в нем.

Если main actor занят и используется кем-то другим, задача снова приостанавливается.

Под комментарием #3 выполняется асинхронный вызов загрузчика учетной записи для получения данных. Обратите внимание на normal bb1, error bb3 в вызове функции: так коду фактически указывается, куда переходить при нормальном возвращении функции или выбрасывании ошибки.

Под комментариями #4 и #5 снова вызывается hop_to_executor: возвращаемся к основному потоку, прежде чем что-то присваивать издателям.

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

Удивительно, что при добавлении аннотации @MainActor к функции исходный код увеличился на одну строку, а общий сгенерированный размер  —  лишь до 805 строк кода SIL, то есть общее увеличение < 1 %.

Механизм довольно эффективен.

MainActor в классе

Альтернатива ему  —  аннотирование всего класса @MainActor:

@MainActor
class ContentViewModel: ObservableObject {
...
}

Так указываем Swift, что, в дополнение к асинхронному поведению выше, каждый вызов каждой функции и каждая ссылка на свойство должны выполняться в main actor.

Это отлично иллюстрируется общим кодом SIL, созданным для определения класса:

@MainActor class ContentViewModel : ObservableObject {
@Published @_projectedValueProperty($accounts) @MainActor var accounts: [Account] { get set _modify }
@MainActor var $accounts: Published<[Account]>.Publisher { get set }
@_hasStorage @MainActor final var _accounts: Published<[Account]> { get set }
@Published @_projectedValueProperty($message) @MainActor var message: String? { get set _modify }
@MainActor var $message: Published<String?>.Publisher { get set }
@_hasStorage @_hasInitialValue @MainActor final var _message: Published<String?> { get set }
@_hasStorage @MainActor final let loader: AccountLoader { get }
@MainActor init(loader: AccountLoader)
@MainActor func load() async
@MainActor func process(_ accounts: [Account])
typealias ObjectWillChangePublisher = ObservableObjectPublisher
@objc deinit
}

Здесь в Swift наши указания выполнены, почти все в классе помечено @MainActor.

Еще сюрприз: размер сгенерированного кода идентичен первой версии с асинхронной функцией, то есть всего 26 строк исходного кода и 805 строк кода SIL.

Добавление в класс main actor практически не сказалось на остальном коде класса.

Значит, просто помечаем так весь класс  —  и проблема решена?

Вряд ли. То, что почти все классе помечено как @MainActor, чревато побочными эффектами и проблемами в других местах кода. Любой другой класс или метод, чтобы получить доступ к одной из переменных или методов экземпляра, также должен находиться в изолированном контексте main actor.

Или в изолированном контексте с применением await для ссылки на значение.

Обычно это не проблема, если к модели представления имеется доступ из SwiftUI: представления SwiftUI и body представлений тоже помечаются как @MainActor.

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

Обычное решение  —  помечать эти методы как @MainActor  —  чревато проблемами с методами, которыми они вызываются, и заполонением аннотациями main actor всей кодовой базы.

Этим все усложняется. По-моему, проще функции, которым нужен main actor, пометить как @MainActor  —  и проблема решится.

С этим прицелом рассмотрим следующий пример.

MainActor в функциях обновления

Другое решение  —  создание функций обновления, аннотированных с помощью main actor:

class ContentViewModel: ObservableObject {

@Published var accounts: [Account]
@Published var message: String?

let loader: AccountLoader

init(loader: AccountLoader) {
self.accounts = []
self.loader = loader
}

func load() async {
do {
let accounts = try await loader.load()
await process(accounts) // #3
} catch {
await message("Unable to load") // #4
}
}

@MainActor // #1
func process(_ accounts: [Account]) {
self.accounts = accounts
}

@MainActor // #2
func message(_ message: String) {
self.message = message
}
}

Аннотирована main actor теперь не функция load, а измененная process (#1) и добавленная функция обновления сообщений (#2). При необходимости они вызываются из функции load (#3 и #4).

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

Функцию load не помечают как @MainActor  —  сделав работу, переходят к другой функции, помеченной как main actor, для выполнения фактических обновлений пользовательского интерфейса.

Но такой подход проблематичен по двум причинам.

Во-первых, на 20%, до 31 строки, увеличился исходный код, а сгенерированный код SIL  —  до 831.

Этот рост обусловлен созданием и/или изменением функций обновления с дублированным main actor, а также теперь уже переходом функции load из неизолированного контекста в изолированный. Вернувшись к комментариям #3 и #4, вы увидите: теперь для вызова процедур обновления нужна и await.

Вторая загвоздка круче. Мы знаем, что при возвращении из await попадание в основной поток не гарантировано, и делаем свою работу.

Впрочем, верно и обратное: в системе не прописано конкретной гарантии непопадания в этот поток.

Подумайте немного об этом.

Если нужно много обработки вне основного потока, стоит так ее и выполнить явно. Решения описываемой проблемы выходят за рамки статьи. Просто имейте в виду: этим конкретным подходом можно и не достичь того, что представляется достижимым.

MainActor.run

Другое решение заключается в явном, самостоятельном возвращении к основному потоку с помощью MainActor.run. Вот функция load:

func load() async {
do {
let accounts = try await loader.load()
await MainActor.run {
self.accounts = accounts
message = nil
}
} catch {
await MainActor.run {
message = "Unable to load"
}
}
}

Кажется относительно чистой, но при ее выполнении генерируется заметно больше кода  —  исходный увеличивается до 30 строк, а сгенерированный SIL  —  до 918, то есть на 15%.

Почему? Мы же просто делаем то, что уже делается в компиляторе Swift.

Да, но мы не компилятор Swift. Увеличение сгенерированного кода для этого и двух оставшихся решений  —  взрывное, поскольку двум обработчикам MainActor.run тоже нужно генерировать код для создания и выделения новых замыканий для основных блоков обновления и ошибок, а этим замыканиям нужно захватывать значения из текущего контекста и т. д.

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

Task/MainActor

Это тоже типичный подход:

func load() async {
do {
let accounts = try await loader.load()
Task { @MainActor in
self.accounts = accounts
message = nil
}
} catch {
Task { @MainActor in
message = "Unable to load"
}
}
}

Те же 30 строк исходного кода, но SIL увеличивается до 935.

Часть проблемы заключается в том, что, как и в MainActor.run, для выполнения обновлений создаются новые замыкания task.

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

Добавив пару операторов print, увидим это:

func load() async {
do {
let accounts = try await loader.load()
Task { @MainActor in
print("updating")
self.accounts = accounts
message = nil
}
print("returning")
} catch {
Task { @MainActor in
message = "Unable to load"
}
}
}

Запускаем код и видим ”returning“ («Возврат»), затем “updating” («Обновление»).

Задержка, которой чуть затрудняется написание тестового кода.

DispatchQueue.main

Последний подход  —  отказ от предыдущих. По сути, мы полностью уходим от многопоточности Swift и возвращаемся к DispatchQueue.main:

func load() async {
do {
let accounts = try await loader.load()
DispatchQueue.main.async {
self.accounts = accounts
self.message = nil
}
} catch {
DispatchQueue.main.async {
self.message = "Unable to load"
}
}
}

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

Не опускайте рук из-за уже обозначенных проблем, например трудностей при тестировании, а стремитесь к цели.

Заключение

Это все на сегодня. Мой выбор  —  первый пример с аннотированием асинхронной функции или функций, которые нужно аннотировать и где выполняются обновления потоков пользовательского интерфейса:

class ContentViewModel: ObservableObject {
...

@MainActor
func load() async {
do {
accounts = try await loader.load() // #1
message = nil // #2
} catch {
message = "Unable to load"
}
}

...
}

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

Какой выберете вы?

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Michael Long: Async/Await and MainActor Strategies

Предыдущая статья18 продвинутых навыков JavaScript для старших инженеров-программистов
Следующая статьяПростой прием для молниеносных запросов LIKE и ILIKE