1. Чому Swift 6 узагалі втручається у вашу конкурентність
Якщо писати конкурентний код — кілька Task, паралельні запити, фонові обчислення — легко опинитися в ситуації, коли два завдання майже одночасно торкаються однієї й тієї самої памʼяті. Це називається data race, і проблема в тому, що баг може проявитися раз на тиждень та ще й саме перед демо. Swift 6 намагається ловити такі речі на етапі компіляції, вводячи сувору перевірку конкурентної безпеки: які значення можна переносити між конкурентними контекстами, а які — ні.
Корисна модель для новачка така: уявіть, що в кожного завдання є «кімната», а в актора теж є своя «кімната». Значення можна переносити між кімнатами лише тоді, коли вони або безпечно копіюються, або гарантовано незмінні, або захищені ізоляцією. Протокол Sendable — це своєрідний «паспорт», який каже компілятору: «Так, цю річ можна переносити між кімнатами».
2. Sendable: що це таке і де компілятор його вимагає
Sendable — це маркерний протокол, тобто протокол без методів, який означає: значення типу можна безпечно передавати між конкурентними контекстами. На практиці це стає помітно там, де ви перетинаєте межу конкурентності: наприклад, надсилаєте дані іншому акторові або запускаєте роботу, яка може виконуватися паралельно.
Найтиповіший момент, коли ви бачите Sendable «в дії», — це cross-actor виклики. Якщо акторний метод приймає якийсь параметр, то під час виклику цього методу ззовні актора Swift має переконатися, що параметр можна передати безпечно. Наприклад, String зазвичай можна, а NSMutableString (зі світу Objective‑C) — уже небезпечніше, бо це змінний посилальний об’єкт.
import Foundation
actor NotesActor {
func save(title: String) {
print("Збережено:", title) // Збережено: ...
}
}
// Демонстрація ідеї помилки (див. коментар нижче)
func demo(actor: NotesActor, bad: NSMutableString) async {
// await actor.save(title: bad)
// ❌ У дусі діагностики Swift: NSMutableString не є Sendable,
// тому його не можна передавати через межу актора.
}
Важливо відчути саму ідею: компілятор не «шкодить», а намагається запобігти ситуації, коли два завдання тримають посилання на один і той самий змінний об’єкт і змінюють його без координації.
3. Як Swift розуміє, що struct / enum — Sendable
Зазвичай із value-типами, тобто struct і enum, усе добре, бо вони копіюються «за значенням». Але самого копіювання ще недостатньо для безпеки: якщо всередині struct заховано посилання на змінний об’єкт, ви все одно можете випадково розділити спільну памʼять. Тому правило компілятора для struct/enum просте й доволі суворе: усі збережені властивості (і associated values в enum) мають бути Sendable, інакше ваш тип не вважається безпечним.
Для стандартної бібліотеки це майже завжди працює «з коробки»: Int, String, Optional, Array, Dictionary і багато інших типів або є Sendable, або стають Sendable за умови, що їхні елементи теж Sendable (умовні відповідності).
Невелика таблиця для орієнтиру:
| Тип | Чому зазвичай є Sendable | Нюанс |
|---|---|---|
|
value-типи без спільного стану | майже завжди безпечні |
|
«контейнер» | Sendable, якщо T: Sendable |
|
value-колекції | Sendable, якщо елементи Sendable |
|
композиція полів | поля теж мають бути Sendable |
Привʼяжімо це до нашого навчального CLI-проєкту LibraryCLI. Якщо ми хочемо передавати «знімки» книжок між завданнями — наприклад, паралельно завантажили кілька книжок, а потім віддали їх на виведення, — нам дуже зручно мати Sendable-модель.
import Foundation
struct BookSnapshot: Sendable {
let id: String
let title: String
let author: String
}
А тепер приклад, де все ламається на інтуїтивному рівні. Уявімо, що ви випадково поклали всередину struct посилальний змінний об’єкт:
import Foundation
final class MutableBox {
var value: Int = 0
}
struct CounterState {
let box: MutableBox
}
// Якщо ви спробуєте зробити CounterState: Sendable,
// компілятор буде проти: всередині заховано mutable class.
Сенс перевірки такий: навіть якщо зовнішній тип виглядає як value, усередині ви протягнули посилання — отже, дві копії CounterState насправді розділять один і той самий MutableBox.
Важливий нюанс про «автоматично Sendable» і публічні типи
Swift намагається економити вам час: деякі непублічні типи можуть отримувати Sendable неявно, якщо всі поля безпечні. Але для публічних API усе обережніше: оголосити тип Sendable — означає пообіцяти клієнтам, що ви не додасте всередину щось не-Sendable пізніше (це пов’язано зі стійкістю ABI та резилієнтністю API). Тому публічні не-frozen типи зазвичай вимагають явного : Sendable.
4. Sendable і class: чому посилальні типи — зона уваги
Із посилальними типами (class) філософія інша: клас за замовчуванням — це «спільний об’єкт», який можуть розділяти завдання. А shared mutable state — це саме те, чого ми намагаємося уникати. Тому компілятор ставиться до class обережно: перевірюваний (тобто реально перевірюваний компілятором) випадок для Sendable у класів доволі вузький. Зазвичай це final class, у якому всі stored-властивості — let і теж Sendable.
Ось приклад об’єкта конфігурації, який можна безпечно роздати між завданнями:
import Foundation
final class AppConfig: Sendable {
let apiHost: String
let timeoutSeconds: Int
init(apiHost: String, timeoutSeconds: Int) {
self.apiHost = apiHost
self.timeoutSeconds = timeoutSeconds
}
}
Чому це виглядає безпечним? Бо після ініціалізації об’єкт не змінюється. Якщо два завдання читають один і той самий AppConfig, вони не змагаються за право «хто перший запише» — записів немає.
А ось приклад, де компілятор був би правий, якби почав сваритися, навіть якщо вам здається: «Та я ж обережно!»:
import Foundation
final class MutableSettings {
var mode: String = "default" // змінний стан
}
// Ідея проблеми: MutableSettings не можна чесно назвати Sendable,
// бо один екземпляр можна змінювати з різних завдань.
Майже завжди правильний дизайн тут такий: або ви перетворюєте це на value-тип, або ховаєте змінний клас усередину актора, щоб назовні не виходило «живе» посилання.
5. @Sendable-замикання і перевірка захоплень
Із замиканнями все стає ще цікавіше, бо замикання — це не просто код, а код плюс захоплені значення. І якщо замикання може виконуватися конкурентно, наприклад у Task.detached, то все, що воно «затягне» із собою, теж має бути безпечно переносним між конкурентними контекстами.
Саме тому в моделі Swift concurrency є поняття @Sendable closure: це замикання, яке дозволено передавати до конкурентного запуску, і компілятор перевіряє його захоплення. Додатково важливо, що @Sendable-замикання вважається non-isolated: воно не може нишком користуватися акторним станом без await.
Порівняйте дві ситуації.
Перша: ви створюєте Task.detached, і там замикання потенційно виконується паралельно з усім світом.
import Foundation
func runDetached(_ work: @Sendable @escaping () -> Void) {
Task.detached {
work()
}
}
Друга: ви перебуваєте всередині актора і викликаєте алгоритм, який гарантує послідовний виклик замикання, наприклад forEach. Тоді замикання може лишатися не-@Sendable, тобто акторно-ізольованим.
import Foundation
actor BatchCounter {
private var value: Int = 0
func addAll(_ xs: [Int]) async {
xs.forEach { x in
value += x
}
print("сума =", value) // сума = ...
}
}
У цьому коді forEach викликає замикання послідовно, і тому воно не зобов’язане бути @Sendable (у термінах моделі — воно лишається actor-isolated).
Типовий «біль новачка»: захоплення self у конкурентному замиканні
Найчастіша історія така: у вас є якийсь клас, наприклад сервіс або контролер, і ви робите Task.detached { ... }, а всередині пишете щось на кшталт self.something += 1. Компілятор починає сваритися, бо self — посилальний, змінний, і його захоплення у @Sendable-замиканні виглядає як «винесли shared mutable state у паралельний потік».
import Foundation
final class Stats {
var requests: Int = 0
}
func demoRace(stats: Stats) {
Task.detached {
// stats.requests += 1
// ❌ Ідея помилки: Stats не є Sendable, а захоплення потенційно небезпечне.
}
}
Правильне виправлення майже ніколи не полягає в тому, щоб «поставити @unchecked і забути». Правильне виправлення — змінити дизайн: або ізолювати Stats актором, або передавати в detached-завдання лише snapshot, наприклад число, рядок, масив value-типів.
6. Мапа перевірок і мініприклад у LibraryCLI
Коли ви ввімкнули сувору перевірку конкурентності, а Swift 6 language mode майже напевно до цього приведе, компілятор починає перевіряти кілька типів ситуацій: багато речей стають помилками, а не попередженнями.
Щоб було простіше орієнтуватися, тримайте в голові таку «мапу» без спроби вивчити всі тонкощі одразу.
flowchart TD
A[Десь зʼявився паралелізм] --> B{Що переносимо між завданнями й акторами?}
B -->|Значення / аргумент / результат| C[Потрібен Sendable]
B -->|Замикання для конкурентного запуску| D[Потрібен @Sendable]
C --> E{Тип — value?}
E -->|struct / enum| F[Усі поля мають бути Sendable]
E -->|class| G[Зазвичай final + let, інакше складно]
D --> H["Перевіряються захоплення (що замикання 'затягло')"]
Тут важлива не схема заради схеми, а думка: Swift 6 «ловить» помилки там, де ви робите перенесення через межу конкурентності: cross-actor виклик, detached-завдання і будь-які API, які обіцяють «виконаю це десь потім і, можливо, паралельно».
У нашому CLI-застосунку LibraryCLI ми вже робили речі, які за змістом є конкурентними: паралельні мережеві запити, частковий успіх, кешування, індексування. Чим далі, тим сильнішою буде спокуса «просто розшарити один об’єкт на всіх і змінювати його». Swift 6, на щастя, поступово відучуватиме вас від цього — іноді через біль компілятора, але все ж.
Уявімо простий шматок нашого застосунку: після команди fetch-many ми хочемо зібрати результати й віддати вгору структуру для друку звіту. Нам зручно зробити звіт Sendable, щоб його можна було збирати хоч у паралельних завданнях, хоч в акторі, хоч на одному потоці — компілятор буде спокійний.
import Foundation
struct FetchReport: Sendable {
let successIDs: [String]
let failedIDs: [String]
}
І тепер маленький, але дуже практичний прийом: зробити «ключі та значення кешу» Sendable-типами (зазвичай це рядки, числа і прості структури). Тоді кеш, який ми ізолюємо актором, зможе приймати й віддавати дані без конфліктів із компілятором.
import Foundation
struct CacheKey: Sendable, Hashable {
let value: String
}
struct CachedText: Sendable {
let body: String
let savedAt: Date
}
Зверніть увагу на стиль: жодних посилальних mutable-об’єктів назовні. Ми будуємо маленькі value-сутності, які легко переносити, тестувати й друкувати. І так, Date у цій моделі — теж value-представлення «точки в часі», тому він природно лягає у snapshot.
7. Типові помилки
Помилка №1: «Але це ж просто struct, чому він не Sendable?»
Найчастіше всередині struct опинився посилальний змінний об’єкт, навіть випадково: через class, через Objective‑C тип, через якусь «коробку» для мутабельності. Компілятор не дивиться на слово struct як на магічне заклинання — він дивиться на те, що реально лежить усередині.
Помилка №2: «Я зроблю Task.detached, а всередині торкнуся self — що може піти не так?»
Проблема в тому, що detached-завдання не поважає вашу «кімнату» — актор, потік чи контекст — і може виконуватися паралельно з іншим кодом. Тому @Sendable там обов’язковий, і компілятор починає перевіряти захоплення. Захоплення mutable self — майже завжди червоний прапорець.
Помилка №3: «Я поставлю Sendable на class, і все.»
Для класів безпечний випадок обмежений: зазвичай це final і лише let-властивості, які самі Sendable. Якщо в класу є var, компілятор не може довести, що ви не будете змінювати його з різних завдань. Умовно кажучи, ви просите компілятор повірити вам на слово, а Swift 6 якраз намагається не вірити.
Помилка №4: «Чому forEach усередині актора працює без @Sendable, а Task.detached — ні?»
Тому що це різні гарантії. Non-@Sendable-замикання, створене всередині актора, може лишатися акторно-ізольованим і виконуватися послідовно, наприклад як частина алгоритму. А Task.detached вимагає @Sendable, і таке замикання вважається non-isolated: воно не може звертатися до акторного стану без await. Це не примха, а спосіб не допустити data race.
Помилка №5: «У Swift 5 це було попередженням, а в Swift 6 стало помилкою — чому?»
Тому що Swift 6 посилює режими перевірки конкурентності: те, що раніше можна було «протиснути» як warning, тепер частіше зупиняє збирання, щоб ви не відправили гонку даних у prod. У міграційних документах це описується як перехід до суворої перевірки та поступова адаптація модулів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ