Вступ
Swift 6.2 приніс, мабуть, найважливішу зміну в моделі конкурентності з часів появи async/await — Approachable Concurrency. Якщо ви, як і я, провели не одну годину, намагаючись приборкати лавину помилок компілятора після увімкнення strict concurrency у Swift 6.0, то ця стаття саме для вас.
Apple фактично визнала: модель конкурентності стала надто складною для більшості розробників. І кардинально змінила підхід.
Тепер ваш код за замовчуванням працює на головному потоці, а конкурентність додається лише тоді, коли вона дійсно потрібна. Це не просто ще один прапорець компілятора — це фундаментальна зміна філософії. Замість «доведи компілятору, що твій код безпечний» ми отримали «код безпечний за замовчуванням, додавай конкурентність поступово». Далі ми розберемо кожну складову Approachable Concurrency — від увімкнення до розв'язання типових помилок міграції — з робочими прикладами коду на кожному кроці.
Що таке Approachable Concurrency і навіщо це потрібно
Ключова ідея Approachable Concurrency — progressive disclosure (поступове розкриття складності). По суті, Swift повинен вимагати від вас розуміння конкурентності лише на тому рівні, на якому ви її фактично використовуєте:
- Перший етап — ви пишете звичайний послідовний код, і він просто працює
- Другий етап — додаєте
async/awaitдля API, які потребують очікування - Третій етап — лише коли профілювання показує необхідність паралелізму, ви використовуєте акторів,
TaskGroupта@concurrent
Звучить логічно, правда? Але до Swift 6.2 реальність була зовсім іншою. Увімкнення strict concurrency перетворювало компіляцію на справжнє поле бою: сотні помилок про Sendable, ізоляцію акторів та перетин контекстів. І це при тому, що фреймворки самої Apple (UIKit, Core Data) не були повністю адаптовані до strict concurrency. Approachable Concurrency вирішує цю проблему, роблячи безпеку за замовчуванням — без зайвих зусиль з боку розробника.
MainActor за замовчуванням (SE-0466)
Отже, найважливіша зміна Swift 6.2 — можливість встановити @MainActor як ізоляцію за замовчуванням для всього коду у вашому таргеті. По суті, це повертає ваш застосунок до однопоточної моделі, де весь код працює на головному потоці, доки ви явно не попросите інше.
Як це працює
Коли ви увімкнете Default Actor Isolation з MainActor, компілятор автоматично додає неявну анотацію @MainActor до всіх декларацій, які не мають явної ізоляції. Що це означає на практиці:
- Ваші класи, структури та enum неявно ізольовані на
@MainActor - Функції та властивості автоматично виконуються на головному потоці
- Щоб вивести код з головного потоку, потрібно явно позначити його як
nonisolatedабо@concurrent
Подивімось, як виглядає типовий код до і після увімкнення:
// До Swift 6.2: треба вручну анотувати все
@MainActor
class ProfileViewModel: ObservableObject {
@Published var userName: String = ""
@Published var isLoading: Bool = false
func loadProfile() async {
isLoading = true
let profile = try? await APIClient.shared.fetchProfile()
userName = profile?.name ?? ""
isLoading = false
}
}
// Після увімкнення MainActor за замовчуванням:
// @MainActor вже не потрібен — він неявний
class ProfileViewModel: ObservableObject {
@Published var userName: String = ""
@Published var isLoading: Bool = false
func loadProfile() async {
isLoading = true
let profile = try? await APIClient.shared.fetchProfile()
userName = profile?.name ?? ""
isLoading = false
}
}
На перший погляд різниця невелика — прибрали один рядок. Але в реальному проєкті з десятками ViewModel, контролерів та сервісів видалення десятків (а то й сотень) @MainActor анотацій суттєво спрощує код.
Як увімкнути в Xcode 26
Для нових проєктів Xcode 26 встановлює MainActor за замовчуванням автоматично. А для існуючих проєктів потрібно змінити налаштування вручну:
- Відкрийте Build Settings вашого таргету
- Знайдіть параметр "Default Actor Isolation" (можна скористатися пошуком)
- Змініть значення з
nonisolatedнаMainActor - Знайдіть параметр "Approachable Concurrency" або
SWIFT_APPROACHABLE_CONCURRENCY - Встановіть його у Yes
Увімкнення Approachable Concurrency автоматично активує два додаткові upcoming features:
- Infer Isolated Conformances (SE-0470)
- nonisolated(nonsending) By Default (SE-0461)
Як увімкнути в Swift Package
Для Swift-пакетів потрібно оновити swift-tools-version до 6.2 та додати відповідні налаштування:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyFeaturePackage",
platforms: [.iOS(.v26), .macOS(.v26)],
targets: [
.target(
name: "MyFeature",
swiftSettings: [
.defaultIsolation(MainActor.self),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
]
)
nonisolated(nonsending): нова поведінка за замовчуванням (SE-0461)
Друга ключова зміна — nonisolated(nonsending). Чесно кажучи, це одна з тих речей, яка може здатися заплутаною на перший погляд, але насправді робить все набагато простішим. Щоб зрозуміти її значення, спочатку пригадаємо проблему.
Проблема Swift 6.1
У Swift 6.1 і раніше nonisolated функції поводились непослідовно:
- Синхронні
nonisolatedфункції виконувались на потоці того, хто їх викликав - Асинхронні
nonisolatedфункції завжди переключались на глобальний виконавець (фоновий потік)
Бачите проблему? Одне й те саме ключове слово — дві різні поведінки. Це спричиняло плутанину та помилки:
// Swift 6.1: ця функція ЗАВЖДИ виконується на фоновому потоці
nonisolated func decodeResponse(_ data: Data) async throws -> T {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
// Виклик з @MainActor контексту:
@MainActor
func handleResponse(_ data: Data) async throws {
// Тут ми на main thread
let user: User = try await decodeResponse(data) // <-- стрибок на фоновий потік!
// Знову на main thread
self.currentUser = user
}
Кожен виклик decodeResponse непомітно стрибав на фоновий потік і назад. Це створювало зайві перемикання контексту і, що ще гірше, вимагало щоб усі передані дані були Sendable.
Розв'язання у Swift 6.2
nonisolated(nonsending) уніфікує поведінку: тепер і синхронні, і асинхронні nonisolated функції виконуються на потоці того, хто їх викликав. Виклик з @MainActor контексту? Функція залишається на головному потоці. Просто і логічно.
// Swift 6.2: ця функція виконується на потоці викликача
nonisolated(nonsending) func decodeResponse(_ data: Data) async throws -> T {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
// Виклик з @MainActor — декодування відбувається на main thread
// Ніяких стрибків між потоками!
А ось що мені подобається найбільше: коли увімкнено NonisolatedNonsendingByDefault, вам навіть не потрібно писати nonisolated(nonsending) явно — це стає поведінкою за замовчуванням для всіх nonisolated функцій.
Переваги нового підходу
- Менше перемикань контексту — функція залишається на тому ж потоці
- Не потрібен Sendable — дані не перетинають межу ізоляції
- Простіша ментальна модель — все працює послідовно, як ви й очікуєте
- Консистентна поведінка — синхронні та асинхронні функції нарешті поводяться однаково
@concurrent: явне переключення на фоновий потік
Окей, якщо nonisolated(nonsending) тримає все на потоці викликача, як тепер виконувати важку роботу у фоні? Саме для цього Swift 6.2 вводить новий атрибут @concurrent.
Коли використовувати @concurrent
@concurrent явно повідомляє компілятору: «Ця функція повинна виконуватися на глобальному виконавці (фоновому потоці)». Використовуйте його для CPU-інтенсивних операцій:
- Декодування великих JSON-відповідей
- Обробка зображень
- Складні обчислення
- Парсинг великих файлів
class ImageProcessor {
// Ця функція завжди виконується на фоновому потоці
@concurrent func applyFilters(to imageData: Data) async throws -> Data {
// Важка обробка зображення
let image = try decodeImage(imageData)
let filtered = applyGaussianBlur(image, radius: 10)
let adjusted = adjustColorBalance(filtered)
return try encodeImage(adjusted)
}
// Ця функція залишається на потоці викликача (за замовчуванням)
func updateUI(with processedData: Data) async {
// Оновлення UI — має бути на main thread
self.displayImage = UIImage(data: processedData)
}
}
Зверніть увагу на один нюанс: @concurrent неявно включає nonisolated, тому писати @concurrent nonisolated не обов'язково (хоча й не заборонено — компілятор не скаржитиметься).
Порівняння: nonisolated(nonsending) проти @concurrent
Ось ключові відмінності між двома підходами:
nonisolated(nonsending)— виконується на потоці викликача, не вимагаєSendable, використовується за замовчуванням для більшості функцій@concurrent— виконується на глобальному виконавці (фоновому потоці), вимагаєSendableдля переданих даних, використовується для CPU-інтенсивної роботи
Простіше кажучи: якщо функція не робить нічого «важкого» — залиште її як є. Якщо виконує тривалі обчислення — додайте @concurrent.
Повний приклад: мережевий клієнт
Розглянемо реалістичний приклад мережевого клієнта, який наочно демонструє обидва підходи:
// З увімкненим MainActor за замовчуванням, цей клас
// неявно ізольований на @MainActor
class NetworkClient {
private let session = URLSession.shared
private var cache: [URL: Data] = [:]
// Функція мережевого запиту — використовує async/await,
// але не потребує @concurrent, бо URLSession.data
// сам обробляє мережеві операції асинхронно
func fetchData(from url: URL) async throws -> Data {
if let cached = cache[url] {
return cached
}
let (data, _) = try await session.data(from: url)
cache[url] = data
return data
}
// Декодування великого JSON — CPU-інтенсивна операція,
// використовуємо @concurrent для фонового потоку
@concurrent func decode(_ data: Data, as type: T.Type) async throws -> T {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(type, from: data)
}
// Використання обох функцій разом
func loadUserProfile(userId: String) async throws -> UserProfile {
let url = URL(string: "https://api.example.com/users/\(userId)")!
let data = try await fetchData(from: url)
return try await decode(data, as: UserProfile.self)
}
}
У цьому прикладі fetchData залишається на головному потоці (мережевий запит і так асинхронний), а decode виконується у фоні, щоб не блокувати UI під час парсингу великих відповідей. Такий поділ дуже природний і зрозумілий.
Infer Isolated Conformances (SE-0470)
Третя складова Approachable Concurrency — автоматичне виведення ізольованих відповідностей протоколам. Ця штука вирішує проблему, яка дратувала багатьох розробників у Swift 6.0 та 6.1: типи, ізольовані на @MainActor, часто просто не могли відповідати неізольованим протоколам без дивних обхідних рішень.
Проблема
// Swift 6.1: помилка компілятора!
@MainActor
class UserSettings: Equatable {
var theme: String
var fontSize: Int
// Помилка: "Main actor-isolated static method '=='
// cannot be used to satisfy nonisolated protocol requirement"
static func == (lhs: UserSettings, rhs: UserSettings) -> Bool {
lhs.theme == rhs.theme && lhs.fontSize == rhs.fontSize
}
}
Тобто ви хочете просто порівняти два об'єкти — і не можете. Бо компілятор вважає, що оператор == ізольований на @MainActor, а протокол Equatable вимагає неізольовану реалізацію.
Рішення у Swift 6.2
З увімкненим Infer Isolated Conformances компілятор автоматично створює ізольовану відповідність — відповідність протоколу, яка працює лише в контексті відповідного актора:
// Swift 6.2: працює без помилок!
// Компілятор виводить, що Equatable відповідність
// обмежена контекстом @MainActor
class UserSettings: Equatable {
var theme: String
var fontSize: Int
static func == (lhs: UserSettings, rhs: UserSettings) -> Bool {
lhs.theme == rhs.theme && lhs.fontSize == rhs.fontSize
}
}
Це особливо корисно для протоколів Codable, Hashable, Equatable та інших стандартних протоколів, з якими раніше доводилось возитися, додаючи nonisolated до кожної реалізації методу.
Типові помилки міграції та їх розв'язання
Увімкнення Approachable Concurrency в існуючому проєкті може породити нові помилки компілятора. Не панікуйте — більшість із них вирішуються досить просто. Розглянемо найпоширеніші.
1. Конфлікт відповідності протоколам
Це, мабуть, найчастіша помилка після увімкнення MainActor за замовчуванням:
// Помилка: "Main actor-isolated instance method
// cannot be used to satisfy nonisolated protocol requirement"
protocol DataProvider {
func fetchItems() async throws -> [Item]
}
// З MainActor за замовчуванням цей клас неявно @MainActor
class ItemService: DataProvider {
func fetchItems() async throws -> [Item] { // <-- Помилка тут
// ...
}
}
Рішення A — додати @MainActor до протоколу, якщо він завжди використовується на головному потоці:
@MainActor
protocol DataProvider {
func fetchItems() async throws -> [Item]
}
Рішення B — позначити метод як nonisolated, якщо він може працювати на будь-якому потоці:
class ItemService: DataProvider {
nonisolated func fetchItems() async throws -> [Item] {
// ...
}
}
2. Проблеми з Codable/Decodable
// Помилка: "Main actor-isolated initializer 'init(from:)'
// cannot be used to satisfy nonisolated protocol requirement"
struct UserDTO: Codable {
let id: String
let name: String
let email: String
}
Рішення — позначити тип як nonisolated. Це особливо важливо для DTO-моделей, які часто декодуються на фоновому потоці:
nonisolated struct UserDTO: Codable {
let id: String
let name: String
let email: String
}
На практиці варто зразу позначити всі ваші DTO та data-моделі як nonisolated — це збереже вам купу часу при міграції.
3. Змінні глобальні змінні
// Помилка: "Global variable is not concurrency-safe"
var currentSessionToken: String? = nil
Рішення — використовуйте один із варіантів:
// Варіант 1: Зробити константою, якщо можливо
let defaultTimeout: TimeInterval = 30
// Варіант 2: Ізолювати на актор
@MainActor var currentSessionToken: String? = nil
// Варіант 3: Використати nonisolated(unsafe) якщо
// синхронізація реалізована іншими засобами
nonisolated(unsafe) var legacyToken: String? = nil
Третій варіант — це, по суті, «я знаю що роблю, відчепись, компілятор». Використовуйте з обережністю.
4. XCTestCase та юніт-тести
// Помилка: "Main actor-isolated initializer 'init()'
// has different actor isolation from nonisolated overridden declaration"
class UserServiceTests: XCTestCase {
// ...
}
Рішення — для тестових таргетів розгляньте можливість залишити Default Actor Isolation як nonisolated, або (що навіть краще) перейти на Swift Testing:
// Краще: використовуйте Swift Testing
import Testing
struct UserServiceTests {
@Test func testFetchUser() async throws {
let service = UserService()
let user = try await service.fetchUser(id: "123")
#expect(user.name == "John")
}
}
Чесно кажучи, це гарний привід нарешті мігрувати на Swift Testing — з Approachable Concurrency він працює значно природніше, ніж XCTest.
5. Регресія продуктивності
Одна з прихованих пасток, про яку варто знати. Якщо ваш код раніше неявно виконувався у фоні, а тепер залишається на головному потоці, UI може «завмерти»:
// УВАГА: ця функція тепер виконується на main thread!
// Якщо обробка зображення повільна, UI зависне
func processImage(_ data: Data) async -> UIImage? {
// Важка обробка...
return UIImage(data: data)
}
// Виправлення: додайте @concurrent
@concurrent func processImage(_ data: Data) async -> UIImage? {
return UIImage(data: data)
}
Після міграції обов'язково протестуйте продуктивність — особливо в місцях з CPU-інтенсивними операціями. Instruments вам у поміч.
Покрокова стратегія міграції існуючого проєкту
Рекомендований підхід до увімкнення Approachable Concurrency в існуючому проєкті. Головне правило — не вмикайте все одразу:
- Оновіть Xcode до версії 26 та Swift до 6.2
- Увімкніть кожну функцію окремо, а не все одразу. Почніть з
NonisolatedNonsendingByDefault - Позначте CPU-інтенсивні функції атрибутом
@concurrentдо увімкненняMainActorза замовчуванням - Увімкніть Default Actor Isolation з
MainActor - Виправте помилки компілятора, використовуючи
nonisolatedдля типів та функцій, які не потребують@MainActor - Увімкніть Infer Isolated Conformances
- Протестуйте продуктивність — переконайтеся, що головний потік не перевантажений
Порядок кроків тут важливий. Якщо ви спочатку увімкнете MainActor за замовчуванням без попередньої розмітки @concurrent, ваші фонові операції раптово опиняться на головному потоці.
// Крок 1: Спочатку позначте фонові операції
class DataProcessor {
@concurrent func parseCSV(_ text: String) async -> [[String]] {
// Важка операція парсингу
text.components(separatedBy: "\n").map {
$0.components(separatedBy: ",")
}
}
@concurrent func compressData(_ data: Data) async throws -> Data {
// Стиснення даних
try (data as NSData).compressed(using: .lzfse) as Data
}
}
// Крок 2: Позначте DTO-моделі як nonisolated
nonisolated struct APIResponse: Codable {
let data: T
let meta: ResponseMeta
}
nonisolated struct ResponseMeta: Codable {
let page: Int
let totalPages: Int
}
// Крок 3: Увімкніть MainActor за замовчуванням —
// решта коду автоматично працює на головному потоці
Практичний приклад: MVVM-архітектура з Approachable Concurrency
А тепер — повний приклад MVVM-архітектури, що використовує всі можливості Approachable Concurrency. Це, мабуть, найкорисніша частина статті, бо показує, як все працює разом у реальному коді:
// Model — nonisolated, бо використовується для декодування
nonisolated struct Article: Codable, Identifiable {
let id: UUID
let title: String
let body: String
let publishedAt: Date
}
// Repository — використовує @concurrent для фонових операцій
class ArticleRepository {
private let session = URLSession.shared
private let baseURL = URL(string: "https://api.example.com")!
func fetchArticles() async throws -> [Article] {
let url = baseURL.appending(path: "/articles")
let (data, _) = try await session.data(from: url)
return try await decodeArticles(data)
}
@concurrent private func decodeArticles(_ data: Data) async throws -> [Article] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode([Article].self, from: data)
}
}
// ViewModel — неявно @MainActor завдяки Default Actor Isolation
@Observable
class ArticleListViewModel {
var articles: [Article] = []
var isLoading = false
var errorMessage: String?
private let repository = ArticleRepository()
func loadArticles() async {
isLoading = true
errorMessage = nil
do {
articles = try await repository.fetchArticles()
} catch {
errorMessage = "Не вдалося завантажити статті: \(error.localizedDescription)"
}
isLoading = false
}
}
// View — також неявно @MainActor
struct ArticleListView: View {
@State private var viewModel = ArticleListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Завантаження...")
} else if let error = viewModel.errorMessage {
ContentUnavailableView(
"Помилка",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
List(viewModel.articles) { article in
VStack(alignment: .leading) {
Text(article.title)
.font(.headline)
Text(article.publishedAt, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Статті")
.task {
await viewModel.loadArticles()
}
}
}
}
Зверніть увагу, як мало анотацій конкурентності залишилось у коді. Лише nonisolated для DTO-моделі та @concurrent для CPU-інтенсивної операції декодування. Все інше — автоматично безпечне завдяки MainActor за замовчуванням. Саме так і має виглядати сучасний Swift-код.
FAQ
Чи потрібно мігрувати з Swift 6.0 на Swift 6.2 для Approachable Concurrency?
Так, Approachable Concurrency — це набір функцій, доступних починаючи зі Swift 6.2 та Xcode 26. Ви не зможете використовувати @concurrent, nonisolated(nonsending) за замовчуванням та Default Actor Isolation у попередніх версіях. Але навіть якщо ви поки не плануєте увімкнювати всі нові функції, оновлення до Swift 6.2 варте того — вони активуються opt-in через налаштування збірки.
Чи можна використовувати MainActor за замовчуванням разом з SwiftData та Core Data?
Так, але з нюансами. SwiftData-моделі (анотовані @Model) потребують nonisolated, щоб коректно працювати з Codable та фоновими контекстами. Core Data NSManagedObject підкласи також варто позначити як nonisolated, оскільки вони мають власну модель потокобезпеки через NSManagedObjectContext. Загальне правило: data-моделі — nonisolated, ViewModel та UI-шар — MainActor за замовчуванням.
Чи вплине MainActor за замовчуванням на продуктивність мого застосунку?
Потенційно — так. Якщо ваш код раніше неявно виконував операції на фоновому потоці (через nonisolated async функції), тепер ці операції будуть на головному потоці. CPU-інтенсивні задачі можуть «заморозити» UI. Рішення просте: профіліруйте застосунок за допомогою Instruments та додайте @concurrent до функцій, які потребують фонового виконання. Для більшості типових операцій (мережеві запити, робота з базою даних) різниця непомітна.
Яка різниця між @concurrent та async let / TaskGroup?
@concurrent — це атрибут, який визначає де виконується функція (на глобальному виконавці). async let та TaskGroup — це механізми структурованої конкурентності, які визначають як виконувати кілька задач паралельно. Вони чудово доповнюють одне одного: наприклад, можна використати @concurrent функцію всередині TaskGroup, щоб обробити масив даних паралельно на фонових потоках.
Чи сумісний Approachable Concurrency зі сторонніми бібліотеками?
Так, і це хороша новина. Бібліотеки, які ще не оновлені до Swift 6.2, продовжать працювати — ваші налаштування Default Actor Isolation застосовуються лише до коду вашого таргету, а не до імпортованих модулів. Однак при виклику функцій із сторонніх бібліотек можуть виникати додаткові попередження про перетин меж ізоляції. Більшість популярних бібліотек (Alamofire, Kingfisher та інші) поступово додають підтримку strict concurrency, тому ситуація покращується з кожним днем.