JavaRush /Курси /Swift SELF /Структурована конкурентність: дерево задач

Структурована конкурентність: дерево задач

Swift SELF
Рівень 68 , Лекція 2
Відкрита

1. Дерево задач

Якщо раніше ви писали лише послідовний код, усе здавалося простим: рядок за рядком, як поїзд за розкладом, хоч інколи й запізнюється. Конкурентний код легко перетворюється на набір «поїздів-примар»: щось стартувало, щось іще біжить, щось уже впало з помилкою, а де саме — незрозуміло.

structured concurrency пропонує дисципліну: будь-яка побічна робота має бути частиною зрозумілої операції й мати чіткі межі життя. Саме тому ми говоримо про дерево: у будь-якої роботи є батько, а в батька — відповідальність дочекатися дітей.

У Swift structured concurrency описується як підхід, що організовує конкурентність в ієрархію задач, щоб скасування, поширення помилок і пріоритети природно рухалися вгору й униз цією ієрархією.

Якщо без філософії, запам’ятайте просту річ: дерево задач — це спосіб зрозуміти, хто за кого відповідає. Якщо ви знаєте, хто є батьком, то розумієте, хто має дочекатися результату, де перехопити помилку і де робота справді завершиться.

2. Task: контейнер роботи, а не потік

Коли ви починаєте розбиратися з конкурентністю, дуже хочеться уявити, що «задача = потік». Це звична, але небезпечна аналогія. Потік — це ресурс виконання, на кшталт робітника на заводі. Задача — радше наряд на роботу, який може бути виконаний різними робітниками в різні моменти часу.

У документах про structured concurrency підкреслюється: async/await сам по собі не робить виконання паралельним; усередині однієї задачі код іде послідовно, а конкурентність з’являється тоді, коли ви створюєте кілька задач (або кілька «дочірніх робіт») і чекаєте на їхні результати.

Щоб легше читати код, корисно уявляти такі стани задачі:

Стан Що це означає на практиці Що ви зазвичай бачите в коді
running
задача просто зараз виконує інструкції звичайні рядки коду між await
suspended
задача призупинилася і чекає місце на await (або всередині викликаної async-функції)
completed
задача закінчилася успіхом, помилкою або скасуванням момент, коли 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: очікувати, що конкурентний код залишатиметься зрозумілим без «карти дерева» в голові.
Коли проєкт виростає за межі одного файла, конкурентність без дисципліни починає виглядати як серіал, який ви дивилися уривками на тлі приготування вечері. Рятує проста ментальна звичка: для кожної операції ставити собі два запитання — «хто батько?» і «де точка збирання результатів?». Щойно відповіді знаходяться в коді, читати й налагоджувати стає помітно простіше.

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