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. Важно не бояться этого, а проектировать код так, чтобы предположения были короткоживущими и проверяемыми.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ