Swift 6 Актьори и Sendable: Ръководство за data race безопасност

Как Swift 6 актьорите, протоколът Sendable и стриктната проверка за конкурентност елиминират data race на ниво компилация. Практическо ръководство с примери за миграция и Swift 6.2.

Защо безопасността от 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 трябва да е постепенна. Сериозно — не се опитвайте да мигрирате целия проект за един уикенд. Ето подхода, който работи:

  1. Започнете с Minimal проверка — включете стриктната конкурентност на минимално ниво и отстранете предупрежденията, които се появят
  2. Преминете на Targeted — разширете проверките до код, който вече използва конкурентност
  3. Мигрирайте модул по модул — не правете всичко наведнъж, така ще запазите разума си
  4. Преобразувайте сингълтоните — заменете static shared инстанции с глобални актьори или ги маркирайте с @MainActor
  5. Модернизирайте callbacks — конвертирайте completion handlers към async функции
  6. Използвайте @preconcurrency — за немигрирани зависимости, докато авторите им ги обновят
  7. Включете 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. За нови проекти го препоръчвам определено. За съществуващи — преценете дали промяната на подразбиращата се изолация няма да наруши текущата логика.

За Автора Editorial Team

Our team of expert writers and editors.