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” | Що зазвичай робити |
|---|---|---|
|
мутабельний форматер, історично не потокобезпечний | тримати всередині actor, назовні віддавати String |
|
класи, всередині налаштування й кеш | тримати всередині actor/сервісу, назовні віддавати DTO/Data |
|
посилальний об’єкт I/O | тримати в одному місці (actor/сервіс), назовні віддавати Data/String/результат |
|
мутабельні структури ObjC без гарантій | не пересилати; за потреби — копіювати або ізолювати |
|
модуль іще не позначено під concurrency | тимчасово @preconcurrency import, але лікувати причину |
І тут важливий психологічний момент: ваше завдання — не «змусити компілятор замовкнути», а побудувати межі так, щоб небезпечні речі не просочувалися в конкурентний світ.
3. Базовий прийом: ізоляція + snapshot замість «живого посилання»
Якщо коротко, найздоровіший шлях такий: не-Sendable живе всередині актора, а назовні виходить лише 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 уже є логування і компоненти зі станом: кеш, репозиторій, індекс. На практиці не-Sendable часто виринає саме там: репозиторій хоче JSONDecoder, мережевий шар хоче певні налаштування, логер — форматери.
Корисне правило для проєкту: усе, що схоже на службовий об’єкт (форматер, декодер, file-handle) — ховаємо всередину актора або сервісу, який і так відповідає за ізоляцію.
Snapshot замість «живого об’єкта»
Зараз буде важлива думка, яка економить години дебагу. Навіть якщо ви ізолювали стан актором, ви можете випадково видати назовні посилання на об’єкт, який потім хтось почне змінювати. І тоді ви формально «дотрималися актора», але фактично винесли спільний змінний стан назовні. Це як поставити сейф, а ключ залишити під килимком.
Тому часто ми повертаємо назовні не внутрішні об’єкти, а знімки: value-типи, які легко передавати.
Уявімо, що всередині нашого домену книга вже зберігається як struct Book — це ідеально. Але іноді новачки починають робити class Book, бо «так звичніше», а потім хочуть повернути масив книг назовні з актора — і компілятор починає підозріло мружитися.
Зробімо усвідомлений «знімок»:
import Foundation
struct BookSnapshot: Sendable {
let id: UUID
let title: String
let author: String
}
Чому це добре: це value-тип, а його поля очевидно пересилаються. Такий об’єкт можна безпечно повернути з актора й використовувати в паралельних задачах.
«Реальний об’єкт» vs «знімок»: мінісхема
flowchart TD
A[Актор: зберігає стан] -->|назовні| B["Знімок (struct, Sendable)"]
A -->|НЕ робимо| C[Посилання на мутабельний 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, у міжакторному виклику, іноді навіть під час повернення значення з 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, компілятор мав би рацію: це спільний змінний стан.
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) // друкуємо копію значення, а не звертаємося до спільного об’єкта
}
}
Так, це звучить просто. І так, найчастіше це і є правильна відповідь: перенесіть через межу лише дані, а не джерело даних.
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 та індекс), мережевий шар (кеш/лімітер), логування (форматування, збирання контексту). І в кожному місці є спокуса «протягнути» об’єкт між задачами.
Робоча стратегія виглядає так: ви тримаєте не-Sendable-деталі всередині акторів або всередині послідовного шару, а назовні віддаєте прості значення. Наприклад, JSONDecoder живе всередині актора репозиторію і не виходить назовні, а назовні виходить або доменна модель (value-type), або BookSnapshot. Форматери та будь-які «об’єкти налаштувань» теж сидять всередині акторів, а назовні виходять лише рядок або дані.
І лише якщо у вас є сторонній модуль, який ви не можете зараз виправити, і він сипле діагностикою, ви тимчасово вмикаєте @preconcurrency import — як перехідник, а не як фінальне рішення.
9. Типові помилки
Помилка № 1: «Зроблю @unchecked Sendable — і все пройде».
Так справді часто «проходить», але це схоже на те, як вимкнути пожежну сигналізацію, бо вона занадто голосно пищить. @unchecked Sendable має бути рідкісним і виправданим: ви або ізолювали об’єкт (наприклад, він повністю живе всередині актора і назовні не виходить як посилання), або зробили незмінний, справді безпечний контейнер. Якщо ви поставили @unchecked на перший-ліпший клас із var-полями, то ви не розв’язали проблему, а відклали її на потім.
Помилка № 2: «Раз актор — значить усе, що він повертає, безпечно».
Актор захищає свій стан лише доти, доки він усередині. Якщо актор повертає назовні посилання на мутабельний об’єкт, ви фактично винесли стан за периметр охорони. Це особливо підступно, бо код виглядає «архітектурно правильним»: є actor, є await, усе красиво. А гонка з’являється вже в коді, який отримав посилання.
Помилка № 3: Плутати «копіювання масиву» з «копіюванням даних».
Колекції у Swift копіюються як значення, але якщо всередині лежать посилання на об’єкти, то ви копіюєте посилання. У конкурентності це важливо: ви могли думати, що «передав копію», а насправді передали спільний об’єкт. Тому snapshot-підхід (перетворити в struct із простими полями) часто надійніший за будь-які міркування про те, «скопіювалося чи ні».
Помилка № 4: Забувати, що Task.detached — найвибагливіший до безпечних захоплень.
Task { ... } часто успадковує контекст і виглядає м’якшим, а Task.detached — це явний вихід у паралельний світ, де замикання зобов’язане бути @Sendable, і компілятор починає перевіряти захоплення максимально суворо. Якщо вам не потрібне повне від’єднання, не використовуйте detached лише тому, що слово красиве.
Помилка № 5: Вважати @preconcurrency import рішенням безпеки.
Цей імпорт вирішує проблему міграції та шуму діагностики, але не робить код потокобезпечним. Якщо ви додали @preconcurrency import, а потім почали активно передавати об’єкти з цього модуля між задачами, ви, найімовірніше, просто вимкнули собі ремінь безпеки. Правильне застосування — тимчасово спростити перехід, а потім усе одно вибудувати ізоляцію, snapshots і нормальні межі відповідальності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ