Актёры, гонки данных и отмена задач
Финальная тройка вопросов: зачем нужен actor, чем data race отличается от race condition и почему отмена задачи кооперативная.
Actor не «ускоряет», а исключает одновременный доступ к своему состоянию — и проверяет это компилятором, а не вашей дисциплиной.
«Чем data race отличается от race condition?»
Что на самом деле проверяют
Терминологическая точность: разница определяет лечение — data race чинится синхронизацией, race condition пересмотром логики.
Развёрнутый ответ
Data race — два потока без синхронизации обращаются к одной области памяти, и хотя бы один пишет. Это undefined behavior: не просто неверное значение, но и порванная структура.
Race condition — логическая гонка: результат зависит от очерёдности операций, и она возможна даже на потокобезопасных примитивах. Классика — «проверил, потом сделал»: каждый шаг атомарен, а вместе дырявы.
// data race: инкремент не атомарен
var counter = 0
DispatchQueue.concurrentPerform(iterations: 1000) { _ in counter += 1 }
// race condition без data race: шаги атомарны, логика дырявая
if await cache.contains(key) == false {
await cache.insert(key, expensiveValue()) // между проверкой и вставкой всё изменилось
}
Классические защиты и их цена: NSLock — дёшево, но легко забыть unlock; serial-очередь надёжна, но блокирует поток; барьеры хороши для reader-writer, но ручные. Общая беда: ничто не мешает обратиться к полю напрямую, минуя защиту.
«Что такое actor и какую проблему он решает?»
Что на самом деле проверяют
Понимаете ли вы, что актёр — не «класс с очередью», а модель изоляции, проверяемая компилятором.
Развёрнутый ответ
actor — ссылочный тип с изолированным изменяемым состоянием. Изнутри методы работают с полями напрямую, доступ извне — только через await. Компилятор статически доказывает отсутствие data race: обратиться к balance снаружи он не даст.
actor Account {
private var balance: Decimal = 0
nonisolated let id: UUID = UUID() // не трогает состояние — доступен синхронно
func deposit(_ amount: Decimal) { balance += amount } // изнутри — без await
func withdraw(_ amount: Decimal) throws -> Decimal {
guard balance >= amount else { throw AccountError.insufficientFunds }
balance -= amount
return balance
}
}
await account.deposit(100) // извне — только через await
print(account.id) // nonisolated-член, await не нужен
Global actor — та же изоляция, но общая для множества типов; самый известный, @MainActor, привязан к главному потоку: пометив им view model, вы получаете гарантию «UI-состояние меняется только на main» на уровне компиляции.
Реентерабельность — главная ловушка
Актёр гарантирует, что одновременно исполняется не больше одного его метода. Но он реентерабелен: на каждом await актёр освобождается и обслуживает другой вызов. После возобновления состояние может быть другим — это race condition внутри актёра, хотя data race нет.
actor ImageLoader {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
let loaded = try await download(url) // здесь актёр отпущен
if let cached = cache[url] { // повторная проверка обязательна
return cached // кто-то успел положить раньше
}
cache[url] = loaded
return loaded
}
}
Правило: считайте, что после каждого await вы попали в метод заново. Надёжнее же кэшировать не картинку, а саму Task.
«Как отменить задачу и почему отмена кооперативная?»
Что на самом деле проверяют
Понимаете ли вы, что cancel() ничего не убивает: принудительное прерывание оставило бы полузаписанные файлы, поэтому Swift ставит флаг.
Развёрнутый ответ
Task.cancel() помечает задачу и все её дочерние задачи как отменённые. Отреагировать можно тремя способами: проверить Task.isCancelled и выйти с частичным результатом, вызвать try Task.checkCancellation() и бросить CancellationError либо повесить withTaskCancellationHandler на внешний ресурс.
func process(_ items: [Item]) async throws -> [Result] {
var out: [Result] = []
for item in items {
try Task.checkCancellation() // бросит CancellationError
out.append(await transform(item))
}
return out
}
func fetch(_ url: URL) async throws -> Data {
let task = session.dataTask(with: url)
return try await withTaskCancellationHandler {
try await task.response()
} onCancel: { task.cancel() } // будим внешний ресурс
}
Библиотечные async-функции SDK проверяют флаг сами. А ваш цикл на миллион итераций без единой проверки докрутится до конца, сколько ни зови cancel().
Частый кейс — поиск по мере ввода: перед новой задачей отменяем прошлую, а внутри ждём паузу-debounce, чтобы отменённый ввод не долетел до сети.
@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var results: [Item] = []
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel() // прошлый запрос больше не нужен
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300)) // debounce
guard !Task.isCancelled else { return }
let found = (try? await api.search(query)) ?? []
if !Task.isCancelled { self.results = found }
}
}
}
Типичные ошибки кандидатов
- Считают data race и race condition синонимами.
- Считают, что actor «делает код потокобезопасным целиком», и не перепроверяют кэш после await.
- Думают, что
Task.cancel()немедленно останавливает выполнение, или проверяютTask.isCancelledодин раз в начале и больше никогда. - Ловят
CancellationErrorобщимcatchи показывают алерт «Ошибка сети».
Как ответить кратко
Data race — одновременный доступ к памяти, где хотя бы один поток пишет, то есть undefined behavior. Race condition — логическая гонка порядка, возможная и при атомарных операциях. Первое лечится синхронизацией, второе — пересмотром логики.
Actor — тип с изолированным состоянием: снаружи только через await, и это проверяет компилятор, в отличие от NSLock или serial-очереди. Ловушка — реентерабельность: после каждого await состояние могло измениться, поэтому кэш проверяем повторно.
Отмена кооперативная: cancel() лишь ставит флаг задаче и её потомкам. Задача сама проверяет Task.isCancelled или зовёт try Task.checkCancellation(), а для внешних ресурсов есть withTaskCancellationHandler. Пример — отмена прошлого поискового запроса при новом вводе.