Программирование
Погружение в Акторы в Swift 5.5
Давайте посмотрим на изменения в параллелизме в новой версии Swift.
С новыми изменениями, связанными с параллелизмом, которые появятся в Swift 5.5, язык упрощает написание параллельного кода. Существует огромное количество предложений, которые были приняты в версии 5.5, есть масса новинок, которые нужно изучить и к которым привыкнуть.
Одна из новых функций, которые появятся в новом релизе Swift, — это доступность нового примитива, называемого Актором (actor — актер, действующее лицо, деятель). И прежде, чем мы начнем их использовать, давайте попробуем понять, что они из себя представляют и какие изменения вносит Swift для поддержки этой модели «Акторов» в языке.
Статья будет разбита на два основных раздела. В первом разделе мы попытаемся понять, что такое Акторы, какова основная проблема, которую они пытаются решить, и как они ее решают. Затем мы рассмотрим, как Swift представляет нам Акторов.
Параллелизм и модель Акторов
Конкурентность и параллелизм в программировании — это очень эффективные способы убедиться, что ваша программа использует преимущества аппаратного и программного обеспечения процессора, чтобы ваша программа работала с максимально возможной скоростью. Но с большой силой приходят и большие обязанности!
Одна из самых больших проблем параллельных систем — это проблемы общего состояния. Чтобы быть более конкретным, управление общим состоянием часто приводит к двум типам проблем в параллельной системе:
- Гонка данных — когда два или более потока пытаются получить доступ (по крайней мере один доступ является записью) к одному ресурсу, что вызывает несогласованность данных.
- Состояние гонки — из-за недетерминированного выполнения фрагмента кода на общем ресурсе результат в различных сценариях оказывается непредсказуемым. В основном, поскольку порядок выполнения не определен, он часто приводит к разным результатам.
Между ними есть разница, но, вообще говоря, они возникают из-за того, что есть какой-то доступ к общему состоянию. Как правило, гораздо проще обнаруживать гонки данных, но очень сложно отлаживать состояние гонки, потому что первые могут быть воспроизведены, а вторые не могут воспроизводиться каждый раз.
Существуют различные модели параллелизма, которые помогают нам решать проблемы гонки данных (например, блокировки и мьютексы, сериализованный доступ к общим данным и т.д.).
Swift пытается избежать этой проблемы, поощряя нас использовать семантику значений (то есть структуры и перечисления), поскольку их, как правило, легче использовать в параллельных средах.
Но даже семантика значений не помогает во всех случаях (либо потому, что они не имеют смысла для использования в таком контексте, либо из-за неправильной реализации), и в конечном итоге вы используете какой-то механизм синхронизации, такой как блокировки или последовательные очереди. Здесь нам на помощь приходит новая модель Акторов.
Модель актора — это модель параллелизма, в которой актор представляет собой новую примитивную структуру, которая поддерживает и защищает локальное состояние, будучи единственным, кто может вносить в него изменения/мутации. Любой внешний член может просто попросить актора действовать в соответствии с его состоянием, и актор обеспечит синхронизацию всех запросов на доступ/изменение его состояния.
Актеры обладают следующими характеристиками:
- Имеют собственное изолированное состояние.
- Может содержать логику для изменения собственного состояния.
- Может общаться с другими участниками только асинхронно (через их адреса).
- Может создавать других дочерних акторов (в данном случае нас это не особо волнует).
Одно из лучших объяснений ELI5 для общения в модели Акторов выглядит следующим образом:
Представьте, что каждый актор похож на остров, а наша кодовая база — это мир с островами. Каждый остров может общаться с другим островом, отправляя ему сообщения в бутылке. Каждый остров знает, куда отправить сообщение (то есть адрес другого острова), и именно так работает связь между островами.
Хотя теория, лежащая в основе модели акторов, гораздо больше (смотрите различные ссылки внизу статьи), мы рассмотрим, как эта модель работает в Swift.
Акторы в Swift
Swift 5.5 вводит новое ключевое слово под названием «актор». Так же, как вы можете определить класс или структуру, теперь вы можете определить актор.
Эти субъекты могут соответствовать протоколам и функционировать как любые примитивы (за исключением наследования, которое в настоящее время не поддерживается). Единственное отличие состоит в том, что взаимодействие между разными участниками происходит асинхронно.
Один из наиболее часто используемых примеров при объяснении параллелизма — это пример внесения/снятия денег с банковского счета. Итак, давайте продолжим и определим актора для BankAccount:
В приведенном выше примере, если BankAccount был бы определен как класс, а не как актор, переменная balance могла бы считаться небезопасным «изменяемым состоянием» (mutable state) в BankAccount, что могло бы привести к потенциальным ситуациям гонки данных в параллельной среде. Но теперь, когда BankAccount определен как actor, переменная баланса защищена от гонки. Посмотрим как.
Изоляция акторов
Теперь защита от гонки данных, описанная выше, осуществляется с помощью концепции, называемой изоляцией акторов. Это просто термин, используемый для определения нескольких правил, касающихся того, как должен работать доступ между участниками (как функциями, так и свойствами). Правила следующие:
- Актор может читать свои собственные свойства или вызывать свои функции (т.е. используя self) синхронно.
- Актор может обновлять только свои собственные свойства (и может делать это синхронно). Это означает, что вы можете обновлять свойства только с помощью ключевого слова self. Попытка обновить свойство другого актера приведет к ошибке компилятора.
- Считывание свойств между участниками или вызовы функций должны происходить асинхронно с использованием ключевого слова await. Однако перекрестное чтение неизменяемых свойств может происходить синхронно (тех, что объявлены с помощью let).
Повторный вход в актор
Выполнение функций в акторах повторяется. Под повторным входом я подразумеваю тот факт, что среда выполнения может повторно войти в выполнение кода в точке приостановки и продолжить работу оттуда. Давайте посмотрим на это на примере:
Допустим, вы пытались закрыть свой банковский счет, и для этого вам необходимо связаться с серверами вашего банка. Мы предпринимаем следующие шаги:
- Проверяем, открыт ли аккаунт. Нет смысла закрывать уже закрытый аккаунт.
- Сообщаем на серверы банка, что счет запрашивает закрытие (этот шаг может занять время).
- Проверяем, открыт ли еще счет. Если счет все еще открыт, закрываем счет и возвращаем остаток. В противном случае выдаем ошибку, сообщающую, что во время сетевого запроса был произведен другой запрос на отмену, который, вероятно, уже закрыл учетную счет.
Повторный вход в акторы определяется тем фактом, что функция может быть приостановлена на полпути на некоторое время, в то время как поток, выполнявший функцию, выполняет некоторые другие задачи, а затем возобновляет функцию с точки приостановки.
Например, в этом вызове для закрытия банковского счета вы можете «приостановить» свой код на некоторое время (пока он обменивается данными с серверами банка), заставить тот же поток выполнять некоторую другую работу, а затем «возобновить» выполнение работать с того места, где он остановился, как только вы получите ответ от серверов банка.
Есть небольшое, но важное замечание относительно строки 8, где происходит «приостановка» работы текущего потока (то есть строки, в которой происходит вызов ожидания).
Помните, что каждый вызов await — это потенциальная точка приостановки вашего кода.
Пока серверы банка не ответят, этот поток может выполнять другую незавершенную работу, которую, вероятно, запланировал наш код, или какую-то новую работу, которую запрашивает код. Мы можем отправить запрос на withdraw (снятие) денег, deposit (внесение денег) или другой запрос cancellation (на отмену), и это в конечном итоге будет запущено в этом потоке.
Теперь, когда сервер банка ответит, состояние актора может отличаться от состояния до точки приостановки. Это очень важный момент, так как вы должны осознавать тот факт, что вы не можете делать предположения о состоянии вашего актера до и после вызова функции await.
Это единственная причина, по которой я еще раз проверил, открыта ли еще учетная запись, в строке 9 (после вызова await), потому что вполне возможно, что второй вызов отмены мог быть выполнен и завершен, а счет уже был закрыт.
Следовательно, рассуждая о повторном вхождении в актор, вам нужно помнить две вещи:
- Всегда пытайтесь выполнить изменение состояния в синхронном коде (избегайте вызовов асинхронных async функций в функциях, в которых вы меняете внутреннее состояние).
- Если вам необходимо выполнить вызовы асинхронных функций внутри функции, которая изменяет состояние, не делайте никаких предположений о состоянии после завершения await.
@MainActor
Apple предлагает вызывать весь код пользовательского интерфейса в основном потоке. Поэтому всякий раз, когда нам нужно выполнить тяжелую обработку данных или сделать сетевой вызов для получения данных для отображения нашего пользовательского интерфейса, мы делаем это в фоновых потоках. После завершения обработки мы обычно делаем следующее:
Swift 5.5 использует новый property wrapper, называемый @MainActor. Эта аннотация гарантирует, что любой доступ для чтения и записи к свойствам, аннотированным с помощью этой обертки, будет происходит в основном потоке (таким образом убираются все вызовы DispatchQueue.main).
Вы можете аннотировать свойства, функции и определения классов/структур с помощью этого property wrapper.
Классы UIKit (например, UILabel, UIView и т.д.) уже отмечены этой оберткой. Таким образом, вы можете быть уверены, что они всегда будут доступны в основном потоке.
Единственная загвоздка заключается в том, что эти члены будут доступны только в основном потоке при использовании новых вызовов async/await — а не при использовании обработчиков завершения. Вот фрагмент кода, который поможет вам лучше понять это:
Если вы запустите этот фрагмент кода и установите точку останова в строке 11/12, вы увидите, что вызов из DispatchQueue.global не будет выполняться в основном потоке, но вызов из asyncDetached будет выполняться в основном потоке.
Акторы в Swift 5.5: заключение
Как вы увидели, акторы — определенно необходимое дополнение к такому современному языку, как Swift. Я уверен, что вся система будет продолжать развиваться, и в Swift может быть много обновлений параллелизма. С учетом сказанного, основные моменты, которые вам нужно вынести из этой статьи, следующие:
- Акторы — это еще один метод, который вы можете использовать для решения проблемы гонки данных, которая возникает в параллельных системах.
- Акторы используют концепцию изоляции, чтобы предотвратить гонку данных.
- Несмотря на то, что акторы помогают вам с гонками данных, все же есть точки разногласий, в которых могут возникнуть условия гонки. Следовательно, вам следует убедиться, что вы не делаете никаких предположений о состоянии актора всякий раз, когда вводите точки приостановки.
- Использование @MainActor может помочь вам получить доступ к свойствам в основном потоке без вызовов DispatchQueue.main, но только с использованием новой системы вызовов async/await.
Ссылки
- SE-0303 Proposal
- WWDC Talk on Actors
- WWDC Talk on the implementations of Actors
- Doug Gregor’s talk on Swift Concurrency — Swift By Sundell