1. Дерево задач
Якщо раніше ви писали лише послідовний код, усе здавалося простим: рядок за рядком, як поїзд за розкладом, хоч інколи й запізнюється. Конкурентний код легко перетворюється на набір «поїздів-примар»: щось стартувало, щось іще біжить, щось уже впало з помилкою, а де саме — незрозуміло.
structured concurrency пропонує дисципліну: будь-яка побічна робота має бути частиною зрозумілої операції й мати чіткі межі життя. Саме тому ми говоримо про дерево: у будь-якої роботи є батько, а в батька — відповідальність дочекатися дітей.
У Swift structured concurrency описується як підхід, що організовує конкурентність в ієрархію задач, щоб скасування, поширення помилок і пріоритети природно рухалися вгору й униз цією ієрархією.
Якщо без філософії, запам’ятайте просту річ: дерево задач — це спосіб зрозуміти, хто за кого відповідає. Якщо ви знаєте, хто є батьком, то розумієте, хто має дочекатися результату, де перехопити помилку і де робота справді завершиться.
2. Task: контейнер роботи, а не потік
Коли ви починаєте розбиратися з конкурентністю, дуже хочеться уявити, що «задача = потік». Це звична, але небезпечна аналогія. Потік — це ресурс виконання, на кшталт робітника на заводі. Задача — радше наряд на роботу, який може бути виконаний різними робітниками в різні моменти часу.
У документах про structured concurrency підкреслюється: async/await сам по собі не робить виконання паралельним; усередині однієї задачі код іде послідовно, а конкурентність з’являється тоді, коли ви створюєте кілька задач (або кілька «дочірніх робіт») і чекаєте на їхні результати.
Щоб легше читати код, корисно уявляти такі стани задачі:
| Стан | Що це означає на практиці | Що ви зазвичай бачите в коді |
|---|---|---|
|
задача просто зараз виконує інструкції | звичайні рядки коду між await |
|
задача призупинилася і чекає | місце на await (або всередині викликаної async-функції) |
|
задача закінчилася успіхом, помилкою або скасуванням | момент, коли await повернув значення або виникла помилка |
Важливий висновок: await — це межа в часі. Між рядком перед await і рядком після нього може пройти відчутний проміжок часу, і за цей час встигнуть виконатися інші задачі.
3. Structured concurrency: батьки та діти
Ключова думка: structured concurrency робить так, що конкурентна робота живе в зрозумілих межах і не виходить за межі операції.
Ідея звучить приблизно так: ви будуєте ієрархію задач, а система допомагає так організувати виконання, щоб дочірні задачі не могли вийти за межі батьківської операції. Це дає змогу не будувати вручну тонни інфраструктури для тайм-аутів, скасування та пріоритетів — інформація тече по дереву автоматично.
Уявіть дерево:
flowchart TD
A["Команда CLI: fetch-many"] --> B["Задача батька: обробка команди"]
B --> C["Дочірня робота #1: завантажити книгу 101"]
B --> D["Дочірня робота #2: завантажити книгу 102"]
B --> E["Дочірня робота #3: завантажити книгу 103"]
C --> F["Повернути результат батькові"]
D --> F
E --> F
F --> G["Батько оновлює репозиторій і зберігає файл"]
Тут важливо не те, що вузли називаються «Task» (інколи це буквально Task, інколи — більш високорівнева «дочірня робота»), а те, що батько явно збирає результати дітей і лише потім іде далі.
4. Як дерево виражається в коді
async-функція як батьківська область
Коли ви пишете async-функцію, ви вже перебуваєте всередині певної задачі. Це, щонайменше, означає: у вашого шматка коду є контекст виконання, і він може створювати «підзадачі» — дочірні операції, які мають завершитися в межах цієї ж функції.
Почнімо з максимально простого прикладу, де дерево поки «одновузлове», але межі в часі вже видно:
import Foundation
func delay(ms: UInt64) async {
try? await Task.sleep(nanoseconds: ms * 1_000_000)
}
func demo() async {
print("A")
await delay(ms: 200)
print("B")
}
Тут немає паралельності, але корисно звикнути читати так: між
"A" і
"B" задача переходила в
suspended, а потім поверталася.
async let як гілки дерева
Тепер додамо розгалуження. async let — це синтаксис, який дає змогу запустити дочірню роботу конкурентно й водночас зберегти структурні межі: результат потрібно «зібрати» (await) у тій самій області коду.
Мініприклад: «дві затримки паралельно».
import Foundation
func parallelDelays() async {
async let a: Void = delay(ms: 200)
async let b: Void = delay(ms: 200)
_ = await (a, b)
print("Готово")
}
Зверніть увагу на рядок перед print: _ = await (a, b) — це і є точка збирання. Вона робить дерево прозорим: діти завершилися, батько йде далі.
Якщо ви спробуєте «забути» зібрати результат, компілятор почне ставити неприємні запитання. І це добре: це як нагадування «ви точно впевнені, що дитину можна залишити в торговому центрі саму?».
Точка збирання — гарантія здорового глузду
Іноді дуже хочеться написати: «ну, я запущу паралельно, а там якось само». У конкурентному коді «якось само» зазвичай означає «на продакшені буде весело».
structured concurrency вимагає, щоб дочірні роботи були завершені в межах зрозумілої області, бо інакше ви втрачаєте передбачуваність: де ловити помилку, коли закінчується операція, чи можна звільняти ресурси, чи можна вже записувати результат на диск.
Якщо говорити строго, structured concurrency замислювалася як спосіб розв’язати системні проблеми: поширення скасування, пріоритетів, тайм-аутів і контексту по стеку викликів, замість того щоб вручну передавати «токени» й дедлайни через десять шарів функцій.
На рівні читання коду це перетворюється на правило:
Якщо ви бачите «fan-out» (розгалуження), ви повинні побачити й «join» (збирання).
5. Приклад на LibraryCLI
Команда як корінь дерева
Застосуймо ідею дерева до навчального проєкту LibraryCLI. У нас є CLI, який виконує, наприклад, команду fetch-many, робить кілька мережевих запитів, а потім оновлює репозиторій і зберігає дані.
Ключовий момент: вся обробка однієї команди — це одна логічна операція. Вона має або завершитися успіхом і залишити систему в хорошому стані, або завершитися помилкою, але теж у зрозумілій точці.
Уявімо, що в нас є спрощена функція, яка отримує книгу за id:
import Foundation
struct Book { let id: Int; let title: String }
func fetchBook(id: Int) async throws -> Book {
try await Task.sleep(nanoseconds: 120_000_000)
return Book(id: id, title: "Книга #\(id)")
}
fetch-many як розгалуження і збирання
Тепер fetch-many може запустити кілька дочірніх робіт:
import Foundation
func fetchMany(ids: [Int]) async throws -> [Book] {
async let b1 = fetchBook(id: ids[0])
async let b2 = fetchBook(id: ids[1])
let (x, y) = try await (b1, b2)
return [x, y]
}
Цей приклад навмисно короткий: два id, дві гілки. У реальному коді id може бути більше, але тут важливе інше: fetchMany залишається «батьком», який зобов’язаний зібрати результати дітей, перш ніж повернути керування коду, який його викликає.
Спершу паралельно читаємо, потім послідовно змінюємо
У CLI-застосунку особливо важливо не влаштувати хаос у даних. Навіть якщо ви завантажуєте кілька книг паралельно, запис у репозиторій і збереження файлу краще робити послідовно, в одному місці, щоб не отримати напівперезаписаний файл і не захотіти піти працювати баристою.
Схематично це виглядає так:
flowchart LR
A["Паралельні fetch (діти)"] --> B["Збирання результатів (await)"]
B --> C["Послідовне оновлення репозиторію"]
C --> D["Збереження на диск"]
І кодом, якщо сильно спростити:
import Foundation
func handleFetchMany(ids: [Int]) async throws {
let books = try await fetchMany(ids: ids)
// updateRepository(books) // послідовне оновлення
// try saveToDisk() // теж послідовний крок
print("Отримано:", books.count)
}
Тут fetchMany — це «розгалуження і збирання», а оновлення й збереження — «єдиний фінал» батька.
6. Як читати async-код
Коли код стає конкурентним, мозок намагається читати його як звичайний послідовний код, і звідси народжуються баги рівня «я думав, що до цього моменту вже все готово». Тому потрібна нова навичка: читати async-код як історію з паузами.
Корисна звичка: коли бачите await, подумки вставляйте коментар: «тут могли пройти мілісекунди чи секунди, і інші задачі могли виконати частину роботи».
Порівняйте дві функції:
import Foundation
func sequential() async {
await delay(ms: 200)
await delay(ms: 200)
print("послідовно завершено")
}
і
import Foundation
func concurrent() async {
async let a: Void = delay(ms: 200)
async let b: Void = delay(ms: 200)
_ = await (a, b)
print("конкурентно завершено")
}
Обидві виглядають майже однаково з погляду змісту, але в другій ви явно будуєте дерево: дві дочірні гілки і точка збирання.
7. Практична користь дерева задач
Зараз ми свідомо не заглиблюємося в механіку скасування, тайм-аутів і пріоритетів (це окремі лекції), але важливо зрозуміти, чому саме дерево є для них фундаментом.
structured concurrency спроєктована так, щоб інформація природно текла по ієрархії: якщо верхня операція скасовується або стає пріоритетнішою, логічно, що її частини (діти) мають дізнатися про це, а не продовжувати працювати у вакуумі. Саме про це йдеться в прикладах: в ієрархії простіше забезпечити поширення скасування та керування пріоритетом дочірніх робіт.
На рівні «інженерної гігієни» це означає:
| Що потрібно в реальному проєкті | Як це зазвичай виглядає без структури | Чому дерево допомагає |
|---|---|---|
| Гарантовано дочекатися всіх підоперацій | ручні масиви «handles», прапорці, семафори | батьківська область сама задає межу |
| Зрозуміти, де ловити помилку | помилки «вилітають» із фонових робіт непередбачувано | помилка піднімається до батька у зрозумілій точці |
| Звільнити ресурси вчасно | «витоки» роботи, забуті операції | діти не «живуть довше» за батька |
І так, це один із тих рідкісних моментів, коли дисципліна справді економить час: ви менше налагоджуєте «примарні» баги, які проявляються лише під навантаженням.
8. Типові помилки
Помилка № 1: думати, що async означає «паралельно».
Дуже поширена пастка: ви бачите async і підсвідомо очікуєте прискорення. Але async — це про можливість призупиняти виконання на await, а не про одночасне виконання. Паралельність з’являється лише тоді, коли ви явно запускаєте кілька робіт (наприклад, через async let) і потім збираєте результати.
Помилка № 2: «сховати» await усередину складного виразу і втратити межі в часі.
Коли await опиняється десь посеред величезного рядка, читати код стає боляче: ви перестаєте бачити, де саме була пауза. На перших порах корисніше писати у стилі «одна важлива думка — один рядок»: спочатку запустили дочірні роботи, потім окремим рядком зібрали, потім пішли далі.
Помилка № 3: запускати дочірні операції й не мати явної точки «join».
Навіть якщо компілятор у деяких місцях підстрахує, сама звичка небезпечна: без точки збирання ви втрачаєте місце, де операція логічно закінчується. У CLI це особливо неприємно: команда може «формально завершитися», а якісь шматки роботи ще виконуються, і ви вже друкуєте користувачеві "Готово", коли насправді "Ще не готово".
Помилка № 4: змішувати паралельні «fetch» і паралельні мутації спільного стану.
Завантаження даних паралельно часто безпечне (бо воно «read-only» щодо вашого стану). А от паралельний запис у спільний репозиторій або файл — частий шлях до дивних багів: від втрачених записів до пошкодженого файла. Набагато спокійніше розділяти фази: паралельно зібрали результати, потім послідовно застосували зміни.
Помилка № 5: очікувати, що конкурентний код залишатиметься зрозумілим без «карти дерева» в голові.
Коли проєкт виростає за межі одного файла, конкурентність без дисципліни починає виглядати як серіал, який ви дивилися уривками на тлі приготування вечері. Рятує проста ментальна звичка: для кожної операції ставити собі два запитання — «хто батько?» і «де точка збирання результатів?». Щойно відповіді знаходяться в коді, читати й налагоджувати стає помітно простіше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ