Avatar ·

Эквивалент @synchronized из Objective-C в Swift: правильная синхронизация потоков

Всем привет! Я только недавно перешёл с Objective-C на Swift и столкнулся с проблемой синхронизации доступа к общим данным из разных потоков. В Objective-C я привык использовать простую конструкцию: ```objc @synchronized(self) { // критическая секция self.someProperty = newValue; } ``` Она гарантировала, что только один поток одновременно выполняет код внутри блока. В Swift такого синтаксиса нет. Я знаю, что в Swift есть GCD (Grand Central Dispatch), но не уверен, как правильно организовать синхронизацию. Проблема: у меня есть класс кэша, к которому обращаются несколько потоков. Вот упрощённая версия: ```swift class Cache { private var storage: [String: Any] = [:] func set(value: Any, forKey key: String) { storage[key] = value } func get(forKey key: String) -> Any? { return storage[key] } } ``` При конкурентном доступе из нескольких потоков происходит гонка данных (data race). Как правильно реализовать потокобезопасность в Swift? Нужен ли аналог @synchronized или есть более идиоматичные способы? Заранее спасибо за помощь! <<>>

Основные подходы к синхронизации в Swift

Прямого эквивалента @synchronized в Swift нет, но есть несколько более гибких и мощных способов. Выбор зависит от конкретной задачи. Рассмотрим три основных подхода.

1. Последовательная очередь GCD (Serial Queue) — рекомендуемый способ

Самый простой и идиоматичный способ для Swift — использовать последовательную (serial) очередь GCD. Все блоки, отправленные в такую очередь, выполняются строго по одному. Это идеально для синхронизации доступа к общим данным.

class Cache {
    private var storage: [String: Any] = [:]
    private let queue = DispatchQueue(label: "com.example.cache.queue", qos: .default)
    
    func set(value: Any, forKey key: String) {
        queue.sync {
            storage[key] = value
        }
    }
    
    func get(forKey key: String) -> Any? {
        return queue.sync {
            return storage[key]
        }
    }
}

Как это работает: Все операции чтения (get) и записи (set) выполняются синхронно на одной последовательной очереди. Это гарантирует, что в любой момент времени только один поток может читать или писать в storage.

Важное замечание: Если внутри блока queue.sync вызвать другой синхронный метод того же объекта, который тоже использует queue.sync, произойдёт взаимоблокировка (deadlock). В таких случаях используйте queue.async для записи, а для чтения — queue.sync.

2. Конкурентная очередь с барьером (Concurrent Queue + Barrier)

Если ваша структура читается гораздо чаще, чем пишется (например, редко обновляемый кэш), можно оптимизировать производительность с помощью конкурентной очереди и барьерных флагов.

class OptimizedCache {
    private var storage: [String: Any] = [:]
    private let queue = DispatchQueue(label: "com.example.cache.concurrent", attributes: .concurrent)
    
    func get(forKey key: String) -> Any? {
        return queue.sync {
            return storage[key]
        }
    }
    
    func set(value: Any, forKey key: String) {
        queue.async(flags: .barrier) {
            storage[key] = value
        }
    }
}

Почему это быстрее: Несколько потоков могут одновременно читать storage (вызовы queue.sync). Но когда происходит запись (с флагом .barrier), все текущие чтения должны завершиться, и только после этого выполняется запись. Во время записи другие операции блокируются.

3. NSLock и os_unfair_lock (низкоуровневые мьютексы)

Можно использовать классические мьютексы. Это ближе всего к поведению @synchronized, но требует ручного управления блокировкой.

class LockedCache {
    private var storage: [String: Any] = [:]
    private let lock = NSLock()
    
    func set(value: Any, forKey key: String) {
        lock.lock()
        storage[key] = value
        lock.unlock()
    }
    
    func get(forKey key: String) -> Any? {
        lock.lock()
        defer { lock.unlock() }
        return storage[key]
    }
}

Когда использовать: Когда нужен максимальный контроль над блокировкой (например, для рекурсивных вызовов используйте NSRecursiveLock). Для большинства случаев последовательная очередь GCD предпочтительнее, так как она менее подвержена ошибкам.

Рекомендация

  • Для 90% случаев используйте последовательную очередь GCD (первый вариант).
  • Если у вас read-heavy нагрузка (чтение > запись) — конкурентная очередь с барьером.
  • Избегайте прямого использования os_unfair_lock в Swift, если нет особой необходимости в микрооптимизациях.

Важно: в Swift 5.5+ появились акторы (actors), которые решают эту проблему на уровне языка. Но для iOS 12 и ниже они недоступны, поэтому GCD остаётся основным инструментом.

<<>>

Альтернативный подход: использование акторов (Swift 5.5+)

Если ваше приложение поддерживает iOS 13+ или macOS 10.15+, рассмотрите использование акторов — это современное решение от Apple для синхронизации. Актор автоматически защищает своё состояние от гонок данных.

actor Cache {
    private var storage: [String: Any] = [:]
    
    func set(value: Any, forKey key: String) {
        storage[key] = value
    }
    
    func get(forKey key: String) -> Any? {
        return storage[key]
    }
}

// Использование:
Task {
    let cache = Cache()
    await cache.set(value: "Data", forKey: "key1")
    let value = await cache.get(forKey: "key1")
}

Как это работает: Компилятор сам гарантирует, что к изменяемому состоянию актора (свойство storage) можно обращаться только изнутри актора. Все внешние вызовы должны быть помечены await, и актор сам управляет синхронизацией.

Преимущества: Не нужно вручную создавать очереди или блокировки. Код становится чище и безопаснее. Акторы также поддерживают reentrancy (повторный вход), что позволяет избежать взаимоблокировок.

Недостатки: Требует Swift 5.5 и поддержки async/await. Для старых версий iOS или macOS используйте GCD. Также акторы имеют накладные расходы на управление контекстом (переключение на главный актор при необходимости), но для большинства приложений это незаметно.

Если вы начинаете новый проект на Swift 5.5+, акторы — это рекомендуемый способ. Для поддержки старых версий ОС используйте последовательную очередь GCD.