Actors в Swift: как использовать и предотвращать гонки данных Swift появились впервые в Swift 5.5 и являются частью больших изменений параллелизма на WWDC 2021. До появления акторов гонки данных были обычным исключением. Поэтому, прежде чем мы углубимся в субъекты с изолированным и неизолированным доступом, полезно понять, что такое гонки данных, и понять, как вы можете решить их сегодня.
Actors в Swift: как использовать и предотвращать гонки данных в Swift стремятся полностью решить проблему гонок данных, но важно понимать, что они все еще могут сталкиваться с гонками данных. В этой статье будет рассказано, как работают Actors в Swift: как использовать и предотвращать гонки данных и как вы можете использовать их в своих проектах.
Что такое Actors в Swift: как использовать и предотвращать гонки данных?
Actors в Swift: как использовать и предотвращать гонки данных в Swift не новы: они вдохновлены акторной моделью, которая рассматривает акторов как универсальные примитивы параллельных вычислений. Однако в предложении SE-0306 представлены действующие лица и объясняется, какую проблему они решают: гонки данных.
Гонки данных происходят, когда к одной и той же памяти обращаются из нескольких потоков без синхронизации, и по крайней мере один доступ является записью. Гонки данных могут привести к непредсказуемому поведению, повреждению памяти, ненадежным тестам и странным сбоям. У вас могут быть сбои, которые вы не можете решить сегодня, поскольку вы понятия не имеете, когда они происходят, как вы можете их воспроизвести или как вы можете исправить их на основе теории. В моей статье Thread Sanitizer объясняется: Гонки данных в Swift подробно объясняют, как вы можете решать, находить и исправлять гонки данных.
Актеры в Swift защищают свое состояние от гонок данных, и их использование позволяет компилятору давать нам полезную обратную связь при написании приложений. Кроме того, компилятор Swift может статически применять ограничения, связанные с акторами, и предотвращать одновременный доступ к изменяемым данным.
Вы можете определить Актера с помощью ключевого слова актера, точно так же, как в случае с классом или структурой:
actor ChickenFeeder {
let food = "worms"
var numberOfEatingChickens: Int = 0
}
Актеры похожи на другие типы Swift, поскольку они также могут иметь инициализаторы, методы, свойства и индексы, в то время как вы также можете использовать их с протоколами и дженериками. Кроме того, в отличие от структур, актор требует определения инициализаторов, когда ваши определенные свойства требуют этого вручную. Наконец, важно понимать, что актеры являются ссылочными типами.
Актеры являются ссылочными типами, но все же отличаются от классов.
Актеры — это ссылочные типы, что вкратце означает, что копии ссылаются на один и тот же фрагмент данных. Следовательно, изменение копии также изменит исходный экземпляр, поскольку они указывают на один и тот же общий экземпляр. Подробнее об этом можно прочитать в моей статье «Структура против классов в Swift: объяснение различий».
Тем не менее у Актеров есть важное отличие от классов: они не поддерживают наследование.
Отсутствие поддержки наследования означает, что нет необходимости в таких функциях, как удобство и обязательные инициализаторы, переопределение, члены класса или операторы open и final.
Однако самая большая разница определяется основной обязанностью Актеров, которая заключается в изоляции доступа к данным.
Как Актеры предотвращают гонки данных с помощью синхронизации
Актеры предотвращают гонки данных, создавая синхронизированный доступ к своим изолированным данным. До появления Актеров мы бы создавали тот же результат, используя всевозможные блокировки. Примером такой блокировки является параллельная очередь отправки в сочетании с барьером для обработки доступа для записи. Вдохновленный методами, описанными в моей статье Concurrent vs. Serial DispatchQueue: Concurrency in Swift, я покажу вам до и после использования Актеров.
До создания Актеров мы бы создали потокобезопасную кормушку для цыплят следующим образом:
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, но часто возникают споры о том, какую блокировку лучше использовать.
Актеры, с другой стороны, позволяют Swift максимально оптимизировать синхронизированный доступ. Базовая блокировка, которая используется, является просто деталью реализации. В результате компилятор Swift может обеспечить синхронизированный доступ, не позволяя нам в большинстве случаев вводить гонки данных.
Чтобы увидеть, как это работает, мы можем реализовать приведенный выше пример, используя нашего ранее определенного Актера кормушки для кур:
actor ChickenFeeder {
let food = "worms"
var numberOfEatingChickens: Int = 0
func chickenStartsEating() {
numberOfEatingChickens += 1
}
func chickenStopsEating() {
numberOfEatingChickens -= 1
}
}
Первое, что вы заметите, это то, что экземпляр стал намного проще и легче читается. Вся логика, связанная с синхронизацией доступа, скрыта как деталь реализации в стандартной библиотеке Swift. Однако самое интересное происходит, когда мы пытаемся использовать или прочитать любое из изменяемых свойств и методов:
То же самое происходит при доступе к изменяемому свойству numberOfEatingChickens:
Однако нам разрешено написать следующий фрагмент кода:
let feeder = ChickenFeeder()
print(feeder.food)
Свойство food в нашей кормушке для кур неизменяемо и, следовательно, потокобезопасно. Риск гонки данных отсутствует, так как его значение не может измениться из другого потока во время чтения.
Однако другие наши методы и свойства изменяют изменяемое состояние ссылочного типа. Для предотвращения гонок данных требуется синхронизированный доступ для обеспечения последовательного доступа.
Использование async/await для доступа к данным от Актеров
Поскольку мы не уверены, когда доступ разрешен, нам нужно создать асинхронный доступ к изменяемым данным нашего Актера. Если нет другого потока, обращающегося к данным, мы получим доступ напрямую. Однако, если есть другой поток, выполняющий доступ к изменяемым данным, нам нужно сидеть и ждать, пока нам не разрешат пройти.
В Swift мы можем создать асинхронный доступ, используя ключевое слово await:
let feeder = ChickenFeeder()
await feeder.chickenStartsEating()
print(await feeder.numberOfEatingChickens) // Prints: 1
Предотвращение ненужных приостановок
В приведенном выше примере мы обращаемся к двум различным частям нашего актера. Во-первых, мы обновляем количество съеденных цыплят, после чего выполняем еще одну асинхронную задачу, чтобы распечатать количество съеденных цыплят. Каждое ожидание может привести к приостановке вашего кода для ожидания доступа. В этом случае имеет смысл иметь две подвески, поскольку обе части на самом деле не имеют ничего общего. Тем не менее, вы должны принять во внимание, что может быть другой поток, ожидающий вызова ChickenStartsEating, что может привести к тому, что две курицы будут есть цыплят в то время, когда мы распечатываем результат.
Чтобы лучше понять эту концепцию, давайте рассмотрим случай, когда вы хотите объединить операции в один метод, чтобы предотвратить дополнительные приостановки. Например, представьте, что у нашего актера есть метод уведомления, который уведомляет наблюдателей о новом цыпленке, который начал есть:
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()
Однако это может привести к двум точкам приостановки, по одной для каждого ожидания. Вместо этого мы могли бы оптимизировать этот код, вызвав метод notifyObservers из ChickenStartsEating:
func chickenStartsEating() {
numberOfEatingChickens += 1
notifyObservers()
}
Поскольку мы уже находимся внутри Актера с синхронизированным доступом, нам не нужно еще одно ожидание. Это важные улучшения, которые следует учитывать, поскольку они могут повлиять на производительность.
nonisolated доступ внутри Актеров
Важно понимать концепцию изоляции в Актерах. В приведенных выше примерах уже показано, как синхронизируется доступ из внешних экземпляров актора, требуя использования ожидания. Однако если вы внимательно смотрели, то могли заметить, что наш метод notifyObservers не требует использования await для доступа к нашему изменяемому свойству numberOfEatingChickens.
При доступе к изолированному методу внутри акторов вам в основном разрешен доступ к любым другим свойствам или методам, которые требуют синхронизированного доступа. Таким образом, вы в основном повторно используете предоставленный вам доступ, чтобы получить от него максимальную отдачу!
Однако бывают случаи, когда вы знаете, что изолированный доступ не требуется. Методы в акторах по умолчанию изолированы. Следующий метод обращается только к нашему неизменному свойству 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)"
}
}
Однако определять их для неизменяемых свойств не нужно, как вам скажет компилятор:
Почему гонки данных все еще могут возникать при использовании актеров
При последовательном использовании Актеров в вашем коде вы наверняка снизите риски, связанные с гонкой данных. Создание синхронизированного доступа предотвращает странные сбои, связанные с гонками данных. Однако вам, очевидно, необходимо постоянно использовать их, чтобы предотвратить гонку данных в вашем приложении.
Условия гонки по-прежнему могут возникать в вашем коде, но больше не могут приводить к исключению. Это важно понимать, поскольку Актеры могут продвигаться как решающие все. Например, представьте, что два потока правильно обращаются к данным наших акторов, используя await:
queueOne.async {
await feeder.chickenStartsEating()
}
queueTwo.async {
print(await feeder.numberOfEatingChickens)
}
Состояние гонки здесь определяется как: «какой поток первым начнет изолированный доступ?». Таким образом, в основном есть два исхода:
- Поставьте в очередь первого, увеличивая количество поедаемых цыплят. Вторая очередь напечатает 1
- Очередь два становится первой, печатая количество съеденных цыплят, которое по-прежнему равно 0.
Разница здесь в том, что мы больше не обращаемся к данным во время их изменения. Без синхронизированного доступа в некоторых случаях это может привести к непредсказуемому поведению.