1. Два способа запускать unstructured tasks
Если смотреть на API Swift Concurrency с высоты птичьего полёта, легко задать логичный вопрос: «Зачем мне два способа запустить работу — Task {} и Task.detached {}? Разве нельзя было оставить один, а второй спрятать под ковёр вместе с ! и “быстрыми решениями”?». Увы, жизнь сложнее: иногда нам действительно нужно продолжить работу “как часть текущего контекста”, а иногда — строго “с чистого листа”.
Важная идея дня: оба способа создают unstructured task (задачу, жизнью которой мы управляем через “хэндл”), но они отличаются тем, насколько новая задача “доверяет” окружению, из которого её запустили. В Swift Evolution это описано прямо: unstructured задачи создаются через Task { ... }, а “detached tasks” — это unstructured задачи, которые не наследуют контекст.
Task {}: наследует контекст и ведёт себя как «продолжение истории»
Когда вы пишете Task { ... }, вы создаёте задачу и получаете “ручку управления” — значение типа Task<Success, Failure>. Через неё можно дождаться результата (value), получить результат как Result (result) или отменить задачу (cancel()). Также у Task есть isCancelled.
Но главное для нашей темы — наследование контекста. Task {} старается быть “логическим продолжением” места, где вы её создали. В proposal’е structured concurrency сказано, что unstructured задачи, созданные через initializer 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 =", value) // value = 42
}
Здесь Task {} — это “создал, подождал, прочитал”. Никакой магии — но уже видно: задача живёт отдельно, а мы держим хэндл.
Task.detached {}: независима от контекста и требует больше дисциплины
Task.detached { ... } создаёт задачу другого характера: она полностью независима от контекста, из которого её создали. В structured concurrency proposal это сформулировано максимально прямолинейно: 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 почти всегда означает «не трогай общий mutable state»
Сейчас будет важный кусок инженерной трезвости. Когда новичок узнаёт про Task.detached, рука тянется применить его как “самый настоящий параллелизм”. Проблема в том, что detached‑задача выполняется конкурентно “со всем остальным”, и если внутри неё вы трогаете общий изменяемый объект (shared mutable state), вы почти гарантированно создаёте условия для гонки данных (data race) — даже если “вроде работает” на вашем ноутбуке.
В actor proposal отдельно объясняется, почему это опасно: Task.detached запускает замыкание, которое не является actor-isolated, и поэтому доступ к actor‑изолированным штукам должен быть через await. Это часть механики защиты от гонок.
То есть detached в акторном мире — это как выйти из комнаты, где действует правило “за столом работает только один человек”, и начать тащить оттуда предметы через окно. Может получиться, но точно не в тот день, когда вы хотите стабильности.
Почему гонки с Task.detached всплывают проще, чем с Task {}
В этом разделе важно не перепутать: Task {} тоже может привести к плохим последствиям, если вы создадите хаос с общим состоянием. Но у Task.detached есть две особенности, которые делают ошибки особенно вероятными.
Первая особенность — он обрывает контекст. У вас меньше гарантий “где я выполняюсь” и “какие правила вокруг действуют”. В structured concurrency proposal различие описано как контекстное наследование у Task {} и отсутствие наследования у Task.detached.
Вторая особенность — в detached замыкание подразумевается @Sendable, то есть оно предназначено для потенциально конкурентного выполнения, а значит к захватам применяются более строгие правила. В proposal про Sendable и @Sendable показано, что захваты должны быть безопасными для передачи между concurrency domain’ами, и что некоторые захваты (например, мутируемая локальная переменная) запрещаются.
Мы ещё не изучаем Sendable как отдельную тему (это будет позже), но нам достаточно честной “рамки”: компилятор не просто вредничает — он пытается спасти вас от гонок.
Actor isolation: почему Task {} часто «естественнее» внутри компонентов с правилами
Сейчас аккуратно, без погружения в будущие темы. В actor proposal показан важный пример: внутри актора Task.detached создаёт задачу, замыкание которой не изолировано актором, поэтому доступ к self и его состоянию должен быть асинхронным.
В то же время structured concurrency proposal говорит, что 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("New Book") // ❌ общий mutable state внутри 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 {
"New Book" // только данные наружу
}
let title = await task.value
repo.add(title) // мутация в одном месте, явно
}
Да, здесь всё ещё есть тонкости (например, где именно выполняется repo.add и не трогают ли репозиторий ещё где-то конкурентно). Но ключевая мысль сегодняшней лекции именно такая: в detached не мутируем общий state. Возвращаем “кусок результата”, а сборку и запись делаем в одном понятном месте.
Task {} как управляемая «параллельная работа» в коде команды
Давайте приблизим к реальности курса: у нас есть команда fetch, которая ходит в сеть, получает DTO, маппит в доменную модель и сохраняет в репозиторий. Иногда хочется параллельно сделать две независимые штуки: например, получить “карточку книги” и отдельно получить “обложку” (или дополнительную информацию).
Мы можем запустить две unstructured задачи через Task {} и дождаться их результата через .value. В structured concurrency proposal показано, что Task — это хэндл, у которого есть value и result.
import Foundation
struct BookDTO { let title: String }
struct CoverDTO { let url: String }
func fetchBook() async -> BookDTO { BookDTO(title: "Dune") }
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) // Dune https://example.com/dune.png
}
Заметьте стиль: задачи созданы рядом, результаты “собраны” рядом. Это как раз та читабельность, к которой мы стремимся: точки запуска и точки ожидания должны быть видны.
4. Когда Task.detached оправдан
Task.detached не “запрещён”. Он просто требует дисциплины. Если вы его используете, вы как бы говорите: “я понимаю, что не наследую контекст, и мне это действительно нужно”.
Есть типичный класс сценариев: вы хотите запустить работу, которая не должна зависеть от текущего контекста и не должна трогать общий mutable state. Например, условно “сформировать диагностическую строку” или “посчитать хеш” от неизменяемых входных данных, или сделать чистое преобразование.
Важное уточнение: detached‑задача особенно хорошо себя чувствует, когда внутрь вы передаёте простые неизменяемые значения (числа, строки, маленькие структуры), а наружу возвращаете тоже значения. В proposal про @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 {}
Эта привычка очень экономит нервы. В proposal про async let даже есть прямое сравнение: упоминается, что Task.detached “большую часть времени не должен использоваться”, потому что не пропагирует важный контекст (приоритет, task-local, execution context).
Профессиональный рефлекс такой: начинаем с Task {}. Если всё хорошо — отлично. Если вам реально нужно “оторваться от контекста” (и вы можете это обосновать не словами “так веселее”), тогда думаем про Task.detached, но сразу проверяем себя вопросом: “а я не тащу внутрь общий mutable state?”.
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 возвращает данные, а изменение общего состояния делаем в одном месте и последовательно. Actor proposal показывает, что detached выполняется конкурентно со всем остальным, и поэтому изоляция важна.
Ошибка №3: потерять границы ответственности: кто должен ждать результат и где.
Detached часто пишут как “fire-and-forget”: запустили — и забыли. В результате ошибки теряются, отмена не работает как ожидается, а поведение превращается в “иногда что-то происходит”. Если результат важен — сохраняйте хэндл и явно ждите value или хотя бы result. Сам тип Task и его API как раз под это и спроектированы: value, result, cancel().
Ошибка №4: думать, что раз компилятор не ругается — значит безопасно.
Сегодня мы держимся на дисциплине дизайна: “не тащим shared mutable state”. Позже, на теме Sendable, вы увидите, как компилятор начинает жёстче проверять такие вещи, особенно в @Sendable-замыканиях (и Task.detached как раз из этой зоны). В proposal про Sendable показаны примеры запретов на опасные захваты — это не придирки, а защита от гонок.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ