Почти в каждой статье об 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"
}
}
...
}
Все показанные здесь стратегии рабочие, но если придерживаться этого подхода, кода будет меньше. Он самый простой, работает эффективнее, в нем генерируется меньше кода и меньше побочных эффектов.
Какой выберете вы?
Читайте также:
- Реактивное программирование с Combine
- Как освоить API-интерфейсы Metal с UIView и SwiftUI
- Диспетчеризация методов в Swift
Читайте нас в Telegram, VK и Дзен
Перевод статьи Michael Long: Async/Await and MainActor Strategies