1. Два способи запускати неструктуровані задачі
Якщо подивитися на API Swift Concurrency з висоти пташиного польоту, легко поставити логічне запитання: «Навіщо мені два способи запустити роботу — Task {} і Task.detached {}? Хіба не можна було залишити один, а другий заховати під килим разом із ! і «швидкими рішеннями»?». На жаль, життя складніше: іноді нам справді потрібно продовжити роботу як частину поточного контексту, а іноді — суворо з чистого аркуша.
Важлива ідея дня: обидва способи створюють неструктуровану задачу, життям якої ми керуємо через «хендл», але вони відрізняються тим, наскільки нова задача «довіряє» оточенню, з якого її запустили. У пропозиції щодо structured concurrency це описано прямо: неструктуровані задачі створюються через Task { ... }, а detached tasks — це неструктуровані задачі, які не успадковують контекст.
Task {}: успадковує контекст і поводиться як «продовження історії»
Коли ви пишете Task { ... }, ви створюєте задачу й отримуєте «ручку керування» — значення типу Task<Success, Failure>. Через неї можна дочекатися результату (value), отримати його як Result (result) або скасувати задачу (cancel()). Також у Task є isCancelled.
Але головне для нашої теми — успадкування контексту. Task {} намагається бути «логічним продовженням» місця, де ви її створили. У пропозиції щодо structured concurrency сказано, що неструктуровані задачі, створені через ініціатор Task, успадковують важливі метадані: пріоритет, task-local значення і, що критично, контекст акторної ізоляції, якщо ви всередині актора.
Простими словами, Task {} — це «я запускаю додаткову роботу, але в межах поточної історії». Тому така задача часто безпечніша й передбачуваніша: вона не вистрибує з вашого світу й не починає жити за власними законами.
Невеликий приклад «ручки керування» у вакуумі — у реальному проєкті це буде всередині наших команд:
import Foundation
func computeMeaningOfLife() async -> Int {
42
}
func demoTaskHandle() async {
let task = Task { await computeMeaningOfLife() }
let value = await task.value
print("значення =", value) // значення = 42
}
Тут Task {} — це «створив, дочекався, прочитав». Жодної магії — але вже видно: задача живе окремо, а ми тримаємо хендл.
Task.detached {}: незалежна від контексту і вимагає більше дисципліни
Task.detached { ... } створює задачу іншого характеру: вона повністю незалежна від контексту, з якого її створили. У пропозиції щодо structured concurrency це сформульовано прямо: detached task не успадковує пріоритет, task-local значення й actor context.
Навіщо це взагалі потрібно? Іноді корисно створити роботу, яка не має залежати від поточного «дерева задач» і його метаданих. Наприклад, ви хочете виконати службову ділянку коду, яка не зобов’язана «шанувати» поточний контекст виконання.
Але саме тут починається зона ризику, тому що detached — це не просто «ще один Task». Це означає: «я виходжу з контексту і беру відповідальність на себе».
Гарний спосіб уявити різницю — у вигляді мінісхеми:
flowchart TD
A["Поточна операція (усередині async-коду)"] --> B["Task { ... }
успадковує контекст"]
A --> C["Task.detached { ... }
контекст не успадковує"]
B --> D["Результат / await у зрозумілій точці"]
C --> E["Ризик: втрата контексту й складніше гарантувати безпеку"]
2. Чому detached небезпечніший: shared mutable state і actor isolation
Головне правило: detached майже завжди означає «не чіпайте спільний змінюваний стан»
Зараз буде важливий шматок інженерної тверезості. Коли новачок дізнається про Task.detached, рука тягнеться застосувати його як «найсправжніший паралелізм». Проблема в тому, що detached-задача виконується конкурентно разом з усім іншим, і якщо всередині неї ви торкаєтеся спільного змінюваного об’єкта (shared mutable state), ви майже гарантовано створюєте умови для гонки даних (data race) — навіть якщо «нібито працює» на вашому ноутбуці.
У пропозиції щодо акторів окремо пояснюється, чому це небезпечно: Task.detached запускає замикання, яке не є actor-isolated, і тому доступ до акторно ізольованих сутностей має бути через await. Це частина механіки захисту від гонок.
Тобто detached в акторному світі — це як вийти з кімнати, де діє правило «за столом працює лише одна людина», і почати тягнути звідти предмети через вікно. Може, і вийде, але точно не того дня, коли ви хочете стабільності.
Чому гонки з Task.detached спливають простіше, ніж із Task {}
У цьому розділі важливо не переплутати: Task {} теж може призвести до поганих наслідків, якщо ви створите хаос із загальним станом. Але у Task.detached є дві особливості, які роблять помилки особливо ймовірними.
Перша особливість — він обриває контекст. У вас менше гарантій щодо того, де ви виконуєтеся і які правила навколо діють. У пропозиції щодо structured concurrency різницю описано як успадкування контексту у Task {} і відсутність успадкування у Task.detached.
Друга особливість — у detached замикання мається на увазі @Sendable, тобто воно призначене для потенційно конкурентного виконання, а отже до захоплень застосовуються суворіші правила. У пропозиції про Sendable і @Sendable показано, що захоплення мають бути безпечними для передавання між доменами конкурентності, і що деякі захоплення (наприклад, змінювана локальна змінна) забороняються.
Ми ще не вивчаємо Sendable як окрему тему (це буде пізніше), але нам достатньо чесної рамки: компілятор не просто капризує — він намагається врятувати вас від гонок.
Actor isolation: чому Task {} часто «природніший» усередині компонентів із власними правилами
Зараз обережно, без занурення в майбутні теми. У пропозиції щодо акторів показано важливий приклад: всередині актора Task.detached створює задачу, замикання якої не ізольоване актором, тому доступ до self і його стану має бути асинхронним.
Водночас пропозиція щодо structured concurrency каже, що Task {} може успадковувати actor context і виконуватися на executorʼі актора, і тоді доступ до actor-isolated стану стає природнішим з погляду правил ізоляції.
Практичний висновок для вас сьогодні простий: якщо ви перебуваєте всередині компонента з власними правилами (умовно: сервісу, репозиторію або актора в майбутньому), то Task {} частіше відповідає очікуванням, а Task.detached частіше ламає ці очікування.
3. Приклади на LibraryCLI
Поганий приклад: detached мутує репозиторій
Щоб приклад був близький до нашого проєкту, уявімо спрощену модель: у нас є репозиторій, який зберігає книги в пам’яті, а потім записує їх на диск. У курсі ми вже домовилися: запис у сховище робимо послідовно, бо «один записувач — менше сюрпризів».
Зараз покажу погану ідею: запустити detached-задачу і з її допомогою змінювати репозиторій.
import Foundation
final class LibraryRepository {
private var items: [String] = []
func add(_ title: String) {
items.append(title)
}
}
func unsafeDetached(repo: LibraryRepository) {
Task.detached {
repo.add("Нова книга") // ❌ спільний змінюваний стан усередині detached
}
}
Цей код може виглядати «нешкідливо», тому що append — один рядок. Але саме такі «нешкідливі однорядкові» речі потім перетворюються на найнеприємніші баги: іноді додалося, іноді ні, іноді список пошкодився, іноді «чомусь» упало під час збереження.
Суть не в тому, що append поганий. Суть у тому, що repo — спільний змінюваний об’єкт, а detached-задача живе сама й виконується конкурентно.
Більш безпечний варіант: detached повертає дані, а мутація — в одному місці
Наша «доросла» стратегія виглядає так: паралельно або просто асинхронно обчислюємо чи отримуємо дані, але крок змінювання робимо в одному місці, явно й у зрозумілій послідовності. Це прямо відповідає політиці single-writer, яку ми вже застосовували: розпаралелюємо отримання, а зміни репозиторію й збереження — послідовно.
Приклад: нехай detached повертає рядок, а не змінює репозиторій.
import Foundation
final class LibraryRepository {
private var items: [String] = []
func add(_ title: String) {
items.append(title)
}
}
func saferDetached(repo: LibraryRepository) async {
let task = Task.detached {
"Нова книга" // лише дані назовні
}
let title = await task.value
repo.add(title) // мутація в одному місці, явно
}
Так, тут усе ще є тонкощі (наприклад, де саме виконується repo.add і чи не чіпають репозиторій ще десь конкурентно). Але ключова думка сьогоднішньої лекції саме така: в detached не мутуємо спільний стан. Повертаємо «шматок результату», а збирання й запис робимо в одному зрозумілому місці.
Task {} як керована «паралельна робота» в коді команди
Давайте наблизимося до реальності курсу: у нас є команда fetch, яка ходить у мережу, отримує DTO, мапить їх у доменну модель і зберігає в репозиторій. Іноді хочеться паралельно зробити дві незалежні речі: наприклад, отримати «картку книги» і окремо отримати «обкладинку» або додаткову інформацію.
Ми можемо запустити дві неструктуровані задачі через Task {} і дочекатися їхнього результату через .value. У пропозиції щодо structured concurrency показано, що Task — це хендл, у якого є value і result.
import Foundation
struct BookDTO { let title: String }
struct CoverDTO { let url: String }
func fetchBook() async -> BookDTO { BookDTO(title: "Дюна") }
func fetchCover() async -> CoverDTO { CoverDTO(url: "https://example.com/dune.png") }
func fetchBookAndCover() async {
let bookTask = Task { await fetchBook() }
let coverTask = Task { await fetchCover() }
let book = await bookTask.value
let cover = await coverTask.value
print(book.title, cover.url) // Дюна https://example.com/dune.png
}
Зверніть увагу на стиль: задачі створені поруч, результати зібрані поруч. Це якраз та читабельність, до якої ми прагнемо: точки запуску й точки очікування мають бути видимі.
4. Коли Task.detached виправданий
Task.detached не «заборонений». Він просто вимагає дисципліни. Якщо ви його використовуєте, ви ніби говорите: «я розумію, що не успадковую контекст, і мені це справді потрібно».
Є типовий клас сценаріїв: ви хочете запустити роботу, яка не має залежати від поточного контексту і не має чіпати спільний змінюваний стан. Наприклад, умовно «сформувати діагностичний рядок», «порахувати хеш» від незмінних вхідних даних або зробити чисте перетворення.
Важливе уточнення: detached-задача особливо добре почувається, коли всередину ви передаєте прості незмінні значення — числа, рядки, невеликі структури, а назовні повертаєте теж значення. У пропозиції щодо @Sendable захоплень прямо сказано, що захоплення незмінних let значень відбувається за значенням (by-value), і це природна модель для безпечного коду.
Приклад безпечного detached: захоплення значення і повернення значення
import Foundation
func makeLabel(id: Int) async -> String {
let task = Task.detached { "book-id=\(id)" }
return await task.value
}
Тут немає спільного стану. id — просто число. Detached не може «зламати» нічого навколо, бо ламати нічого.
Правило дня: якщо хочеться detached, спочатку спробуйте Task {}
Ця звичка дуже економить нерви. У пропозиції щодо async let навіть є пряме порівняння: згадується, що Task.detached «більшу частину часу не має використовуватися», тому що не поширює важливий контекст (пріоритет, task-local, контекст виконання).
Професійний рефлекс такий: починаємо з Task {}. Якщо все добре — чудово. Якщо вам справді потрібно «відірватися від контексту» (і ви можете це обґрунтувати не словами «так веселіше»), тоді думаємо про Task.detached, але одразу перевіряємо себе запитанням: «А я не тягну всередину спільний змінюваний стан?».
5. Шпаргалка: Task {} vs Task.detached {}
Іноді краще один раз побачити таблицю, ніж десять разів «відчути». Тут ми фіксуємо найважливіше, що нам потрібно саме для сьогоднішнього дня.
| Властивість | Task { ... } | Task.detached { ... } |
|---|---|---|
| Тип результату | (хендл) |
(також хендл) |
| Ідея | «продовження поточної операції» | «самостійна операція» |
| Успадкування контексту | успадковує метадані й може успадковувати actor context | не успадковує пріоритет/task-local/actor context |
| Ризик shared mutable state | він усе ще можливий, але часто його простіше втримати в межах | вищий: легко випадково затягнути спільний об’єкт усередину |
| Стиль використання | «створив → await value → обробив» | «створив → await value → використав як чисту функцію», або дуже свідомо |
6. Типові помилки
Помилка № 1: використовувати Task.detached як «прискорювач» без причини.
Дуже хочеться думати, що detached — це «більш паралельно», а отже «швидше». Але detached передусім не про швидкість, а про незалежність від контексту. Якщо ваша мета — зробити код швидшим, найчастіше потрібно або розпаралелити незалежні запити нормальними засобами (async let або хоча б Task {} + await у зрозумілій точці), або оптимізувати алгоритм. Detached сам по собі не робить повільний код швидким — він робить його самостійнішим і потенційно небезпечнішим.
Помилка № 2: захопити в detached спільний об’єкт і мутувати його «потроху».
Це класика: «ну я ж лише один append роблю». Саме з цього починаються гонки. Правило дня звучить нудно, але рятує: detached повертає дані, а зміну спільного стану ми робимо в одному місці й послідовно. Пропозиція щодо акторів показує, що detached виконується конкурентно з усім іншим, і тому ізоляція важлива.
Помилка № 3: втратити межі відповідальності: хто має чекати результат і де.
Detached часто пишуть як «fire-and-forget»: запустили — і забули. У результаті помилки губляться, скасування не працює так, як очікується, а поведінка перетворюється на «іноді щось відбувається». Якщо результат важливий — зберігайте хендл і явно чекайте value або хоча б result. Сам тип Task і його API якраз для цього і спроєктовані: value, result, cancel().
Помилка № 4: думати, що якщо компілятор не свариться — значить безпечно.
Сьогодні ми тримаємося на дисципліні дизайну: «не тягнемо shared mutable state». Пізніше, на темі Sendable, ви побачите, як компілятор починає суворіше перевіряти такі речі, особливо в @Sendable-замиканнях (і Task.detached якраз із цієї зони). У пропозиції щодо Sendable показані приклади заборон на небезпечні захоплення — це не прискіпування, а захист від гонок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ