Найти в Дзене
Chidorin

Concurrency шаг за шагом: системы состояний

Знаете что? Придумать примерный материал сложно. Это может показаться глупым или несущественным, но это действительно важно! Естественно, хороший пример помогает понять суть дела. Он также делает написание более приятным. Но письмо все равно требует много работы. Именно поэтому я так разозлился, когда Джеймс Демпси прислал мне отличную идею. Он отметил, что оба первых сообщения использовали исключительно систему только для чтения. Это совершенно нереально! Реальные приложения записывают данные в локальное хранилище, удаленные службы и вообще погружаются в изменяемое состояние. В этом посте мы создадим приложение SwiftUI, которое будет работать с состоянием, размещенным на воображаемой удаленной сетевой службе. Как и во всех предыдущих статьях этой серии, я игнорирую ошибки и требую Xcode 16. Я также остаюсь новичком в SwiftUI, и здесь больше SwiftUI, чем в других статьях. Еще один важный момент заключается в том, что я пытался найти подходящую бесплатную удаленную службу, но безуспеш
Оглавление

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

Именно поэтому я так разозлился, когда Джеймс Демпси прислал мне отличную идею.

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

В этом посте мы создадим приложение SwiftUI, которое будет работать с состоянием, размещенным на воображаемой удаленной сетевой службе.

Краткие заметки

Как и во всех предыдущих статьях этой серии, я игнорирую ошибки и требую Xcode 16. Я также остаюсь новичком в SwiftUI, и здесь больше SwiftUI, чем в других статьях. Еще один важный момент заключается в том, что я пытался найти подходящую бесплатную удаленную службу, но безуспешно. Сначала я думал, что это конец света. Но потом понял, что эта неудача могла быть счастливым случаем. Читайте дальше, чтобы узнать почему.

Воображаемая система

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

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

Я выбрал реализацию с использованием диспетчера очередей. Чтобы это работало со Swift 6, мы должны уведомить компилятор о том, что мы взяли на себя ответственность за безопасность потоков, пометив тип как @unchecked Sendable. Нам также нужны несколько замыканий @Sendable.

Представление

Теперь нам нужно представление, которое фактически использует эту систему.

struct ContentView: View {
@State private var system = RemoteSystem()
@State private var state = false

private var imageName: String {
state ? "star.fill" : "star"
}

private func press() {
system.toggleState {
system.readState { value in
DispatchQueue.main.async {
self.state = value
}
}
}
}

var body: some View {
Image(systemName: imageName)
.imageScale(.large)
.foregroundStyle(.tint)
.onTapGesture {
press()
}
.padding()
}
}

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

Повторный вход

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

Пользователь касается экрана
начало выполнения метода toggleState, но оно замедляется по какой-либо причине
пользователь снова касается экрана
запускается еще один метод toggleState, но на этот раз он быстрее!
после завершения этого метода и чтения состояния обновляется пользовательский интерфейс
наконец, завершается первый метод toggleState
обновляется пользовательский интерфейс снова, отменяя первоначальное намерение пользователя

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

Это называется повторным входом!

Термин "повторный вход"

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

Это всего лишь обычная гонка условий. Но это не гонка данных. У нас нет нескольких потоков, которые одновременно читают или записывают одни и те же участки памяти. Я предпочитаю называть такие вещи "логическими гонками". (Если существует более правильный термин, пожалуйста, дайте мне знать!)

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

Актеры

Я решил использовать диспетчер исключительно для иллюстрации того, что вам не нужны актеры или Swift Concurrency для возникновения таких проблем. Тем не менее, люди часто говорят об "актерском повторном входе", особенно когда речь идет о конкурентности — и на то есть веские причины! Нам просто нужно проделать некоторую работу, прежде чем мы сможем рассмотреть это подробнее.

Первое, что мы сделаем, — это превратим нашу систему в актера.

actor RemoteSystem {
private(set) var state = false

init() {}

func toggleState() {
self.state.toggle()
}
}

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

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

Актер против сервиса

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

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

Конечно, удаленной службе требуется, чтобы данные были сериализованы для передачи. С актером нам не нужно выполнять сериализацию. Мы просто должны убедиться, что они безопасны для перемещения типов из текущего потока или очереди в актер. Эти входы и выходы должны быть Sendable. Актеры и удаленные сетевые службы не взаимозаменяемы. Но они могут быть очень похожими! И я думаю, что полезно думать о них аналогичным образом.

(Эта схожесть с удаленными службами — это именно то, откуда берется идея распределенных актеров.)

Использование актера

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

private func press() {
Task {
await system.toggleState()
self.state = await system.state
}
}

Снижение объема кода вновь значительно. Но этот небольшой объем кода включает в себя многое! Происходит много разных вещей, и мы шаг за шагом рассмотрим каждую из них.

Во-первых, нам нужно фактически запускать асинхронные методы, а это значит, что нам нужен объект Task. Напомним, что благодаря изоляции инференса функция press теперь имеет модификатор @MainActor. Это, в свою очередь, означает, что тело задачи также наследует модификатор @MainActor. Именно поэтому вполне допустимо присваивать значение полю self.state здесь.

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

Есть еще одна тонкость, которую я хочу подчеркнуть. Если вы посмотрите на версию с использованием диспетчера, то увидите, что функция press синхронно вызывает метод system.toggleState. То есть выполнение проходит напрямую от пользовательского интерфейса в метод RemoteSystem без выхода из основного потока.

Теперь ситуация изменилась. Мы добавили новый асинхронный шаг. Этот объект Task, который устанавливает контекст async, не запустится немедленно. Его нужно запланировать на основной очереди.

(Существует множество нюансов вокруг создания и порядка выполнения задач. Однако в общем случае не следует думать о задачах как имеющих семантику FIFO. Это важное отличие между конкурентностью и диспетчеризацией).

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

Мы вернемся к этой идее, но миграция системы с обработчиков завершения на асинхронные функции — это не чисто изменение синтаксиса.

Добавление задержек

Поскольку мы теперь начинаем работу асинхронно, мы фактически увеличили окно возможностей для нашей логической гонки. В этом смысле ситуация определенно ухудшилась! Но я не думаю, что это так уж плохо. На самом деле, сталкиваясь с логическими гонками (известными или предполагаемыми), я иногда намеренно делаю подобные вещи. Замедление выполнения кода часто позволяет выявить скрытые баги, такие как эти. Многие гонки маловероятны, возможно, практически невозможны при нормальном выполнении кода.

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

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

func randomDelay() {
let delayRange: Range<UInt32> = 0..<1_000_000
usleep(delayRange.randomElement()!)
}

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

(Подробности о sleep и usleep см. в руководствах).

Вот код с добавленными задержками:

private func press() {
Task {
randomDelay() // начало
randomDelay() // вход
await system.toggleState()
randomDelay() // выход
let value = await system.state
randomDelay() // выход
self.state = value
}
}

Задержек довольно много!

Первая — это латентность, которую объект Task может испытывать перед началом выполнения. Это присуще большинству API такого рода, и метод очереди async тоже испытал бы такую задержку. Очень важно всегда держать это в уме.

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

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

(Понимание деталей о том, когда/почему/как ожидание может фактически приостановить выполнение, является сложным. К счастью, вам редко приходится беспокоиться об этом. Но я бы с удовольствием написал об этом однажды).

Фокус на гонке

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

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

struct ContentView: View {
@State private var inProgress = false
//...

private func press() {
if inProgress { return }
self.inProgress = true
Task {
await system.toggleState()
self.state = await system.state
self.inProgress = false
}
}
}

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

Просто!

Критические разделы

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

Проверка должна быть синхронной.

Вот альтернативная версия, где проверки находятся внутри объекта Task. Я хочу, чтобы вы серьезно задумались над этим вопросом, но есть ли здесь еще гонка?

private func press() {
Task {
if inProgress { return }
self.inProgress = true
await system.toggleState()
self.state = await system.state
self.inProgress = false
}
}

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

Можно спорить о том, какое решение лучше, но эта версия также свободна от гонок.

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

private func press() {
Task {
if inProgress { return }
await system.prepare()
self.inProgress = true
await system.toggleState()
self.state = await system.state
self.inProgress = false
}
}

Я просто добавил сюда новый асинхронный вызов. Но теперь становится ясно, в чем проблема. Мы проверяем состояние. Мы ошибочно полагаем, что состояние не может измениться после вызова prepare(), но оно вполне могло измениться!

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

Что насчет актеров?

Давайте еще раз взглянем на часть нашего оригинального класса RemoteSystem, использующего диспетчеризацию.

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

func toggleState() {
self.state.toggle()
}

func toggleState(completionHandler: @escaping () -> Void) {
queue.async {
self.state.toggle()
completionHandler()
}
}

Вызов await system.toggleState()

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

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

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

func toggleState() async {
await initializeStateIfNeeded()
self.state.toggle()
}

Теперь у нас есть другая логическая гонка, потому что возможно, что более одного вызывающего абонента одновременно вызовут toggleState. Актеры не являются атомарными, что может привести к многократному вызову initializeStateIfNeeded.

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

Подведение итогов

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

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

О, и если вы боретесь с управлением логическими гонками внутри актера, вы не одиноки. Многие люди, включая меня, сталкивались с проблемами здесь. Существуют асинхронные версии блокировки и семафора, которые оказались полезными. Но если вы можете поддерживать вещи даже проще, это может быть лучшим решением. Первый шаг, когда возникают проблемы с актерами, — это убедиться, что вам действительно нужен актер в первую очередь. Помните, однако, что даже однопоточный код (который работает только с MainActor) все равно может столкнуться с логическими гонками.

И вы не сможете избавиться от асинхронности настоящих сетевых сервисов, поэтому диспетчер очередей — это то, от чего мы никогда не сможем избавиться. К счастью, зачастую асинхронный поток (AsyncStream) может справиться с задачей.

Я подозреваю, что многие люди хотят углубиться в работу с асинхронным изменением состояния. Это всего лишь поверхностный обзор. Я получаю столько вопросов о SwiftData в частности, и я никогда не использовал его! Но я думаю, что мы рассмотрели много важных тем! Способность распознавать логические гонки и мыслить о синхронном управлении состоянием важны независимо от того, используете ли вы Swift Concurrency.

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

Источник