1. Вступ
Якщо ви колись натискали «Скасувати» під час завантаження файлу, закривали сторінку, поки вона «думає», або надсилали запит на кшталт «Погодьтеся, я передумав», ви вже інтуїтивно розумієте, навіщо потрібне скасування. В асинхронному світі ми постійно запускаємо операції, результат яких з’явиться пізніше, але інколи він уже не потрібен. Наприклад, користувач увів інший запит, а ми ще обчислюємо старий.
У Swift cancellation (скасування) — це не «вбивство» потоку і не зупинка застосунку. Це сигнал задачі: «результат більше не потрібен». Саме так це формулюють у рекомендаціях щодо адаптації concurrency: скасування означає, що той, хто чекає на результат, більше не зацікавлений у ньому. Далі починається важлива частина: задача має сама чемно відреагувати на цей сигнал — припинити роботу й повернути керування.
Щоб не було відчуття магії, зафіксуймо одне речення, яке варто роздрукувати й повісити над монітором: скасування в Swift кооперативне — якщо код не перевіряє скасування, він може продовжувати працювати, ніби нічого не сталося.
Кооперативне скасування в Swift
На перший погляд, скасування виглядає просто: у вас є Task (посилання на задачу), ви викликаєте task.cancel(), і «ніби» все має зупинитися. Але всередині модель влаштована трохи хитріше — і це добре, бо забезпечує передбачуваність.
Коли задачу скасовують, у ній встановлюється внутрішній прапорець cancelled. Цей прапорець, одного разу встановлений, не скидається. Далі все залежить від того, чи перевірить код цей прапорець і що він зробить, якщо побачить скасування.
Swift спеціально робить скасування таким «ненасильницьким», тому що примусова зупинка майже завжди перетворюється на хаос: ресурси не звільнено, стан напівзмінений, файл записано наполовину, а індекс бібліотеки тепер нагадує абстрактне мистецтво. Тому стратегія проста: скасування — це сигнал, а коректна реакція на нього — наша відповідальність.
На практиці у нас є два основні інструменти:
| Інструмент | Що робить | Коли зручно |
|---|---|---|
|
повертає Bool — чи скасовано поточну задачу | коли ви не хочете або не можете викидати виняток і віддаєте перевагу return або «частковому результату» |
|
якщо задачу скасовано, викидає CancellationError | коли ваша функція async throws і вам зручно вийти через throw |
Обидва способи описані в дизайні structured concurrency: Task.isCancelled безпечно перевіряти, а Task.checkCancellation() — це зручний шорткат, який викидає стандартний виняток скасування.
2. Task.isCancelled: мʼяка перевірка скасування
Дуже хочеться думати, що Task.isCancelled — це якась супер-кнопка «Зупинити все». Насправді це просто запит до поточної задачі: «Нас скасували?». Якщо відповідь true, далі вже ви вирішуєте, що робити. І ось тут починається доросле життя: ви маєте справді завершити роботу — через return, break, повернення часткового результату тощо. Інакше перевірка не має сенсу.
Важливо памʼятати ще одну деталь: перевірка Task.isCancelled безпечна навіть у синхронному коді, але якщо код не виконується всередині задачі, вона просто поверне false. Тобто «поза async-світом» скасування наче не існує.
Мініприклад: виходимо з циклу і повертаємо частковий результат
Уявімо, що ми формуємо рядок попереднього перегляду для виведення. У нашому CLI це цілком реальна річ: наприклад, «перші N знайдених результатів». Якщо задачу скасували, ми можемо повернути те, що вже встигли зібрати.
import Foundation
func buildPreview(limit: Int) async -> String {
var result = ""
for i in 1...limit {
if Task.isCancelled { return result }
result += "\(i) "
}
return result
}
Тут скасування не вважається «помилкою»: ми просто чесно повертаємо те, що встигли. Такий стиль особливо корисний у завданнях «зібрати багато результатів», де частковий результат кращий за відсутність будь-якого результату.
Мініприклад: перевірка є, реакції немає
Іноді новачки пишуть перевірку і… продовжують працювати далі. Це виглядає приблизно так (приклад спеціально невдалий):
func badLoop(limit: Int) async -> Int {
var total = 0
for i in 1...limit {
if Task.isCancelled {
// Ой, нас скасували... Ну гаразд.
}
total += i
}
return total
}
Такий код перевіряє скасування, але ігнорує його. Скасували задачу — а вона далі радісно додає числа, ніби не отримала листа «ми розходимося». Це часта причина питання: «Чому cancel() не працює?»
4. Task.checkCancellation(): скасування через CancellationError
Коли ви пишете бібліотечний або сервісний код, а ми саме це робимо в нашому CLI-проєкті, часто хочеться, щоб скасування оброблялося так само, як і інші причини, через які далі не можна продовжувати: через throw.
Для цього і існує Task.checkCancellation(). Якщо поточну задачу скасовано, цей виклик викине CancellationError — стандартний легкий виняток скасування. Важливий наслідок: така функція має бути throws, а виклик — з try. Це дуже корисно для читабельності: ви одразу бачите місця, де можливий ранній вихід.
Мініприклад: скасовуємо довге обчислення через CancellationError
import Foundation
func computeSum(limit: Int) async throws -> Int {
var total = 0
for i in 1...limit {
try Task.checkCancellation()
total += i
}
return total
}
Якщо ззовні хтось викличе cancel(), задача перестане бути «зобовʼязаною» дійти до кінця — вона вийде через CancellationError.
Мініприклад: відрізняємо скасування від звичайних помилок
import Foundation
func demoCatch() async {
let task = Task { try await computeSum(limit: 100_000_000) }
task.cancel()
do {
let value = try await task.value
print("Сума:", value)
} catch is CancellationError {
print("Скасовано")
} catch {
print("Помилка:", error)
}
}
Чому ми відокремлюємо CancellationError? Тому що скасування — це не обовʼязково «помилка виконання». Частіше це нормальний сценарій керування життєвим циклом задачі: «вже не потрібно». У документації про structured concurrency окремо підкреслюється, що cancellation — це окремий шлях завершення, і його зазвичай очікують як «швидкий вихід».
5. Інтегруємо скасування в LibraryCLI
А тепер — найпрактичніша частина. Ми візьмемо типову CLI-задачу: «зроби щось довге» (наприклад, перебудуй індекс, опрацюй пакет даних, імпортуй записи). Навіть якщо у вас поки немає справжньої бази та індексу, сама форма задачі вже корисна: є цикл, є прогрес, і є бажання вміти зупинитися.
Ми зробимо дві речі: функцію, яка виконує довгу роботу й перевіряє скасування, та маленький сценарій у main() async, який запускає задачу й дає користувачеві можливість її скасувати.
Довга операція: перебудова індексу
Припустімо, що у нас є масив «книг» (у навчальній версії — просто заголовки). Ми «будуємо індекс» як словник: слово → кількість. Справжній індекс у нас буде пізніше, і він буде складнішим, але сьогодні нам важливе інше: довгий цикл і перевірка скасування.
import Foundation
func rebuildWordCountIndex(titles: [String]) async throws -> [String: Int] {
var index: [String: Int] = [:]
for title in titles {
try Task.checkCancellation()
for word in title.lowercased().split(separator: " ") {
index[String(word), default: 0] += 1
}
}
return index
}
Зверніть увагу на стиль: перевірка скасування стоїть на верхньому рівні циклу. Це добра звичка: перевіряти скасування на межах роботи — перед тим, як перейти до наступної великої порції. Такий підхід узгоджується із загальною рекомендацією: скасування особливо корисно перевіряти перед дорогою операцією, щоб не робити роботу даремно.
Запуск і скасування: Enter для зупинки
В ідеальному світі скасування в CLI надходить від сигналів (Ctrl+C) або від зовнішнього контролера, але це окрема історія. Сьогодні ми зробимо просту, передбачувану демонстрацію: запустимо задачу й запропонуємо натиснути Enter, щоб скасувати її.
import Foundation
@main
struct LibraryCLIApp {
static func main() async {
let titles = Array(repeating: "Swift Concurrency Guide", count: 200_000)
let task = Task {
try await rebuildWordCountIndex(titles: titles)
}
print("Натисніть Enter, щоб скасувати перебудову індексу...")
_ = readLine()
task.cancel()
do {
let index = try await task.value
print("Розмір індексу:", index.count)
} catch is CancellationError {
print("Операцію скасовано користувачем")
} catch {
print("Помилка:", error)
}
}
}
Так, тут є блокувальний readLine(). Для CLI-демо це нормально: ми хочемо просту кнопку «скасувати». У серйозніших застосунках ви будете обережніше ставитися до блокувань, але сьогодні мета інша — відчути, що скасування реально доходить до вашої функції й коректно її завершує.
Нюанс: cancel() сам по собі не перериває обчислення
Якщо ви приберете try Task.checkCancellation() з rebuildWordCountIndex, то cancel() перестане бути ефективним: задача може продовжити працювати до кінця, тому що ніде не перевіряє скасування. Це не вада, а дизайн: скасування кооперативне.
6. Де перевіряти скасування
Новачки часто запитують: «Гаразд, я зрозумів, що треба перевіряти скасування. А де саме?» І це хороше запитання, тому що перевірка скасування в кожному рядку коду — це як застібати ремінь безпеки, заходячи в ліфт: формально безпечніше, але виглядає дивно.
Практичне правило таке: перевіряйте скасування там, де ви або довго обчислюєте без await, або збираєтеся почати помітний шмат роботи, який уже не має сенсу продовжувати, якщо результат не потрібен. У structured concurrency це сформульовано прямо: більшість функцій можуть покладатися на нижчі операції, які самі перевіряють скасування, але чисто обчислювальні цикли мають перевіряти його вручну.
Невелика таблиця-шпаргалка для орієнтиру:
| Ситуація | Що відбувається без перевірок | Що зробити |
|---|---|---|
| Довгий for з обчисленнями (без await) | скасування не помічається, цикл крутиться до кінця | вставити або раз на «порцію» |
| Цикл «опрацьовую елементи масиву/файлу/рядків» | після скасування ви продовжуєте марно витрачати час | перевіряти скасування перед обробкою чергового елемента |
| У функції багато await перед «довгими» операціями | часто скасування спрацьовує автоматично через CancellationError | інколи достатньо do/catch на межі, але явна перевірка теж підійде |
| Ви хочете мʼяку зупинку з частковим результатом | throw скине результат | використовуйте і return частковий результат |
Ментальна модель: прапорець скасування і реакція коду
Дуже допомагає тримати в голові просту блок-схему: скасування — це не виняток «з повітря», а прапорець, який встановлюється, а далі ваш код або звертає на нього увагу, або ні. Це дає змогу розуміти поведінку застосунку без містики і без запитання: «Чому воно інколи скасовується, а інколи ні?»
flowchart TD
A["Хтось викликає task.cancel()"] --> B[У задачі встановлюється прапорець cancelled]
B --> C{Код перевіряє скасування?}
C -- ні --> D[Задача продовжує роботу]
C -- так --> E{Як перевіряємо?}
E -- Task.isCancelled --> F[return / break / частковий результат]
E -- Task.checkCancellation() --> G[throw CancellationError]
G --> H[catch is CancellationError на межі сценарію]
Ця схема відповідає ідеї structured concurrency: скасування встановлює стан і запускає обробку лише тоді, коли код його помічає та реагує на нього — зазвичай, викидаючи CancellationError або повертаючись раніше.
7. Типові помилки
Помилка №1: очікувати, що cancel() миттєво «вбʼє» задачу.
Таке очікування зазвичай походить зі світу потоків і жорсткого скасування. У Swift cancellation кооперативне: поки ваш код не перевірить скасування, він може продовжувати працювати. Це особливо помітно в довгих CPU-циклах без await. Правильне виправлення — додати перевірки в місцях, де робота довга й монотонна.
Помилка №2: перевірити Task.isCancelled, але нічого не зробити.
Іноді в коді зʼявляється перевірка для галочки, але після неї немає ні return, ні break, ні зміни поведінки. У результаті скасування «виявлено», але проігноровано. Перевірка має приводити до реального завершення роботи або переходу в безпечний режим.
Помилка №3: викликати Task.checkCancellation() у функції без throws.
Task.checkCancellation() викидає CancellationError, отже функція, що викликає її, має бути throws, а виклик — із try. Якщо ви не хочете змінювати контракт функції, використовуйте Task.isCancelled і виходьте через return. По суті, це вибір: «скасування як помилка» або «скасування як штатне завершення».
Помилка №4: ловити скасування як звичайну помилку і лякати користувача.
Якщо ви в catch друкуєте ERROR для всього підряд, користувач побачить скасування як збій застосунку. Часто скасування — це нормальний сценарій керування життєвим циклом задачі: «не потрібно». Тому корисно відокремлювати catch is CancellationError і писати спокійніше повідомлення. Це відповідає загальній ідеї, що скасування — окремий сценарій завершення, а не обовʼязково «поломка».
Помилка №5: занадто часті перевірки скасування в гарячій ділянці.
Перевірка скасування дешева, але не безкоштовна, і головне — вона засмічує код. Якщо ви вставите try Task.checkCancellation() у найвнутрішніший цикл, який виконується мільйони разів, ви можете отримати зайві накладні витрати і погіршити читабельність. Зазвичай краще перевіряти скасування «порціями»: раз на елемент масиву, раз на тисячу ітерацій, перед дорогою операцією, перед записом результату.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ