Actors в Swift: как использовать и предотвращать гонки данных

Actors Swift появились впервые в Swift 5.5 и являются частью больших изменений параллелизма на WWDC 2021. До появления Actors гонки данных были обычным исключением. Поэтому, прежде чем мы углубимся в субъектов с изолированным и неизолированным доступом, полезно понять, что такое гонки данных, и понять, как вы можете разрешить их сегодня.

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

Что такое Actors?

Actors в Swift не новы: они вдохновлены Actors моделью, которая рассматривает Actors как универсальные примитивы параллельных вычислений. Однако в предложении SE-0306 представлены действующие лица и объясняется, какую проблему они решают: гонки данных.

Гонки данных происходят, когда к одной и той же памяти обращаются из нескольких потоков без синхронизации, и по крайней мере один доступ является записью. Гонки данных могут привести к непредсказуемому поведению, повреждению памяти, ненадежным тестам и странным сбоям. У вас могут быть сбои, которые вы не можете решить сегодня, поскольку вы понятия не имеете, когда они происходят, как вы можете их воспроизвести или как вы можете исправить их на основе теории. В моей статье Thread Sanitizer объясняется: гонки данных в Swift подробно объясняют, как вы можете решать, находить и исправлять гонки данных.

Actors в Swift защищают свое состояние от гонок данных, и их использование позволяет компилятору давать нам полезную обратную связь при написании приложений. Кроме того, компилятор Swift может статически применять ограничения, связанные с Actors, и предотвращает одновременный доступ к изменяемым данным.

Вы можете определить Actor с помощью ключевого слова actor, точно так же, как в случае с классом или структурой:

actor ChickenFeeder {
    let food = "worms"
    var numberOfEatingChickens: Int = 0
}

Actors похожи на другие типы Swift, поскольку они также могут иметь инициализаторы, методы, свойства и индексы, в то время как вы также можете использовать их с протоколами и дженериками. Кроме того, в отличие от структур, Actors требует определения инициализаторов, когда ваши определенные свойства требуют этого вручную. Наконец, важно понимать, что Actors являются ссылочными типами.

Actors являются ссылочными типами, но все же отличаются от классов.

Actors — это ссылочные типы, что вкратце означает, что копии ссылаются на один и тот же фрагмент данных. Следовательно, изменение копии также изменит исходный экземпляр, поскольку они указывают на один и тот же общий экземпляр. Подробнее об этом можно прочитать в моей статье «Структура против классов в Swift: объяснение различий».

Тем не менее у Actors есть важное отличие от классов: они не поддерживают наследование.

Actors в Swift почти как классы, но не поддерживают наследование.

Отсутствие поддержки наследования означает, что нет необходимости в таких функциях, как удобство и обязательные инициализаторы, переопределение, члены класса или операторы open и final.

Однако самая большая разница определяется основной обязанностью Actors, которая заключается в изоляции доступа к данным.

Как Actors предотвращают гонки данных с помощью синхронизации

Actors предотвращают гонки данных, создавая синхронизированный доступ к своим изолированным данным. До появления Actors мы бы создавали тот же результат, используя всевозможные блокировки. Примером такой блокировки является параллельная очередь отправки в сочетании с барьером для обработки доступа для записи. Вдохновленный методами, описанными в моей статье Concurrent vs. Serial DispatchQueue: Concurrency in Swift, я покажу вам до и после использования Actors.

До создания Actors мы бы создали потокобезопасную кормушку для цыплят следующим образом:

final class ChickenFeederWithQueue {
    let food = "worms"
    
    /// A combination of a private backing property and a computed property allows for synchronized access.
    private var _numberOfEatingChickens: Int = 0
    var numberOfEatingChickens: Int {
        queue.sync {
            _numberOfEatingChickens
        }
    }
    
    /// A concurrent queue to allow multiple reads at once.
    private var queue = DispatchQueue(label: "chicken.feeder.queue", attributes: .concurrent)
    
    func chickenStartsEating() {
        /// Using a barrier to stop reads while writing
        queue.sync(flags: .barrier) {
            _numberOfEatingChickens += 1
        }
    }
    
    func chickenStopsEating() {
        /// Using a barrier to stop reads while writing
        queue.sync(flags: .barrier) {
            _numberOfEatingChickens -= 1
        }
    }
}

Как видите, здесь довольно много кода, который нужно поддерживать. Мы должны тщательно продумать использование очереди при доступе к данным, которые не являются потокобезопасными. Флаг барьера требуется, чтобы на мгновение остановить чтение и разрешить запись. Опять же, нам нужно позаботиться об этом самим, так как компилятор не принуждает к этому. Наконец, здесь мы используем DispatchQueue, но часто возникают споры о том, какую блокировку лучше использовать.

Actors, с другой стороны, позволяют Swift максимально оптимизировать синхронизированный доступ. Базовая блокировка, которая используется, является просто деталью реализации. В результате компилятор Swift может обеспечить синхронизированный доступ, не позволяя нам в большинстве случаев вводить гонки данных.

Чтобы увидеть, как это работает, мы можем реализовать приведенный выше пример, используя нашего ранее определенного Actor кормушки для кур:

actor ChickenFeeder {
    let food = "worms"
    var numberOfEatingChickens: Int = 0
    
    func chickenStartsEating() {
        numberOfEatingChickens += 1
    }
    
    func chickenStopsEating() {
        numberOfEatingChickens -= 1
    }
}

Первое, что вы заметите, это то, что экземпляр стал намного проще и легче читается. Вся логика, связанная с синхронизацией доступа, скрыта как деталь реализации в стандартной библиотеке Swift. Однако самое интересное происходит, когда мы пытаемся использовать или прочитать любое из изменяемых свойств и методов:

Методы в Actors изолированы для синхронизированного доступа.

То же самое происходит при доступе к изменяемому свойству numberOfEatingChickens:

Доступ к изменяемым свойствам можно получить только из Актера.

Однако нам разрешено написать следующий фрагмент кода:

let feeder = ChickenFeeder()
print(feeder.food)

Свойство food в нашей кормушке для кур неизменяемо и, следовательно, потокобезопасно. Риск гонки данных отсутствует, так как его значение не может измениться из другого потока во время чтения.

Однако другие наши методы и свойства изменяют изменяемое состояние ссылочного типа. Для предотвращения гонок данных требуется синхронизированный доступ для обеспечения последовательного доступа.

Использование async/await для доступа к данным из Actors

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

В Swift мы можем создать асинхронный доступ, используя ключевое слово await:

let feeder = ChickenFeeder()
await feeder.chickenStartsEating()
print(await feeder.numberOfEatingChickens) // Prints: 1 

Предотвращение ненужных приостановок

В приведенном выше примере мы обращаемся к двум различным частям нашего Actor. Во-первых, мы обновляем количество съеденных цыплят, после чего выполняем еще одну асинхронную задачу, чтобы распечатать количество съеденных цыплят. Каждое await может привести к приостановке вашего кода для ожидания доступа. В этом случае имеет смысл иметь две приостановки, поскольку обе части на самом деле не имеют ничего общего. Однако вы должны принять во внимание, что может быть другой поток, ожидающий вызова ChickenStartsEating, что может привести к тому, что две курицы будут есть, в то время, когда мы распечатываем результат.

Чтобы лучше понять эту концепцию, давайте рассмотрим случай, когда вы хотите объединить операции в один метод, чтобы предотвратить дополнительные приостановки. Например, представьте, что у нашего Actor есть метод уведомления, который уведомляет наблюдателей о новом цыпленке, который начал есть:

extension ChickenFeeder {
    func notifyObservers() {
        NotificationCenter.default.post(name: NSNotification.Name("chicken.started.eating"), object: numberOfEatingChickens)
    }
}

Мы могли бы использовать этот код, используя await дважды:

let feeder = ChickenFeeder()
await feeder.chickenStartsEating()
await feeder.notifyObservers()

Однако это может привести к двум точкам приостановки, по одной для каждого await. Вместо этого мы могли бы оптимизировать этот код, вызвав метод notifyObservers из ChickenStartsEating:

func chickenStartsEating() {
    numberOfEatingChickens += 1
    notifyObservers()
} 

Поскольку мы уже находимся внутри Actor с синхронизированным доступом, нам не нужно еще одно await. Это важные улучшения, которые следует учитывать, поскольку они могут повлиять на производительность.

Неизолированный доступ внутри Actors

Важно понимать концепцию изоляции в Actors. В приведенных выше примерах уже показано, как синхронизируется доступ из внешних экземпляров Actor, требуя использования await. Однако если вы внимательно смотрели, то могли заметить, что наш метод notifyObservers не требует использования await для доступа к нашему изменяемому свойству numberOfEatingChickens.

При доступе к изолированному методу внутри Actors вам в основном разрешен доступ к любым другим свойствам или методам, которые требуют синхронизированного доступа. Таким образом, вы фактически повторно используете предоставленный вам доступ, чтобы получить от него максимальную отдачу!

Однако бывают случаи, когда вы знаете, что изолированный доступ не требуется. Методы в Actors по умолчанию изолированы. Следующий метод обращается только к нашему неизменяемому свойству food, но для доступа к нему по-прежнему требуется await:

let feeder = ChickenFeeder()
await feeder.printWhatChickensAreEating() 

Это странно, поскольку мы знаем, что не обращаемся ни к чему, требующему синхронизированного доступа. SE-0313 был представлен именно для решения этой проблемы. Мы можем пометить наш метод ключевым словом nonisolated, чтобы сообщить компилятору Swift, что наш метод не обращается к каким-либо изолированным данным:

extension ChickenFeeder {
    nonisolated func printWhatChickensAreEating() {
        print("Chickens are eating \(food)")
    }
}

let feeder = ChickenFeeder()
feeder.printWhatChickensAreEating() 

Обратите внимание, что вы также можете использовать ключевое слово nonisolated для вычисляемых свойств, что полезно для соответствия таким протоколам, как CustomStringConvertible:

extension ChickenFeeder: CustomStringConvertible {   
    nonisolated var description: String {     
        "A chicken feeder feeding \(food)"   
    } 
}

Однако определять их для неизменяемых свойств не нужно, как вам скажет компилятор:

Отмечать неизменяемые свойства как неизолированные излишне.

Почему гонки данных все еще могут возникать при использовании Actors

При последовательном использовании Actors в вашем коде вы наверняка снизите риски, связанные с гонкой данных. Создание синхронизированного доступа предотвращает странные сбои, связанные с гонками данных. Однако вам, очевидно, необходимо постоянно использовать их, чтобы предотвратить гонку данных в вашем приложении.

Условия гонки по-прежнему могут возникать в вашем коде, но больше не могут приводить к исключению. Это важно понимать, поскольку Actors могут продвигаться как решающие все. Например, представьте, что два потока правильно обращаются к данным наших Actors, используя await:

queueOne.async {
    await feeder.chickenStartsEating()
}
queueTwo.async {
    print(await feeder.numberOfEatingChickens)
} 

Состояние гонки здесь определяется как: «какой поток первым начнет изолированный доступ?». Таким образом, в основном есть два исхода:

  • Поставьте в очередь первого, увеличивая количество поедаемых цыплят. Вторая очередь напечатает 1
  • Очередь два становится первой, печатая количество поедаемых цыплят, которое по-прежнему равно 0.

Разница здесь в том, что мы больше не обращаемся к данным во время их изменения. Без синхронизированного доступа в некоторых случаях это может привести к непредсказуемому поведению.

Продолжаем свое путешествие в Swift Concurrency

Изменения параллелизма — это больше, чем просто асинхронное ожидание, они включают в себя множество новых функций, которые вы можете использовать в своем коде. Итак, пока вы этим занимаетесь, почему бы не погрузиться в другие функции параллелизма?

Заключение

Actors Swift решают проблему гонки данных, которая была распространенной проблемой в приложениях, написанных на Swift. Доступ к изменяемым данным осуществляется синхронно, что обеспечивает их безопасность. Мы не рассмотрели экземпляр MainActor, который является отдельной темой. Я обязательно расскажу об этом в следующей статье. Надеюсь, вы смогли следовать этому курсу и знаете, как использовать Actors в своих приложениях.

Если вы хотите узнать больше советов по Swift, посетите страницу категории Swift. Не стесняйтесь обращаться ко мне или писать мне в Твиттере, если у вас есть какие-либо дополнительные советы или отзывы.

Спасибо!