JavaRush /Курси /Swift SELF /Скасування — Task.isCancelled

Скасування — Task.isCancelled

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

1. Вступ

Якщо ви колись натискали «Скасувати» під час завантаження файлу, закривали сторінку, поки вона «думає», або надсилали запит на кшталт «Погодьтеся, я передумав», ви вже інтуїтивно розумієте, навіщо потрібне скасування. В асинхронному світі ми постійно запускаємо операції, результат яких з’явиться пізніше, але інколи він уже не потрібен. Наприклад, користувач увів інший запит, а ми ще обчислюємо старий.

У Swift cancellation (скасування) — це не «вбивство» потоку і не зупинка застосунку. Це сигнал задачі: «результат більше не потрібен». Саме так це формулюють у рекомендаціях щодо адаптації concurrency: скасування означає, що той, хто чекає на результат, більше не зацікавлений у ньому. Далі починається важлива частина: задача має сама чемно відреагувати на цей сигнал — припинити роботу й повернути керування.

Щоб не було відчуття магії, зафіксуймо одне речення, яке варто роздрукувати й повісити над монітором: скасування в Swift кооперативне — якщо код не перевіряє скасування, він може продовжувати працювати, ніби нічого не сталося.

Кооперативне скасування в Swift

На перший погляд, скасування виглядає просто: у вас є Task (посилання на задачу), ви викликаєте task.cancel(), і «ніби» все має зупинитися. Але всередині модель влаштована трохи хитріше — і це добре, бо забезпечує передбачуваність.

Коли задачу скасовують, у ній встановлюється внутрішній прапорець cancelled. Цей прапорець, одного разу встановлений, не скидається. Далі все залежить від того, чи перевірить код цей прапорець і що він зробить, якщо побачить скасування.

Swift спеціально робить скасування таким «ненасильницьким», тому що примусова зупинка майже завжди перетворюється на хаос: ресурси не звільнено, стан напівзмінений, файл записано наполовину, а індекс бібліотеки тепер нагадує абстрактне мистецтво. Тому стратегія проста: скасування — це сигнал, а коректна реакція на нього — наша відповідальність.

На практиці у нас є два основні інструменти:

Інструмент Що робить Коли зручно
Task.isCancelled
повертає Bool — чи скасовано поточну задачу коли ви не хочете або не можете викидати виняток і віддаєте перевагу return або «частковому результату»
Task.checkCancellation()
якщо задачу скасовано, викидає 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) скасування не помічається, цикл крутиться до кінця вставити
Task.isCancelled
або
try Task.checkCancellation()
раз на «порцію»
Цикл «опрацьовую елементи масиву/файлу/рядків» після скасування ви продовжуєте марно витрачати час перевіряти скасування перед обробкою чергового елемента
У функції багато await перед «довгими» операціями часто скасування спрацьовує автоматично через CancellationError інколи достатньо do/catch на межі, але явна перевірка теж підійде
Ви хочете мʼяку зупинку з частковим результатом throw скине результат використовуйте
Task.isCancelled
і 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() у найвнутрішніший цикл, який виконується мільйони разів, ви можете отримати зайві накладні витрати і погіршити читабельність. Зазвичай краще перевіряти скасування «порціями»: раз на елемент масиву, раз на тисячу ітерацій, перед дорогою операцією, перед записом результату.

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