Акторы в Swift решают проблему гонок данных. Cам по себе актор гарантирует, что к его состоянию обращается только одна задача одновременно. Никаких мьютексов, семафоров, очередей — компилятор сам следит.
Звучит неплохо. Но есть подвох, и он называется повторный вход (reentrancy). Актор защищает от одновременного доступа, но не защищает от того, что состояние изменится между двумя вашими обращениями к нему.
Как работает актор (коротко)
actor Счёт { var баланс: Int = 1000 func снять(_ сумма: Int) -> Bool { if сумма > баланс { return false } баланс -= сумма return true } }
Всё синхронно, нет ни одного await. Два вызова снять() никогда не выполнятся одновременно — актор обрабатывает их строго по очереди. Первый проверит баланс, спишет, завершится. Потом второй. Безопасно.
Где появляется проблема
Проблема начинается, когда внутри актора появляется await. Допустим, перед списанием нужно проверить личность через внешний сервис:
actor Счёт { var баланс: Int = 1000 func снять(_ сумма: Int) async -> Bool { // Проверяем личность — асинхронная операция await проверитьЛичность() // ⚠️ Вот тут проблема if сумма > баланс { return false } баланс -= сумма return true } func проверитьЛичность() async { try? await Task.sleep(for: .milliseconds(200)) } }
Теперь представьте, что две задачи вызывают снять(1000) почти одновременно.
Что происходит:
Задача А входит в
снять(), вызываетпроверитьЛичность(), уходит в ожиданиеАктор свободен! Он берёт следующую задачу из очереди
Задача Б входит в
снять(), вызываетпроверитьЛичность(), уходит в ожиданиеЗадача А возвращается: баланс = 1000, сумма = 1000, условие проходит, списываем. Баланс = 0
Задача Б возвращается: баланс = 0, сумма = 1000... но она проверяла баланс до того, как задача А списала! Она «помнит», что баланс был 1000
Подождите, задача Б не проверяла баланс до ожидания. Она проверит его после. Но к этому моменту задача А уже списала деньги, и баланс = 0. Так что в данном конкретном коде Б увидит 0 и вернёт false.
А вот если бы мы проверяли баланс ДО await:
func снять(_ сумма: Int) async -> Bool { // Проверяем ДО ожидания guard сумма <= баланс else { return false } // Уходим на проверку await проверитьЛичность() // Предполагаем, что баланс не изменился — ОШИБКА баланс -= сумма return true }
Вот тут обе задачи видят баланс = 1000, обе проходят проверку, обе уходят на await, обе возвращаются и обе списывают. Итог: баланс = -1000. Актор не помог.
Это и есть повторный вход: пока одна задача повисла на await, актор взял следующую задачу и пустил её внутрь.
Еще в идеях была альтернатива.
Запретить повторный вход. Если актор занят (даже если ждёт ответа от сервера), никто другой не может войти. Проблема: это прямой путь к взаимным блокировкам. Актор А ждёт ответа от актора Б, а Б ждёт ответа от А. Оба заблокированы навсегда.
Эпл выбрала меньшее зло: повторный вход может вызвать логические ошибки, но не подвешивает программу намертво.
Не верь тому, что было до await
Главное правило работы с акторами: каждый await — точка, после которой всё могло измениться. Если вы прочитали значение переменной, потом сделали await, потом используете это значение — вы на минном поле.
Решение первое: делайте все асинхронные вызовы ДО работы с состоянием.
func снять(_ сумма: Int) async -> Bool { // Сначала все await-ы await проверитьЛичность() // Потом — синхронная работа с состоянием // Между этими строками нет await, значит // никто не вклинится if сумма > баланс { return false } баланс -= сумма return true }
Все асинхронные операции — наверху. Вся работа с балансом — внизу, в синхронном блоке. Между проверкой и списанием нет await → нет повторного входа → нет проблемы.
Решение для кеширования: сохраняй задачу, а не результат
Классическая задача: актор-кеш, который загружает данные при первом обращении, а потом отдаёт из кеша.
Наивная реализация:
actor КешКартинок { var кеш: [URL: Data] = [:] func загрузить(_ url: URL) async throws -> Data { if let данные = кеш[url] { return данные } // Загружаем из сети let (данные, _) = try await URLSession.shared.data(from: url) кеш[url] = данные return данные } }
Проблема: если два вызова загрузить() приходят одновременно для одного адреса, оба увидят пустой кеш (потому что первый ещё не вернулся из await), и оба пойдут в сеть. Двойная загрузка.
Решение — сохранять не результат, а задачу:
actor КешКартинок { var задачи: [URL: Task<Data, Error>] = [:] func загрузить(_ url: URL) async throws -> Data { if let задача = задачи[url] { return try await задача.value } // Создаём задачу ДО await — это синхронная операция let задача = Task { let (данные, _) = try await URLSession.shared.data(from: url) return данные } // Сохраняем в словарь ДО await задачи[url] = задача return try await задача.value } }
задачи[url] = задача выполняется синхронно, до любого await. Когда второй вызов придёт — он увидит, что задача уже есть, и просто подождёт её результат. Одна загрузка, два получателя.
Вместо того чтобы сохранять результат после await, сохраняйте «обещание результата» (задачу) до await.
Ещё ловушка: «актор = последовательная очередь»
Многие думают: раз актор выполняет одну задачу за раз, он работает как последовательная очередь.
Последовательная очередь гарантирует, что задача А полностью завершится, прежде чем начнётся задача Б. ��ктор этого не гарантирует, он гарантирует только, что задача А и задача Б не выполняются в один и тот же момент. Но они могут чередоваться на точках await.
Если вам нужна последовательная очередь (задача Б начинается только после полного завершения задачи А), актор без дополнительного кода этого не обеспечивает. Нужна ручная сериализация — например, через цепочку задач:
actor ПоследовательныйИсполнитель { private var текущаяЗадача: Task<Void, Never>? func выполнить(_ работа: @Sendable @escaping () async -> Void) { let предыдущая = текущаяЗадача текущаяЗадача = Task { await предыдущая?.value // ждём завершения предыдущей await работа() } } }
Каждая новая задача ждёт завершения предыдущей. Последовательность гарантирована.
Синхронные методы — безопасная зона
Если метод актора не содержит ни одного await — повторный вход невозможен. Метод выполняется от начала до конца атомарно. Вывод: выносите работу с состоянием в синхронные методы.
actor Корзина { private var товары: [String: Int] = [:] // Синхронный — безопасен func добавить(_ товар: String, количество: Int) { товары[товар, default: 0] += количество } // Синхронный — безопасен func итого() -> Int { товары.values.reduce(0, +) } // Асинхронный — но работа с состоянием // делегирована синхронным методам func обработатьЗаказ() async throws { let список = товары // копируем ДО await let итог = итого() // await — тут состояние может измениться try await отправитьНаСервер(список: список, итог: итог) // Очищаем, но проверяем — а вдруг за время // ожидания что-то добавили? // Тут нужно решить: очищать всё или только то, // что было в заказе } private func отправитьНаСервер( список: [String: Int], итог: Int ) async throws { // ... } }
Чем запомнить
Акторы не защищают от логических гонок, когда ваш код полагается на значение, которое могло измениться за время await. Это ваша ответственность.
Все await — наверх, работа с состоянием — после. Не проверяйте условие до await, а потом действуйте по нему после await.
Если в вашем акторе нет ни одного await — повторный вход невозможен, и вы в полной безопасности. Как только появляется await — думайте, что может измениться, пока вы ждёте.

Если в Swift интересны не только синтаксис и интерфейсы, но и вещи, на которых чаще всего ломается реальная разработка — память, асинхронность, состояние и отладка, у Отус есть курс «iOS-разработчик. Базовый уровень». На нем последовательно разбирают Swift 6, SwiftUI, работу с сетью, тестирование, профилирование в Instruments и по ходу собирают собственное приложение. Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:
18 марта, 20:00. «Пишем простой проигрыватель на SwiftUI». Записаться
23 марта, 20:00. «Навигация Pro-уровня в SwiftUI: как строить масштабируемые iOS-приложения без хаоса в переходах». Записаться
Полный список бесплатных уроков марта смотрите в дайджесте.
