JavaRush /Курсы /Swift SELF /Reentrancy: почему await

Reentrancy: почему await

Swift SELF
70 уровень , 2 лекция
Открыта

1. Реэнтрантность актёров и interleaving

Когда вы впервые услышали «actor», мозг часто рисует картинку: «внутри актёра всё строго по очереди, значит любой метод актёра выполняется целиком, без сюрпризов». Это почти правда… но с маленькой (и очень дорогой) припиской мелким шрифтом: до первого await.

Swift-акторы дают «иллюзию однопоточности» — на одном актёре не исполняются два фрагмента кода одновременно, но они могут перемежаться на точках приостановки (suspension points). Это и есть реэнтрантность: когда актёрный метод приостанавливается, актёр может начать обрабатывать другое сообщение, а потом вернуться к первому. Именно поэтому после await вы не имеете права считать, что мир остался прежним.

Давайте зафиксируем терминологию максимально по-человечески:

  • Data race (гонка данных) — это когда два потока/задачи одновременно трогают одну память без правил. Акторы в Swift как раз очень хорошо защищают от этого.
  • Logical race (логическая гонка) — это когда данные формально не «сломались», но логика стала неверной, потому что состояние успело измениться «между проверкой и действием». И вот от этого актёр не спасает автоматически, если вы сами положили «дыру» в инварианте на await.

Почему именно await — точка переключения

Почти всегда хочется думать про await как про «ну, это просто подождать результат». Но в Swift смысл глубже: await — это явная отметка потенциальной приостановки. Язык специально заставляет вас писать await, чтобы вы глазами видели места, где выполнение может прерваться и продолжиться позже.

И вот ключевая мысль: приостановка ломает атомарность. Если до await вы сделали половину работы и рассчитывали, что дальше «доделаете» вторую половину, то на await вы фактически говорите рантайму: «я пока отойду, можете обслужить других».

Чтобы это не звучало как философия, представим актёра как маленькую кофейню с одним бариста:

  • Пока бариста делает капучино и не отвлекается, он последовательно выполняет шаги рецепта.
  • Но если бариста дошёл до шага «подождать, пока кофемолка домелет» (это наш await), он может повернуться и принять другой заказ.
  • Вернувшись, он не обязан помнить, что на столе уже стояла чашка именно от предыдущего заказа — если вы не оставили понятных меток и правил.

Как выглядит interleaving

Сейчас аккуратно: в актёре не бывает ситуации «два метода исполняются параллельно на одном и том же актёре». Это важно: актёр сохраняет целостность памяти. Но бывает ситуация, когда метод A начал выполняться, дошёл до await, приостановился, затем метод B успел выполниться, а потом метод A продолжился. Это и называется interleaving.

Очень полезно видеть это как таймлайн. Вот схема, где мы не рисуем потоки, а рисуем куски выполнения:

sequenceDiagram
    participant T1 as Task 1
    participant A as Actor
    participant T2 as Task 2

    T1->>A: methodA() starts
    Note over A: A делает шаги 1..N
    Note over A: await внутри methodA → приостановка
    T2->>A: methodB() runs while A is suspended
    Note over A: methodB меняет состояние актёра
    T1->>A: methodA() resumes
    Note over A: methodA продолжает, но состояние уже другое

Эта модель — сердце темы: когда actor-isolated функция приостанавливается, реэнтрантность позволяет другой работе выполниться на актёре, прежде чем оригинальная функция возобновится; при этом состояние может измениться «через await».

2. Примеры логических гонок

Мини-демо: «невинный» счётчик

Сейчас сделаем пример, который можно буквально вставить в песочницу SwiftPM и понаблюдать глазами. Мы создадим актёр Counter, где один метод делает два инкремента, но между ними — await Task.sleep. Этот await специально нужен, чтобы дать системе шанс «вклинить» другой вызов.

import Foundation

actor Counter {
    private var value = 0

    func addTwoSlowly() async {
        value += 1
        try? await Task.sleep(nanoseconds: 100_000_000) // suspension point
        value += 1
    }

    func reset() { value = 0 }
    func get() -> Int { value }
}

С виду всё прилично: «ну добавляем два, что может пойти не так?». А теперь запускающий код (коротко и по делу):

import Foundation

@main
struct Demo {
    static func main() async {
        let c = Counter()
        Task { await c.addTwoSlowly() }
        Task { await c.reset() }
        print(await c.get()) // может быть 0 или 1 (и это уже повод задуматься)
    }
}

Что здесь важно заметить. Ошибка не в том, что Counter «плохой» или что актёр «не защищает». Актёр защищает память, да. Но после первого инкремента метод addTwoSlowly() приостановился, и reset() получил шанс выполниться «между двумя строками» addTwoSlowly(). Это чистая иллюстрация interleaving на await.

Паттерн «check → await → act»

Теперь перейдём к самому частому жизненному сценарию. Он встречается в банковских переводах, резервах складских остатков, лимитерах запросов, очередях импорта — и в CLI-проектах тоже, если не держать себя в руках.

Суть паттерна такая:

1) вы проверили условие на актёрном состоянии;
2) сделали await;
3) продолжили так, будто условие всё ещё истинно.

Проблема в том, что условие могло стать ложным, пока вы «спали».

Вот пример со «складом»:

import Foundation

actor Stock {
    private var items: Int = 10

    func takeIfPossible(_ count: Int) async -> Bool {
        guard items >= count else { return false }
        try? await Task.sleep(nanoseconds: 50_000_000) // тут нас могут "перебить"
        items -= count // логический риск
        return true
    }
}

Это выглядит законно, компилируется, и даже часто «работает» на локальной машине. Но логически вы дали миру окно: между проверкой и изменением состояния мог успеть выполниться другой вызов на этом же актёре.

Минимальная защита, если await неизбежен — перепроверка после await:

import Foundation

actor Stock {
    private var items: Int = 10

    func takeSafely(_ count: Int) async -> Bool {
        guard items >= count else { return false }
        try? await Task.sleep(nanoseconds: 50_000_000)
        guard items >= count else { return false } // повторная проверка
        items -= count
        return true
    }
}

Это не «магическая серебряная пуля», но это очень честное правило: если вы понимаете, что await мог пустить внутрь другую работу, то и предположения надо проверить заново.

Пример: LibraryCLI и «перепутанный» импорт

Перенесём идею в контекст CLI. Представим, что мы сделали актёр, который координирует импорт: он хранит currentImportID, чтобы логировать «какую книгу мы сейчас тянем», а затем возвращает это значение в конце (например, для красивого отчёта).

И вот здесь очень легко сделать «почти правильный, но неверный» код: актёр записывает значение в stored property, затем await, затем возвращает stored property и получает сюрприз.

Сделаем намеренно плохую версию:

import Foundation

actor ImportCoordinator {
    private var currentImportID: String? = nil

    func importBook(id: String) async -> String? {
        currentImportID = id
        try? await Task.sleep(nanoseconds: 80_000_000) // имитируем сеть
        return currentImportID // может оказаться другим id
    }
}

Почему так может случиться? Потому что два параллельных импорта могут перемежаться:

  • импорт A записал currentImportID = "A", дошёл до await и «уснул»;
  • импорт B записал currentImportID = "B", дошёл до await и «уснул»;
  • импорт A проснулся и вернул currentImportID, но там уже "B".

Это не гонка данных (память не повреждена), это гонка смысла.

Правильная версия — «не хранить то, что относится к конкретной операции, в общем актёрном поле, если дальше будет await». Используем локальную константу:

import Foundation

actor ImportCoordinator {
    func importBook(id: String) async -> String {
        let importingID = id          // snapshot для этой операции
        try? await Task.sleep(nanoseconds: 80_000_000)
        return importingID            // всегда вернём то, что начали импортировать
    }
}

Обратите внимание: мы не «запретили параллельность», не сделали систему медленнее и не изобрели замок. Мы просто перестали делать предположение «моё поле всё ещё про меня».

3. Практика: как писать и отлаживать actor-код

Правила, которые переживают await

Сейчас хочется какую-то «универсальную инструкцию на все случаи жизни», но в конкурентности, увы, действует закон сохранения боли: либо вы дисциплинированы, либо вас дисциплинирует баг-репорт в пятницу вечером.

Поэтому вместо лозунгов — несколько практических приёмов, оформим их таблицей.

Ситуация Почему это опасно при await Как сделать устойчиво
Вы записали в stored property «контекст операции», потом сделали await, потом используете stored property В межвременье другой вызов может перезаписать это поле Держите контекст операции в локальных let (snapshot)
Вы сделали «проверка → await → изменение состояния» Условие могло устареть, инвариант мог нарушиться Не ставьте await между check и mutate; если нельзя — перепроверьте после await
Вы отдаёте наружу ссылочные mutable-объекты Снаружи могут менять их параллельно (и вы уже не контролируете) Отдавайте value-снимки (struct) или вычисляемые значения
Вы делаете длинный актёрный метод с несколькими await и кучей логики Трудно понять, какие предположения верны после каждого await Разделяйте на этапы, фиксируйте данные локально, минимизируйте «жизнь» предположений

Почему актёр вообще «разрешает» такие перемежения? Потому что это снижает риск взаимных блокировок и уменьшает бессмысленную сериализацию: пока актёр ждёт сеть или диск, он может обслужить другие запросы.

Приём отладки: логи для interleaving

Конкурентные баги тяжело ловить, потому что они «иногда». Поэтому полезно уметь искусственно создать окно interleaving и логами показать, что порядок реально может быть таким, как вы не ожидали.

Вот минимальный актёр, который печатает шаги:

import Foundation

actor Tracer {
    private var state = "idle"

    func doWork(name: String) async {
        state = "start \(name)"
        print(state) // например: start A
        try? await Task.sleep(nanoseconds: 50_000_000)
        print(state) // может напечатать start B
    }
}

И запуск:

import Foundation

@main
struct Demo2 {
    static func main() async {
        let t = Tracer()
        Task { await t.doWork(name: "A") }
        Task { await t.doWork(name: "B") }
    }
}

Этот пример не «чинит» ничего — он помогает мозгу принять реальность: await реально разрезает выполнение, а актёр реально может переключиться на другую работу.

4. Типичные ошибки

Ошибка №1: считать, что актёр делает любой метод «атомарным».
Иллюзия «ну раз актёр, значит весь метод выполняется целиком» очень естественна, особенно после опыта с однопоточными очередями. Но реэнтрантность означает: актёрный метод может быть прерван на await, и другая работа может вклиниться до его продолжения. Это не баг акторов — это их модель.

Ошибка №2: хранить «контекст операции» в stored property и использовать его после await.
Самый частый вариант — currentTask, currentID, lastRequest, mode, currentUser. Если это поле описывает конкретный запуск метода, а не стабильное состояние актёра, оно почти гарантированно станет источником логической гонки. Спасает простой приём: snapshot в локальную let и работа с ним.

Ошибка №3: писать «check → await → act» и надеяться на удачу.
Такой код иногда проходит тесты месяцами, пока не появится реальная нагрузка или параллельный fetch-many. Проблема здесь не в том, что состояние «сломается», а в том, что ваши предположения устареют. Если await неизбежен, перепроверяйте условия после await или меняйте дизайн так, чтобы критическая часть была без приостановок.

Ошибка №4: делать слишком длинные актёрные методы с несколькими await, не разделяя ответственность.
Чем длиннее метод и чем больше в нём потенциальных suspension points, тем сложнее удерживать инварианты в голове. На практике это превращается в код, который вы боитесь трогать: «не буду рефакторить, а то сломается». Лучше дробить на этапы и явно понимать, какие данные «живут» через await, а какие нужно заново получить или проверить.

Ошибка №5: думать, что «если нет гонки данных, то всё безопасно».
Акторы действительно предотвращают data races на своём состоянии, но логические гонки — это ваша зона ответственности. Как только вы положили важное предположение «между строками» и поставили там await, вы сами открыли дверь interleaving. Важно не бояться этого, а проектировать код так, чтобы предположения были короткоживущими и проверяемыми.

1
Задача
Swift SELF, 70 уровень, 2 лекция
Недоступна
След выполнения
След выполнения
1
Задача
Swift SELF, 70 уровень, 2 лекция
Недоступна
Потерянный контекст
Потерянный контекст
1
Задача
Swift SELF, 70 уровень, 2 лекция
Недоступна
Склад и ожидание
Склад и ожидание
1
Задача
Swift SELF, 70 уровень, 2 лекция
Недоступна
Резерв номеров
Резерв номеров
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ