1. Почему «не Sendable» — это подсказка, а не придирка
Когда вы впервые включаете строгую проверку конкурентности (в Swift 6 это фактически становится нормой), ощущения часто такие: «Я всего лишь хотел сделать Task { ... }, а мне компилятор устроил собеседование на позицию “архитектор многопоточности”». Это нормально. Swift здесь ведёт себя как очень занудный друг: он не просто говорит «нельзя», он пытается не дать вам случайно сделать гонку данных.
Ключевой смысл простой: конкурентность — это когда разные задачи могут выполняться «почти одновременно». Если вы передаёте в другую задачу ссылочный изменяемый объект (типичный class из Foundation), вы потенциально передаёте общую мутабельную память, а это главный источник гонок. Поэтому Swift вводит дисциплину Sendable: через границу конкурентных контекстов можно переносить только то, что безопасно переносить.
Чтобы мозг не перегревался, держите в голове короткую формулу:
«Sendable — это не “тип хороший/плохой”. Это “тип безопасно пересылать между задачами/актёрами”.»
2. Откуда берутся не-Sendable типы в реальном проекте
Когда читаешь учебные примеры, почти всё выглядит Sendable: Int, String, [String], ваши struct с let-полями. Но как только вы возвращаетесь в реальную жизнь (то есть в Foundation и в «библиотеки, которые написаны не вами»), вы встречаете типы, которые:
- ссылочные (class),
- внутри имеют изменяемое состояние,
- не гарантируют потокобезопасность.
Swift не может «доказать», что такой тип безопасен, поэтому он не будет считаться Sendable по умолчанию. Для классов вообще есть очень узкое правило, когда компилятор готов поверить: final class + только неизменяемые stored properties (let) Sendable-типов.
Чтобы было приземлённее, вот табличка «частые гости» в CLI/серверном Swift-коде и что с ними делать.
| Пример типа | Почему часто “не Sendable” | Что обычно делать |
|---|---|---|
|
мутируемый форматтер, исторически не thread-safe | держать внутри actor, наружу отдавать String |
|
классы, внутри настройки/кэш | держать внутри actor/сервиса, наружу отдавать DTO/Data |
|
ссылочный объект I/O | держать в одном месте (actor/сервис), наружу отдавать Data/String/результат |
|
мутабельные структуры ObjC без гарантий | не пересылать; при необходимости — копировать или изолировать |
|
модуль ещё не размечен под concurrency | временно @preconcurrency import, но лечить причину |
И здесь важный психологический момент: ваша задача — не «заставить компилятор замолчать», а построить границы так, чтобы «опасные штуки» не утекали в конкурентный мир.
3. Базовый приём: изоляция + snapshot вместо «живой ссылки»
Если кратко, самый здоровый путь такой: «несендэбл живёт внутри актора, а наружу выходит только Sendable-результат». Это совпадает с моделью актёров: актёр и так создан, чтобы охранять доступ к состоянию.
Мини‑пример: форматирование дат для логов
Нам нужен форматтер для логов. Форматтер — class, а классы с состоянием обычно не хочется шарить между задачами. Поэтому делаем actor LogDateFormatter: внутри держим DateFormatter, а наружу отдаём String (а String спокойно пересекает границы актёров, это типичный Sendable-кандидат).
import Foundation
actor LogDateFormatter {
private let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
func format(_ date: Date) -> String {
formatter.string(from: date)
}
}
Обратите внимание: наружу мы не отдаём DateFormatter. Мы даже не говорим миру, что он существует. Мир получает строку — и всем спокойно.
Привязка к проекту LibraryCLI
В нашем LibraryCLI уже есть логирование и компоненты со state: кэш/репозиторий/индекс. На практике «несендэбл» часто вылезает именно там: репозиторий хочет JSONDecoder, сеть хочет какие-то конфиги, логгер хочет форматтеры.
Хорошее правило для проекта: всё, что похоже на «обслуживающий объект» (форматтер, декодер, файл‑хендл) — прячем внутрь актора или сервиса, который и так отвечает за изоляцию.
Snapshot вместо «живого объекта»
Сейчас будет важная мысль, которая экономит часы дебага. Даже если вы изолировали состояние актёром, вы можете случайно выдать наружу ссылку на объект, который потом кто-то начнёт менять. И тогда вы формально «соблюли актёра», но фактически вынесли shared mutable state наружу. Это как поставить сейф, а ключ оставить под ковриком.
Поэтому часто мы возвращаем наружу не внутренние объекты, а снимки: value-типы, которые легко передавать.
Представим, что внутри нашего домена книга уже хранится как struct Book (это идеально). Но иногда новички начинают делать class Book, потому что «так привычнее», а потом хотят вернуть массив книг наружу из актёра — и компилятор начинает подозрительно щуриться.
Сделаем осознанный «снимок»:
import Foundation
struct BookSnapshot: Sendable {
let id: UUID
let title: String
let author: String
}
Почему это классно: это value-тип, поля — очевидно пересылаемые. Такой объект можно безопасно вернуть из актора и использовать в параллельных задачах.
«Реальный объект» vs «снимок»: мини‑схема
flowchart TD
A[Actor: хранит состояние] -->|наружу| B["Snapshot (struct, Sendable)"]
A -->|НЕ делаем| C[Ссылка на mutable class]
B --> D[Task / async let / TaskGroup]
C --> E[Гонки данных и грусть]
Смысл: через границу конкурентности мы стараемся пропускать либо неизменяемые значения, либо копии/снимки.
4. Копирование значений: что вы реально копируете в Swift
На этом месте обычно случается путаница: «Но у меня же struct — значит копируется всегда?». В Swift value semantics действительно означает, что вы работаете с значениями, но под капотом стандартные коллекции используют Copy-on-Write: пока вы не мутируете — реальной копии памяти может не быть.
В контексте конкурентности это звучит так: «передавать массив можно, но важно, что внутри массива, и важно, не тащите ли вы туда ссылку на мутабельный объект».
Пример: массив как значение (и почему содержимое важно)
import Foundation
let titles: [String] = ["Dune", "Solaris"]
let copy = titles
print(titles.count) // 2
print(copy.count) // 2
Этот пример скучный, но полезный: вы «скопировали» массив, но он всё равно безопасен, потому что String — value-like тип, и по смыслу вы не делите общую мутабельную ссылку между задачами.
А теперь мысленный эксперимент: если внутри массива лежит экземпляр class, то копирование массива копирует ссылки, а не «объекты». И вот это уже конфликтует с Sendable-идеей.
Приём: «копировать в безопасный контейнер»
Часто правильная стратегия: из «богатого» объекта сделать простую, безопасную проекцию. В репозитории вы храните что угодно, но наружу отдаёте только то, что реально нужно.
Например, актёр-репозиторий хранит внутреннюю структуру, а метод list() возвращает [BookSnapshot], а не «внутренние сущности репозитория».
5. Где именно всплывает проблема и почему ругаются на «невинный» код
Один из самых раздражающих моментов: ошибка возникает не там, где вы создали объект, а там, где вы его переслали: в Task.detached, в TaskGroup.addTask, в cross-actor вызове, иногда даже при возвращении значения из async-функции.
Это ровно то, что описывается в правилах про @Sendable-замыкания: Task.detached требует @Sendable operation, и значит компилятор проверяет, что замыкание не утащило не‑Sendable-значения.
Мини‑пример: «поймал несендэбл захват»
import Foundation
final class Box {
var value: Int = 0
}
func demo() {
let box = Box()
Task.detached {
// Если бы box считался не-Sendable, компилятор был бы прав: это shared mutable state.
box.value += 1
}
}
Идея не в том, что Box плохой. Идея в том, что detached означает: «эта задача живёт своей жизнью и может бежать параллельно», поэтому любые захваты должны быть безопасными. Когда компилятор запрещает — он фактически говорит: «ты сейчас собираешь гонку данных своими руками».
Как лечить: захватываем снимок, а не объект
import Foundation
final class Box {
var value: Int = 0
}
func demoFixed() {
let box = Box()
let snapshot = box.value
Task.detached {
print(snapshot) // печатаем копию значения, а не лезем в shared объект
}
}
Да, это звучит просто. И да, чаще всего это и есть правильный ответ: «перенеси через границу только данные, а не источник данных».
6. @unchecked Sendable: когда это допустимо
Иногда вам действительно нужно передать тип, который компилятор не может проверить, но вы гарантируете безопасность архитектурно. Для таких случаев существует @unchecked Sendable. Важно прочитать это буквально: «unchecked» = «непроверяемо», ответственность ваша.
Практическое правило курса: если вы можете решить задачу актёром + snapshot — решайте актёром + snapshot. @unchecked Sendable оставляем на крайний случай и используем точечно, потому что иначе вы превращаете строгую проверку в декоративную наклейку.
Мини‑обёртка (идея, которую лучше не применять без необходимости)
import Foundation
struct UnsafeSendableBox<T>: @unchecked Sendable {
let value: T
}
Эта штука компилируется, но смысл её опасен: вы только что сказали компилятору «верь мне на слово». Пользуйтесь этим только если вы реально обеспечили изоляцию и понимаете, почему гонки не будет.
7. @preconcurrency import: как жить с legacy‑модулями в Swift 6
Теперь про ещё одну штуку, которая часто выглядит как магическое заклинание: @preconcurrency import SomeModule.
Сценарий обычно такой: вы включили строгую конкурентность, импортировали модуль (часто это старый код или сторонняя библиотека), и внезапно получаете тонну ошибок/варнингов — хотя вы ещё даже не написали конкурентный код поверх него. Swift даёт механизм постепенной миграции: @preconcurrency import временно ослабляет диагностику для импортируемого модуля, чтобы вы могли двигаться по проекту не «всё или ничего».
Что делает @preconcurrency import по смыслу
Он не делает код безопасным. Он делает код компилируемым, переводя часть ошибок в предупреждения и подавляя некоторые диагностики, пока модуль не размечен аннотациями конкурентности.
Представьте, что вы переезжаете в новую квартиру (Swift 6), а у вас коробки (legacy‑модули). @preconcurrency import — это не «я распаковал коробки», это «я временно сложил коробки в кладовку, чтобы можно было ходить по комнате».
Мини‑пример использования
@preconcurrency import LegacyStorage
import Foundation
// Дальше ваш код может компилироваться,
// даже если LegacyStorage ещё не размечен под строгую конкурентность.
Жизненный цикл @preconcurrency import
Сначала вы видите ошибки → ставите @preconcurrency import, чтобы продолжить работу → постепенно исправляете реальные проблемы в своём коде → однажды модуль обновляется и становится concurrency-friendly → компилятор начинает подсказывать, что @preconcurrency import больше не нужен → вы его удаляете.
8. Как применить это в LibraryCLI без «аннотационного ада»
В нашем CLI-приложении у нас есть как минимум три места, где встречается «опасное состояние»: репозиторий (хранение JSON и индекс), сетевой слой (кэш/лимитер), логирование (форматирование, сбор контекста). И в каждом месте есть соблазн «протащить» объект между задачами.
Рабочая стратегия выглядит так: вы держите несендэбл детали внутри актёров или внутри последовательного слоя, а наружу отдаёте простые значения. Например, JSONDecoder живёт внутри актора репозитория и не выходит наружу, а наружу выходит либо доменная модель (value-type), либо BookSnapshot. Форматтеры и любые «настройки‑объекты» тоже сидят внутри актёров, а наружу выходит строка/данные.
И только если у вас есть сторонний модуль, который вы не можете сейчас исправить, и он сыпет диагностикой, вы временно включаете @preconcurrency import — как «переходник», а не как финальное решение.
9. Типичные ошибки
Ошибка №1: “Сделаю @unchecked Sendable и всё пройдёт”.
Так действительно часто «проходит», но это похоже на то, как отключить пожарную сигнализацию, потому что она слишком громко пищит. @unchecked Sendable должен быть редким и оправданным: вы либо изолировали объект (например, он полностью живёт внутри актора и наружу не выходит как ссылка), либо сделали неизменяемый, реально безопасный контейнер. Если вы поставили @unchecked на первый попавшийся класс с var-полями, то вы не решили проблему, а спрятали её в будущее.
Ошибка №2: “Раз актёр — значит всё, что он возвращает, безопасно”.
Актёр защищает свой state только пока state внутри. Если актёр возвращает наружу ссылку на мутабельный объект, вы фактически вывели состояние за периметр охраны. Это особенно коварно, потому что код выглядит «архитектурно правильным»: есть actor, есть await, всё красиво. А гонка появляется уже в коде, который получил ссылку.
Ошибка №3: Путать “копирование массива” с “копированием данных”.
Коллекции в Swift копируются логически как значения, но если внутри лежат ссылки на объекты, то вы копируете ссылки. В конкурентности это важно: вы могли думать, что «передал копию», а на деле передали общий объект. Поэтому snapshot‑подход (превратить в struct с примитивными полями) часто надёжнее любых рассуждений о том, «скопировалось или нет».
Ошибка №4: Забывать, что Task.detached — самый требовательный к безопасным захватам.
Task { ... } часто наследует контекст и выглядит «мягче», а Task.detached — это явный выход в параллельный мир, где замыкание обязано быть @Sendable, и компилятор начинает проверять захваты максимально строго. Если вам не нужна «полная отвязка», не используйте detached просто потому, что слово красивое.
Ошибка №5: Считать @preconcurrency import решением безопасности.
Этот импорт решает проблему миграции и шума диагностики, но не делает код потокобезопасным. Если вы добавили @preconcurrency import, а потом начали активно передавать объекты из этого модуля между задачами — вы, скорее всего, просто отключили себе ремень безопасности. Правильное применение — временно упростить переход, а затем всё равно выстроить изоляцию, snapshots и нормальные границы ответственности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ