JavaRush /Курси /Swift SELF /Реентрантність: чому await — точка призупинення

Реентрантність: чому 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 Завдання 1
    participant A as Актор
    participant T2 as Завдання 2

    T1->>A: methodA() починається
    Note over A: Актор виконує кроки 1..N
    Note over A: await у methodA → призупинення
    T2->>A: methodB() виконується, поки A призупинено
    Note over A: methodB змінює стан актора
    T1->>A: methodA() відновлюється
    Note over A: methodA продовжується, але стан уже інший

Ця модель — серце теми: коли акторно ізольована функція призупиняється, реентрантність дозволяє іншій роботі виконатися на акторі, перш ніж початкова функція відновиться; при цьому стан може змінитися «через 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) // точка призупинення
        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
Ви віддаєте назовні змінювані об’єкти за посиланням Ззовні їх можуть змінювати паралельно, а ви вже не контролюєте процес Віддавайте value-знімки (struct) або обчислювані значення
Ви робите довгий акторний метод з кількома await і купою логіки Важко зрозуміти, які припущення залишаються правильними після кожного await Розділяйте на етапи, фіксуйте дані локально, мінімізуйте «життя» припущень

Чому актор узагалі «дозволяє» такі перемежування? Тому що це знижує ризик взаємних блокувань і прибирає зайву серіалізацію: поки актор чекає мережі або диска, він може обслуговувати інші запити.

Прийом налагодження: логи для interleaving

Конкурентні баги важко ловити, тому що вони «іноді». Тож корисно вміти штучно створити вікно interleaving і за допомогою логів показати, що порядок насправді може бути не таким, якого ви очікували.

Ось мінімальний актор, який виводить кроки:

import Foundation

actor Tracer {
    private var state = "idle"

    func doWork(name: String) async {
        state = "початок \(name)"
        print(state) // наприклад, початок A
        try? await Task.sleep(nanoseconds: 50_000_000)
        print(state) // може вивести початок 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. Важливо не боятися цього, а проєктувати код так, щоб припущення були короткоживучими й перевірюваними.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ