Подробно об акторах в Swift

Не зубрить, но разбираться

Мне нравится познавать внутреннее устройство концепций. Без этого их изучение больше походит на механическое запоминание.

Разберем на реальных, практических примерах такое понятие, как акторы Swift.

Что такое «актор»?

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

Рассмотрим простой пример: принтер, к которому имеют доступ все сотрудники в офисе.

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

Что, если распечатанные документы вы передадите руководителю не глядя? Окажись они не вашими, проблем не избежать.

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

Это похоже на то, как манипуляции с принтером одного сотрудника сказываются на заданиях печати всех остальных.

Рассмотрим конкретный пример кода:

class Account {
var balance: Int = 20// текущий баланс пользователя равен 20
...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

var myAccount = Account()
myAccount.withdraw(20$)

Здесь представлена простая операция списания средств со счета, предваряемая проверкой достаточности баланса. Сценарий аналогичен ситуации с принтером.

Использование принтера одним сотрудником  —  как и объекта Account в случае управления счетом одним потоком  —  не представляет проблем. Они возникают, когда несколько человек  —  а в аналогии программирования несколько потоков  —  пытаются использовать принтер одновременно.

Вернемся к примеру со счетом в многопоточной среде, такой как на современных мобильных устройствах с многоядерными процессорами.

Представьте, что функция списания withdraw(20$) выполняется на одном и том же объекте Account одновременно двумя потоками. Операционная система жонглирует ими, назначая процессорные ядра и управляя их выполнением, но точный порядок операций непредсказуем.

Первым потоком баланс проверяется, подтверждается его достаточность: balance >= amount. Но, прежде чем списывается сумма, происходит переключение контекста, и выполняется второй поток.

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

Сценарий опять же аналогичен ситуации, когда два сотрудника пытаются использовать один принтер. Если они не договорятся, отправленные ими команды печати перемешаются, приведя к путанице или потере распечаток.

В примере со счетом такое отсутствие координации чревато овердрафтом, ведь одна и та же сумма списывается дважды. Действительно, из-за недостаточности средств одна из операций списания должна была быть отклонена, но «благодаря» параллельному доступу выполнились обе.

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

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

Такие состояния гонки  —  серьезная проблема программирования, особенно в системах, где важны согласованность и точность данных. Чтобы операции выполнялись безопасно и предсказуемо, управлять общими ресурсами в многопоточной среде необходимо осторожно.

Взаимоблокировка: возникает, когда минимум два потока блокированы в бесконечном ожидании друг от друга освобождения ресурса.

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

Зависание: происходит, когда доступ к общим ресурсам потоку закрыт постоянно и он остается без продвижения.

Инверсия приоритетов: в потоке с низким приоритетом содержится ресурс, необходимый потоку с высоким приоритетом, но его выполнение задерживается, что косвенно приводит к задержке потока с высоким приоритетом.

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

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

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

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

Акторы

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

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

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

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

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

Но каков этот механизм работы на практике? Акторами обеспечивается то, что называют изоляцией данных.

Чтобы понять это, рассмотрим аналогию с офисным принтером. Поместим его в отдельную комнату и отключим от Wi-Fi. Теперь, чтобы что-то распечатать, придется физически зайти туда и дождаться своей очереди. Этим фактически предотвращается любое перекрытие заданий печати или конкурентное использование принтера, обеспечивается единовременный доступ к нему только одного сотрудника.

В контексте Swift работа акторов аналогична. Инкапсулированные в акторе данные изолируются от прямого доступа из других частей программы. Чтобы взаимодействовать с данными, любой код должен добраться до актора, фактически «заняв очередь» и дождаться, когда она до него дойдет. Это означает, что даже в конкурентной среде актором обеспечивается последовательный доступ к своим данным, гарантируя, что в любой момент времени с ними взаимодействует только одна часть кода.

Посмотрим, как эта концепция применяется в коде на практике.

В измененном примере со счетом Account переход от класса к актору очень прост: меняя в определении class на actor, делаем объект Account потокобезопасным. Это изменение, хотя и минимальное по синтаксису, сильно сказывается на доступе к объекту и манипулировании им в конкурентной среде.

actor Account {
var balance: Int = 20// текущий баланс пользователя равен 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

Действительно, переключиться на актор очень просто. Но это изменение небеспроблемное. Попытки после скомпилировать код чреваты ошибками. Обусловлено это природой акторов и их свойствами изоляции данных по аналогии с помещением принтера в отдельную комнату и отключением его от Wi-Fi.

Когда класс преобразуется в актор, прямой доступ к его свойствам и методам вне его контекста больше невозможен, как раньше. Чтобы обеспечить потокобезопасность, акторами применяется строгий контроль доступа. Поэтому все точки кода, где ранее получался доступ к объекту Account, теперь требуется обновить.

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

var myAccount = Account()
myAccount.withdraw(20$) // эта строка больше не действительна
await myAccount.withdraw(20$) // хорошо

Кросс-акторное обращение

В Swift акторами гарантируется изоляция данных: все изменяемые свойства и функции в акторе изолированы от прямого доступа извне. Это главная особенность акторов, важная для обеспечения потокобезопасности в конкурентном программировании. Но что подразумевает такая изоляция для доступа к этим свойствам и функциям, а также для их изменения?

Считать свойство, изменить значение или вызвать функцию актора напрямую  —  как с обычным классом или структурой  —  не получится: нужно дождаться своей очереди. Для этого на почтовый ящик актора отправляется запрос, который становится в очередь на обработку. Только когда подойдет его очередь, свойства актора считываются/изменяются или вызываются его функции.

Этот процесс называется кросс-акторным обращением. Обращаясь или получая доступ к чему-либо внутри актора извне его, вы выполняете кросс-акторное обращение. На практике это означает применение асинхронных шаблонов вроде async и await для взаимодействия с актором. Используя эти конструкции, код как бы говорит: «Мне нужно получить доступ к этому актору или изменить в нем кое-что. Вот мой запрос. Подожду асинхронно, пока не будет безопасно и удобно продолжить».

Кросс-акторным обращением называется любой доступ или взаимодействие с внутренним состоянием актора, например изменяемыми свойствами или функциями, извне области этого актора. Это взаимодействие может быть от другого актора или из кода, не связанного с актором.

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

actor Account {
var balance: Int = 20// текущий баланс пользователя равен 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

actor TransactionManager {
let account: Account

init(account: Account) {
self.account = account
}

func performWithdrawal(amount: Int) async {
await account.withdraw(amount: amount)
}
}

// Usage
let account = Account()
let manager = TransactionManager(account: account)

// Из актора «TransactionManager» выполняется списание
Task {
// кросс-акторное обращение
await manager.performWithdrawal(amount: 10)
}

// Извне какого-либо актора выполняется списание
Task {
// кросс-акторное обращение
await myAccount.withdraw(amount: 5)
}

В модели параллелизма Swift ключевое слово await  —  очень важный компонент, особенно при работе с акторами.

Интересно, что функцию списания withdraw в акторе явно отмечать с помощью async не пришлось. В акторе любая функция по умолчанию считается потенциально асинхронной, что обусловлено природой самих акторов. Все их взаимодействия с внешним миром в принципе асинхронны, поэтому любые кросс-акторные обращения предваряются await.

await как бы сигнализирует о потенциальной паузе в выполнении, это аналогично ожиданию своей очереди воспользоваться принтером. Среде выполнения Swift указывается, что в этом месте кода требуется приостановка выполнения, пока актор не будет готов обработать запрос. Такая приостановка происходит не всегда  —  если актор не занят другими задачами, выполнение кода немедленно возобновляется. Вот почему это называется «возможной» точкой приостановки.

Теперь применим это к сценарию одновременного списания средств двумя потоками в одном акторе Account, и реализация станет намного безопаснее и предсказуемее.

При наличии актора и await, даже если операционная система переключится с первого потока на второй посреди выполнения, второй поток не сразу перейдет к функции withdraw. Его выполнение приостановится в точке await в ожидании завершения первой операции.

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

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

Последовательное средство выполнения

В каждом акторе Swift имеется внутренняя очередь последовательной, одной за другой, обработки задач или почты в почтовом ящике актора. Эта внутренняя очередь актора  —  последовательное средство выполнения  —  в чем-то аналогична последовательной DispatchQueue. Но между ними имеются принципиальные различия, особенно в порядке выполнения задач и базовых реализациях.

Одно существенное различие заключается в том, что задачи, ожидающие последовательного средства выполнения актора, не обязательно выполняются в том порядке, в котором ожидались. Это отклонение в поведении последовательной DispatchQueue, которая придерживается строгой политики FIFO, first-in-first-out, согласно которой задачи выполняются именно в том порядке, в котором получены.

С другой стороны, средой выполнения актора Swift применяется более легкий и оптимизированный механизм очереди, чем Dispatch, адаптированной для использования возможностей асинхронных функций Swift. Это различие проистекает из фундаментальной природы средств выполнения по сравнению с DispatchQueues.

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

Этот нюанс планирования и выполнения задач, различающий последовательные средства выполнения и последовательные DispatchQueue, лежит в основе гибкости и эффективности модели акторов Swift. При управлении задачами, особенно в среде параллельного программирования, средства выполнения динамичнее.

Правила

  1. Получая в акторах доступ к свойствам только для чтения, мы не нуждаемся в await, поскольку их значения неизменяемы.
actor Account {
**let accountNumber: String = "IBAN---"**
var balance: Int = 20// текущий баланс пользователя равен 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

**/// Все нормально ✅**
let accountNumber = account.accountNumber
Task {
**/// Все нормально ✅**
let balance = await account.balance

**/// Не нормально ❌**
let balance = ****account.balance // Ошибка
}

2. Изменять изменяемые переменные из кросс-акторного обращения запрещено, даже с await.

/// Не нормально ❌
account.balance = 12 // Ошибка

/// Не нормально ❌
await account.balance = 12 // Ошибка

Обоснование: наборы кросс-акторных свойств поддерживать можно. Нельзя поддерживать кросс-акторные операции ввода-вывода, так как между get и set возникает неявная точка приостановки, чреватая фактически состояниями гонки. Более того, с асинхронным заданием свойств становится проще случайно нарушить инварианты, если, например, для поддержания инварианта нужно обновить сразу два свойства.

3. Все функции, изолированные в акторе, должны вызываться с ключевым словом await.

Неизолированные части

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

actor Account {
let accountNumber: String = "IBAN..." // Постоянное неизолированное свойство
var balance: Int = 20 // Текущий баланс пользователя равен 20
// Неизолированная функция
nonisolated func getMaskedAccountNumber() -> String {
return String.init(repeating: "*", count: 12) + accountNumber.suffix(4)
}
func withdraw(amount: Int) {
guard balance >= amount else { return }
self.balance = balance - amount
}
}
let accountNumber = account.**getAccountNumber()**

Здесь accountNumber  —  это постоянное свойство let в акторе Account, оно неизменяемо. Неизменяемость делает его потокобезопасным, избавляет от необходимости в изоляции. В итоге доступ к accountNumber получается синхронно, без ключевого слова await, хотя это свойство является частью актора.

И наоборот, balance  —  изменяемое свойство, требующее изоляции в акторе. Любое взаимодействие с balance, как в функции withdraw, должно соответствовать протоколам изоляции актора, часто требующим асинхронного доступа.

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

Выводы

  1. Акторы для управления параллелизмом. Мы представили акторы в Swift 5.5 как важнейшую часть модели параллелизма, предназначенную специально для безопасной обработки общего изменяемого состояния и предотвращения гонок данных.
  2. Устранение проблем параллелизма. Мы обсудили типичные проблемы параллелизма, такие как состояния гонки, обычные и динамические взаимоблокировки, а также продемонстрировали, как их последствия смягчаются акторами. Раньше такие проблемы решались с помощью DispatchQueue, операций и блокировок.
  3. Потокобезопасность и последовательное выполнение. Потокобезопасность в приложениях Swift повышается благодаря акторам, которые функционируют в роли диспетчеров очередей. Задачи обрабатываются акторами последовательно, одна за другой во внутренней очереди, так удается избегать конфликтов параллелизма.
  4. Кросс-акторное обращение. Это важное понятие. Чтобы получить доступ к свойствам или методам актора, отмечая потенциальные точки приостановки для эффективного управления задачами, ему требуется ключевое слово await.
  5. Последовательные средства выполнения против DispatchQueues. Мы выделили ключевое различие между последовательным средством выполнения актора и последовательной DispatchQueue. Акторы не привязаны к строгому порядку first-in-first-out. В отличие от DispatchQueues, задачи приоритизируются ими на основе различных факторов.
  6. Правила взаимодействия с акторами. Мы описали конкретные правила взаимодействия с акторами, акцентировав внимание на асинхронном доступе к изменяемым свойствам и необходимости ожидания изолированных функций.
  7. Неизолированные части акторов. Мы представили понятие неизолированных частей внутри актора. Особенно кстати это для получения синхронного доступа к определенным свойствам или методам без ущерба для потокобезопасности.
  8. Практические примеры для лучшего понимания. С помощью примера Account мы разобрали на практике, как применять акторы эффективно для безопасного поддержания состояния в средах параллельного программирования.

Заключение

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

Акторами устраняются типичные проблемы параллелизма: состояния гонки, обычные и динамические взаимоблокировки. Этот подход оптимизированнее и эффективнее по сравнению с традиционными методами, такими как DispatchQueue, операции и блокировки. С акторами управлять параллелизмом стало значительно проще, доступнее и с меньшей вероятностью ошибок для разработчиков.

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Valentin Jahanmanesh: Swift Actors — in depth

Предыдущая статьяПрограммирование на Java. Глубокое погружение в ключевой функционал Java 21
Следующая статьяПересечение 3D-лучей (ближайшая точка)