Актёры, гонки данных и отмена задач

Финальная тройка вопросов: зачем нужен 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. Пример — отмена прошлого поискового запроса при новом вводе.

Проверьте себя
1. Чем data race отличается от race condition?
AЭто одно и то же, просто разные названия
BData race — несинхронизированный одновременный доступ к памяти с записью, race condition — зависимость результата от очерёдности операций
CData race возможна только в Swift, race condition — только в Objective-C
DRace condition — это всегда undefined behavior, а data race приводит лишь к неверному значению
2. Почему после await внутри метода актёра нужно перепроверять состояние?
AПотому что await меняет поток и обнуляет свойства актёра
BПотому что актёр реентерабелен: на await он отпускается и может обслужить другой вызов
CПотому что компилятор сбрасывает кэш значений при каждом await
DЭто не нужно: актёр блокирует все остальные вызовы до конца метода
3. Что делает Task.cancel()?
AНемедленно прерывает выполнение задачи и освобождает поток
BБросает CancellationError из текущей строки выполняемого кода
CСтавит флаг отмены задаче и её дочерним задачам, реагировать должна сама задача
DОтменяет только дочерние задачи, не трогая родительскую