Почему Task под MainActor ухудшает производительность
Переход на Swift 6 и новую модель concurrency заставляет постепенно адаптировать старый код. Некоторые функции становятся асинхронными, начинают обновлять интерфейс, и легаси код начинает обрастать вставками Task. Часто в этих задачах нужно обновить интерфейс и возникает простое решение: пометить весь таск как @MainActor. Ошибки исчезают, UI обновляется, задача закрыта.
Однако это решение может снизить производительность приложения. Рассмотрим пример и почему так может происходить.
Допустим, была кнопка, по нажатию на которую запрашивались некоторые данные из пары источников и показывались в UI.
let workers = dataProvider.getAllWorkers()
var result: [Schedule] = []
for worker in workers {
let schedule = dataProvider.getSchedule(for: worker)
result.append(schedule)
}
display(schedule: result)
Когда-то это были очень простые операции из маленького локального файла, но потом стали сетевыми и очевидно нужно делать их async. Придется оборачивать в Task и чтобы там точно ничего не крешнулось, помечать @MainActor
Task { @MainActor in
setActivityIndicator(visible: true)
let workers = await dataProvider.getAllWorkers()
var result: [Schedule] = []
for worker in workers {
let schedule = await dataProvider.getSchedule(for: worker)
result.append(schedule)
}
setActivityIndicator(visible: false)
display(schedule: result)
}
Такой код действительно работает и работает корректно. Задачи получения из провайдера выполняются в фоне и не блокируют главный поток. Но внутри этой конструкции скрыт механизм, который начинает перегружать главный поток.
Что происходит под капотом
Каждый оператор await создаёт точку приостановки. После выполнения асинхронной функции Swift обязан вернуть выполнение туда же, где оно было запущено, а в данном случае — в MainActor.
В примере выше четыре N await функций дают N возвратов на главный поток.
Упрощённо, MainActor при каждом таком возврате:
— ставит continuation в свою очередь;
— планирует выполнение на главном потоке;
— когда главный поток свободен — продолжает выполнение следующего шага.
И так 5-10-15 лишних возвратов на MainActor только чтобы загрузить расписание для следующего работника! Swift компилятор не умеет «слепить» это в один hop — акторная модель запрещает такую оптимизацию.
Когда таких участков в приложении становится много, и каждый делает несколько await на MainActor, суммарная нагрузка на главный поток растёт. На горячих путях (например, старт приложения) это может проявляться в подлагивании интерфейса, задержках анимаций и снижении отзывчивости.
🤔
Кажется пример сильно выдуманным?
Но нет, в гибридном коде, который только стремится к new concurrency, это может быть частым паттерном. К тому-же, тут велик соблазн обратиться к LLM чтобы фиксить такие мелочи автоматом, и он без должного промтинга наравит просто раставить @MainActor повсюду чтобы обезопасится от крешей. Будьте внимательны!
Как писать правильно
Асинхронную работу нужно выполнять вне MainActor. Обновление интерфейса — только в одном коротком блоке.
Правильный подход использовать detached задачу и не ленится проставлять MainActor.run:
Task.detached {
await MainActor.run { setActivityIndicator(visible: true) }
let workers = await dataProvider.getAllWorkers()
var result: [Schedule] = []
for worker in workers {
let schedule = await dataProvider.getSchedule(for: worker)
result.append(schedule)
}
await MainActor.run {
setActivityIndicator(visible: false)
display(schedule: result)
}
}
В этом варианте тяжёлая работа остаётся на рабочем executor’е, а главный поток получает только необходимые небольшие UI-вызовы.
При использовании агентов для рефакторинга, попробуйте добавить этот абзац в ваши правила:
Всегда анализируй async/await-код с точки зрения нагрузки на MainActor: не размечай весь Task как @MainActor, если внутри есть тяжёлая работа. Помни, что каждый await в MainActor-контексте создаёт hop на главный поток. Тяжёлую часть выполняй в фоне (Task или structured concurrency), а UI-обновления выноси в короткие блоки через MainActor.run. Используй Task.detached только осознанно — он рвёт structured concurrency. Главная цель — минимум возвратов на главный поток и максимум отзывчивости интерфейса.
Итог
Пометить весь Task как MainActor — простое, но зачастую ошибочное решение в условии отсутствия хорошей архитектуры. При наличии нескольких await внутри оно приводит к множественным возвращениям на главный поток, снижает его пропускную способность и вызывает лаги.
Главный поток должен обновлять только интерфейс.
Асинхронная работа должна выполняться вне MainActor.
Один короткий вызов UI в конце — оптимальная стратегия, которая масштабируется и сохраняет отзывчивость приложения.