Это перевод статьи The Swift Actor Pitfall: Understanding and Managing Reentrancy.
Введение
Сегодня я обсуждаю эту важную тему “Подводные камни Swift Actor”, так что если вы работали со Swift довольно долго, скорее всего, вы сталкивались с этим. Позвольте мне дать вам быстрое определение акторов actor
— это ссылочный (reference) ТИП, похожий на класс class
, но в отличие от классов позволяет только одной задаче получать доступ к своему изменяемому состоянию в один конкретный момент времени.
Так что, как мы все знаем, гонка данных (data race) происходит только тогда, когда несколько задач пытаются получить доступ к одному и тому же изменяемому состоянию в одно и то же время. Это означает что при использовании акторов actor
мы можем эффективно предотвратить гонки данные. Так ли это?
К сожалению, ответ “Нет” и причина в том, что есть actor reentrancy. Что собой в действительности представляет собой actor reentrancy и чем же actor reentrancy отличается от данных гонки (data race)?
Чтобы ответить на все эти вопросы давайте сделаем быстрое сравнение между гонками данных (data race) и actor reentrancy.
Понимание Actor Reentrance
Допустим у нас есть класс class Dummy
с переменной var x
и функцией func change ()
, которая имеет доступ к значению x
:
Так как классы class
не отслеживают безопасное изменение своих переменных, то на этой диаграмме вы видите, что две задачи Task 1
и Task 2
пытаются вызвать функцию change ()
в одно и то же время, и происходит гонка данных (data race), потому что обе задачи пытаются получить доступ к значению x
в одно и то же время.
Теперь давайте создадим наш класс class Dummy
и актор actor Dummy
, и посмотрим, как они функционируют в похожей ситуации, когда две задачи Task 1
и Task 2
пытаются вызвать изменение в одно и то же время:
Вы видите, что в случае с актором actor
сначала будет выполнена задача Task 1
, а задача Task 2
будет ждать завершения задачи Task 1
, и когда задача Task 1
завершится, выполнив функцию change ()
, задача Task 2
продолжит. Этот механизм по сути предотвратит доступ к значению x
в одно и то же время для обеих задач. Именно так так акторы actor
предотвращают гонки данные (data races).
Точка приостановки Suspension poin
Теперь давайте немного усложним ситуацию, добавив асинхронный вызов в функции change ()
с помощью await
:
Делая это, мы, по сути, добавляем точку приостановки Suspension point внутри функции change ()
.
Теперь, что произойдет, когда две задачи Task 1
и Task 2
попытаются вызвать функцию change ()
в одно и то же время?
Глядя на диаграмму, где розовая пунктирная линия представляет собой точку приостановки Suspension point, вы можете видеть, что задача Task 1
войдет в функцию change ()
, достигнет точки приостановки Suspension point и будет приостановлена. Когда задача Task 1
приостановлена, задача Task 2
войдет в функцию change ()
.
Такого рода ситуация, когда задача Task 2
входит в функцию change ()
до того, как задача Task 1
была завершена, и называется reentrancy, и, если вы внимательно посмотрите на диаграмму, вы заметите, что доступ к x
всегда последовательный (sequential) как до, так и после точки приостановки Suspension point, это показывает нам, что даже когда происходит reentrancy, актор actor
все еще делает то, что должен делать, а именно предотвращает гонки данных (data race).
Хорошо, теперь, когда вы поняли, что такое reentrancy актора actor
, позвольте мне показать вам возникшую проблему, которую он может привносить.
Пример с банковским счетом actor BankAccount
Рассмотрим актор банковского счета actor BankAccount
с переменной var balance
и функцией func withDraw
:
… , в которой мы сначала проверяем баланс balance
:
… а затем авторизуем транзакцию с помощью функции authorizeTransaction ():
… и после этого вычитаем из баланса balance
снимаемую ср счета сумму amount
:
Обратите внимание, что функция авторизованной транзакции authorizeTransaction ()
является асинхронной async
функцией, что делает ее точкой приостановки в функции withdraw(_ amount: Int)
:
Теперь давайте увеличим масштаб и сосредоточимся на этой функции withdraw (_ amount: Int)
. Допустим, у нас есть два запроса на списание суммы, происходящие в одно и то же время, первый Task 1
на $800 и второй Task 2
на $500 с балансом счета $1 000:
Мы ожидаем, что первая транзакция Task 1
пройдет, a вторая Task 2
будет отклонена из-за недостаточного баланса, но так не происходит.
На самом деле вот что произошло.
Задача Task 1
, которая снимает $800 начинает выполняться, она проверяет баланс balance
, а затем переходит к авторизации транзакция, потому что баланс достаточен. И на этом этапе из-за reentrancy задача Task 2
войдет в функцию withdraw(_ amount: Int)
и выполнит свою собственную проверку баланса balance
м, и так как баланс balance
все еще $1000 на этом этапе, задача Task 2
также будет выполнена, она авторизует транзакцию.
Теперь после того, как задача Task 1
получит авторизацию, она будет выполнена и снимет 800 $ со счета. Затем следует задача Task 2
и снимет $500 со счета, в результате чего мы получим отрицательный баланс balance
-$300 на счете.
И очевидно, что это не то, что мы хотим, что-то пошло неправильно. Основная причина кроется в наличии точки приостановки Suspension point, вызывающей reentrancy.
Задача Task 2
выполняет проверку баланса до того, как задача Task 1
завершила всю транзакцию. Это позволило задаче Task 2
пройти пройти проверку баланса, даже если баланс недостаточен.
Эта конкретная последовательность приводит к нежелательному отрицательному результирующему балансу счета.
Так как мы можем определить потенциальный reentrancy?
Первой проверкой является наличие точки приостановки Suspension point, reentrancy возникает только тогда, когда есть точка приостановки Suspension point
Во второй проверке необходимо убедиться, что нет доступа к изменяемому состоянию до и после точки приостановки Suspension point, если это так, то скорее всего у нас происходит reentrancy в акторах actor
.
Actor с эффективным предотвращением reentrancy
Как мы можем спроектировать актор actor
, чтобы эффективно предотвратить reentrancy?
Один подход, который мы можем использовать — это всегда обращаться к изменяемому состоянию синхронно, то есть без чтения или записи через точку приостановки Suspension point.
Так что для нашего примера с банковским счетом BankAccount
прямо сейчас мы можем авторизовать транзакцию authorizeTransaction()
перед проверкой баланса, чтобы обеспечить синхронный доступ к изменяемому состоянию, которым является баланс balance
:
Но в некоторых случаях это может быть невозможно, так что в такой ситуации мы можем перепроверить состояние актора actor
после точки приостановки Suspension point.
Нам нужно убедиться, что ничего не поменялось, пока задача была приостановлена, поэтому для нашего примера все, что нам нужно сделать, это перепроверить баланс счет balance
после авторизации транзакции authorizeTransaction ()
.
Ключевые выводы
Акторы actor
предотвращает гонки данных (data race), но это не защищает нас от проблем, вызванных reentrancy.
Вы можете проверить ситуацию с reentrancy, найдя точку приостановки Suspension point и проверив доступ к изменяемому состоянию через точку приостановки. Всегда конструируйте акторы actor
так, чтобы доступ к изменяемому состоянию был синхронным.
Если это невозможно, то перепроверьте изменяемое состояние актора actor
после точки приостановки.
Вы можете узнать больше об акторах actor
и reentrancy в этой статья https://swiftsenpai.com/Swift/actor-reentrancy-problem/.
Проблема reentrancy проявляется не только в акторах actor
, но и в GCD.