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