Защо безопасността от data race е толкова важна в Swift 6
Конкурентното програмиране винаги е било едно от най-трудните неща в софтуерната разработка. Нека бъдем честни — data race условията (когато няколко нишки едновременно пипат едно и също мутируемо състояние) са кошмар. Непредвидими сривове, повредени данни, бъгове, които просто не можеш да възпроизведеш. Познато ли ви е?
Е, Swift 6 променя тази реалност коренно.
С въвеждането на проверка за data race на ниво компилация, рънтайм грешките се превръщат в грешки при компилиране. Това е огромна стъпка — сравнима с въвеждането на Optional типовете, които на времето ни отърваха от null pointer exceptions. В Swift 6 компилаторът вече не третира безопасността от data race като мило предложение — тя е задължителна.
Какво представляват актьорите (actors) в Swift
Актьорите са референтни типове, които автоматично защитават мутируемото си състояние от конкурентен достъп. За разлика от класовете, при актьорите достъпът до свойствата и методите им се сериализира автоматично — само една задача може да взаимодейства с актьора в даден момент.
Звучи абстрактно, нали? Ето конкретен пример:
actor UserRepository {
private var cache: [String: User] = [:]
func getUser(id: String) async throws -> User {
if let cached = cache[id] {
return cached
}
let user = try await fetchFromNetwork(id: id)
cache[id] = user
return user
}
func updateUser(_ user: User) {
cache[user.id] = user
}
private func fetchFromNetwork(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(
from: URL(string: "https://api.example.com/users/\(id)")!
)
return try JSONDecoder().decode(User.self, from: data)
}
}
Тук UserRepository е деклариран като actor, а не като class. Какво ни дава това? Свойството cache е автоматично защитено — няма нужда от ръчни заключвания (locks) или dispatch queues. Лично аз помня колко грозен код писахме с DispatchQueue.sync навсякъде. Тези времена са минало.
Как се извиква актьор отвън
Тъй като актьорите пазят вътрешното си състояние, всяко обръщение към тях отвън техния контекст изисква await:
let repository = UserRepository()
// Извикването е асинхронно, защото преминаваме границата на актьора
let user = try await repository.getUser(id: "123")
print(user.name)
Вътре в самия актьор обаче достъпът до собствените свойства и методи е синхронен — не е нужно да пишете await. Удобно, а?
Протоколът Sendable — типове, безопасни за конкурентност
Протоколът Sendable маркира типове, които могат безопасно да се прехвърлят между конкурентни домейни — например между различни актьори или задачи. В Swift 6 компилаторът стриктно проверява дали типовете, които преминават граници на изолация, са Sendable.
Хубавото е, че много типове са Sendable автоматично.
Кои типове са автоматично Sendable
- Стойностни типове (struct, enum) — защото се копират, а не споделят
- Актьори — защото автоматично защитават състоянието си
- Final класове с неизменими (
let) свойства, които сами са Sendable
// Автоматично Sendable — стойностен тип с Sendable свойства
struct UserProfile: Sendable {
let id: String
let name: String
let email: String
}
// Автоматично Sendable — final клас с let свойства
final class AppConfig: Sendable {
let apiBaseURL: String
let timeout: TimeInterval
init(apiBaseURL: String, timeout: TimeInterval) {
self.apiBaseURL = apiBaseURL
self.timeout = timeout
}
}
// ГРЕШКА — клас с var свойство не може да бъде Sendable
// final class Counter: Sendable {
// var count = 0 // Компилаторна грешка!
// }
Кога да използвате @unchecked Sendable
Понякога имате тип, който е безопасен за конкурентност (да кажем, ползва вътрешни заключвания), но компилаторът просто не може да го потвърди сам. В такива случаи @unchecked Sendable идва на помощ:
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
func set(_ key: String, value: Any) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
}
Внимание: Използвайте @unchecked Sendable внимателно. Този атрибут заобикаля проверките на компилатора и прехвърля отговорността за безопасността изцяло на вас. Честно казано, в повечето случаи по-добрият подход е просто да преработите типа като актьор.
@MainActor — изолация към главната нишка
Ако правите iOS разработка, знаете добре — голяма част от кода трябва да живее на главната нишка. Обновяване на UI, работа с UIKit компоненти, промяна на @Published свойства. @MainActor е глобален актьор, който гарантира, че маркираният код се изпълнява в контекста на главния актьор:
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func loadUser(id: String) async {
isLoading = true
errorMessage = nil
do {
user = try await repository.getUser(id: id)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
Тъй като ProfileViewModel е маркиран с @MainActor, всичките му свойства и методи автоматично се изпълняват в контекста на главния актьор. Край на „purple runtime warnings" за обновяване на UI извън главната нишка. (Ако сте се борили с тях, знаете колко досадни бяха.)
Прилагане на @MainActor върху отделни методи
Не е задължително цял клас да бъде @MainActor. Можете да маркирате само конкретни методи или свойства — и това е напълно валидно:
class DataService {
func fetchData() async throws -> [Item] {
// Изпълнява се на background executor
let (data, _) = try await URLSession.shared.data(from: apiURL)
return try JSONDecoder().decode([Item].self, from: data)
}
@MainActor
func updateUI(with items: [Item]) {
// Гарантирано на главната нишка
NotificationCenter.default.post(
name: .dataDidUpdate,
object: items
)
}
}
Стриктна проверка за конкурентност — три нива
Xcode предлага три нива на проверка за конкурентност, така че да можете да мигрирате постепенно, без да ви се строши всичко наведнъж:
Minimal (Минимално)
Проверява Sendable ограничения само там, където изрично сте ги приложили. Подходящо за самото начало на миграцията.
Targeted (Насочено)
Извършва проверки за изолация на актьори и прилага Sendable ограничения навсякъде, където кодът е приел конкурентност. Един вид междинен етап.
Complete (Пълно)
Прилага всички проверки в целия проект. Това е стандартното поведение на Swift 6 и крайната цел на миграцията.
// В Build Settings на Xcode:
// Swift Compiler - Upcoming Features
// Strict Concurrency Checking: Complete
// Или в Package.swift:
.target(
name: "MyApp",
swiftSettings: [
.swiftLanguageMode(.v6)
]
)
Най-честите грешки при миграция и как да ги решите
Нека да разгледаме грешките, които почти всеки среща при миграция към Swift 6. Познавам ги добре — минах през всяка от тях.
Грешка 1: Мутируеми свойства в Sendable типове
// ГРЕШКА: Stored property 'count' of 'Sendable'-conforming class
// 'Counter' is mutable
final class Counter: Sendable {
var count = 0 // Компилаторът отказва
}
// РЕШЕНИЕ 1: Използвайте actor вместо class
actor Counter {
var count = 0
func increment() {
count += 1
}
}
// РЕШЕНИЕ 2: Направете свойствата неизменими
final class Counter: Sendable {
let initialCount: Int
init(initialCount: Int) {
self.initialCount = initialCount
}
}
Грешка 2: Non-Sendable тип преминава граница на актьор
// Клас, който НЕ е Sendable
class MutableSettings {
var theme: String = "light"
}
actor SettingsManager {
func applySettings(_ settings: MutableSettings) {
// ГРЕШКА: Non-Sendable тип преминава граница на актьор
}
}
// РЕШЕНИЕ: Преобразувайте в struct
struct AppSettings: Sendable {
let theme: String
}
actor SettingsManager {
func applySettings(_ settings: AppSettings) {
// Работи — AppSettings е Sendable struct
}
}
Грешка 3: Глобални мутируеми променливи
Тази е класика. В Swift 6 глобалните var променливи вече не минават:
// ГРЕШКА: Global variable is not concurrency-safe
var currentUser: User?
// РЕШЕНИЕ 1: Маркирайте с @MainActor
@MainActor var currentUser: User?
// РЕШЕНИЕ 2: Направете я константа
let defaultTimeout: TimeInterval = 30
// РЕШЕНИЕ 3: Използвайте actor
actor AppState {
static let shared = AppState()
var currentUser: User?
}
Грешка 4: Работа с немигрирани библиотеки
Тази ще ви се случи гарантирано, ако ползвате трети библиотеки:
// Използвайте @preconcurrency за библиотеки,
// които не са мигрирали към Swift 6
@preconcurrency import SomeOldLibrary
// Това потиска грешките за конкурентност
// от този модул, докато авторите го обновят
Swift 6.2: Approachable Concurrency — новият подход
Тук нещата стават наистина интересни. Swift 6.2 (интегриран в Xcode 26) въвежда концепцията Approachable Concurrency — достъпна конкурентност, която значително опростява работата с конкурентен код.
@MainActor по подразбиране за нови проекти
Новите проекти в Xcode 26 автоматично прилагат @MainActor към целия код по подразбиране. Какво означава това на практика? Приложението ви е еднонишково по подразбиране — пишете обикновен последователен код и въвеждате конкурентност само когато наистина имате нужда от нея.
Това е доста освежаваща промяна на философията.
// В Package.swift за Swift 6.2:
.target(
name: "MyApp",
swiftSettings: [
.defaultIsolation(MainActor.self),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
nonisolated(nonsending) по подразбиране
В Swift 6.2 nonisolated async функции вече не се прехвърлят автоматично на глобалния executor. Вместо това наследяват изолацията на извикващия актьор. Ако наистина искате функцията да работи на фонов executor, маркирайте я изрично с @concurrent:
// Swift 6.2 — работи на executor-а на извикващия актьор
func processData(_ data: Data) async -> ProcessedResult {
// По подразбиране наследява изолацията на caller-а
return transform(data)
}
// Ако искате фонова обработка — маркирайте изрично
@concurrent
func heavyComputation(_ data: Data) async -> ProcessedResult {
// Изпълнява се на глобалния concurrent executor
return performExpensiveWork(data)
}
Изведени изолирани съответствия (Inferred Isolated Conformances)
Типове, изолирани към глобален актьор (например @MainActor), вече могат да се съобразяват с протоколи в контекста на своя актьор. На практика @MainActor клас вече може да съответства на Equatable с изолирано съответствие, без да се налага да маркирате методите като nonisolated. Малка, но приятна промяна.
Практически пример: Пълно приложение с актьори
Теорията е хубаво нещо, но нека да видим как всичко работи заедно. Ето реалистичен пример — приложение за управление на задачи, което използва актьори за безопасна конкурентност:
// Модели — стойностни типове, автоматично Sendable
struct TodoItem: Sendable, Identifiable, Codable {
let id: UUID
var title: String
var isCompleted: Bool
let createdAt: Date
}
// Актьор за управление на данни
actor TodoStore {
private var items: [TodoItem] = []
private let saveURL: URL
init(saveURL: URL) {
self.saveURL = saveURL
}
func loadItems() async throws {
let data = try Data(contentsOf: saveURL)
items = try JSONDecoder().decode([TodoItem].self, from: data)
}
func addItem(_ item: TodoItem) async throws {
items.append(item)
try await saveItems()
}
func toggleCompletion(id: UUID) async throws {
guard let index = items.firstIndex(where: { $0.id == id }) else {
throw TodoError.notFound
}
items[index].isCompleted.toggle()
try await saveItems()
}
func getAllItems() -> [TodoItem] {
return items
}
private func saveItems() async throws {
let data = try JSONEncoder().encode(items)
try data.write(to: saveURL)
}
}
enum TodoError: Error {
case notFound
}
// ViewModel — изолиран към MainActor за UI обновления
@MainActor
class TodoViewModel: ObservableObject {
@Published var items: [TodoItem] = []
@Published var isLoading = false
private let store: TodoStore
init(store: TodoStore) {
self.store = store
}
func load() async {
isLoading = true
do {
try await store.loadItems()
items = await store.getAllItems()
} catch {
print("Грешка при зареждане: \(error)")
}
isLoading = false
}
func addNewItem(title: String) async {
let item = TodoItem(
id: UUID(),
title: title,
isCompleted: false,
createdAt: Date()
)
do {
try await store.addItem(item)
items = await store.getAllItems()
} catch {
print("Грешка при добавяне: \(error)")
}
}
func toggleItem(id: UUID) async {
do {
try await store.toggleCompletion(id: id)
items = await store.getAllItems()
} catch {
print("Грешка при промяна: \(error)")
}
}
}
Забележете как всичко си идва на място: TodoStore е актьор, който безопасно управлява мутируемото състояние. TodoViewModel е маркиран с @MainActor, така че UI обновленията са гарантирано на главната нишка. А TodoItem е struct — автоматично Sendable, което позволява безпроблемното му прехвърляне между актьора и ViewModel-а.
Стратегия за миграция към Swift 6 на реални проекти
Миграцията към Swift 6 трябва да е постепенна. Сериозно — не се опитвайте да мигрирате целия проект за един уикенд. Ето подхода, който работи:
- Започнете с Minimal проверка — включете стриктната конкурентност на минимално ниво и отстранете предупрежденията, които се появят
- Преминете на Targeted — разширете проверките до код, който вече използва конкурентност
- Мигрирайте модул по модул — не правете всичко наведнъж, така ще запазите разума си
- Преобразувайте сингълтоните — заменете
static sharedинстанции с глобални актьори или ги маркирайте с@MainActor - Модернизирайте callbacks — конвертирайте completion handlers към async функции
- Използвайте @preconcurrency — за немигрирани зависимости, докато авторите им ги обновят
- Включете Complete проверка — крайната цел, при която целият проект е безопасен от data race
// Стъпка 1: Идентифицирайте проблемни сингълтони
// ПРЕДИ:
class NetworkManager {
static let shared = NetworkManager()
var baseURL = "https://api.example.com"
}
// СЛЕД: Преобразувайте в actor
actor NetworkManager {
static let shared = NetworkManager()
var baseURL = "https://api.example.com"
func request(_ endpoint: String) async throws -> Data {
let url = URL(string: baseURL + endpoint)!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
Често задавани въпроси
Трябва ли всички зависимости да са мигрирани, преди да приема Swift 6?
Не, не е нужно. Всички проекти, пакети и зависимости могат да мигрират независимо. Използвайте @preconcurrency import за библиотеки, които все още не са обновени, и мигрирайте проекта си без да чакате трети страни.
Каква е разликата между actor и @MainActor клас?
И двата подхода осигуряват изолация, но по различен начин. actor създава собствен изолационен домейн и е подходящ за управление на данни — repositories, кешове, мрежови мениджъри. @MainActor клас е изолиран към главния актьор и е идеален за ViewModel-и и UI код, който трябва да се изпълнява на главната нишка.
Кога да използвам @unchecked Sendable вместо пълно Sendable съответствие?
Само когато имате тип, който е безопасен за конкурентност чрез механизми, които компилаторът не може да провери (като NSLock или DispatchQueue). В повечето случаи по-добре преработете типа като actor или struct. @unchecked Sendable заобикаля проверките на компилатора и отговорността пада на вас.
Как да се справя с грешки за глобални мутируеми променливи?
Три основни варианта: маркирайте глобалната променлива с @MainActor ако е свързана с UI; преобразувайте я в константа (let) с Sendable тип; или я преместете вътре в актьор. Най-добрият подход зависи от конкретната ситуация.
Какво е Approachable Concurrency в Swift 6.2?
Approachable Concurrency е набор от подобрения в Swift 6.2, които правят конкурентния код по-достъпен. Ключовата промяна — новите проекти в Xcode 26 са @MainActor по подразбиране. Кодът ви е еднонишков, освен ако изрично не поискате конкурентност с @concurrent. За нови проекти го препоръчвам определено. За съществуващи — преценете дали промяната на подразбиращата се изолация няма да наруши текущата логика.