Многопоточность по шагам: системы с изменяемым состоянием

Это перевод статьи Concurrency Step-by-Step: Stateful Systems

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

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

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

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

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

«Удаленная» (‘remote’) система

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

final class RemoteSystem: @unchecked Sendable {
	private var state = false
	private let queue = DispatchQueue(label: "RemoteSystemQueue")

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

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

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

View

Теперь нам нужно View, которое действительно что-то делает с этой RemoteSystem.

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()
	}
}

Переменная body этого View не особенно интересна, но эта функция press— то, где происходит всё действие. Когда вы касаетесь изображения “звезды”, мы переключаем значение переменной state из RemoteSystem с помощью system.toggleState, затем считываем его с помощью system.toggleState, и, наконец, обновляем наш UI, чтобы отразить результат. Опять же, все происходит в ​​режиме Swift 6.

Примечание. На самом деле этот код в Swift 6 не компилируется:

A компилируется такой код:

import SwiftUI
 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 {
                Task {
                    await system.readState { value in
                        DispatchQueue.main.async {
                            self.state = value
                        }
                    }
                }
            }
        }
        var body: some View {
            Image(systemName: imageName)
                .imageScale(.large)
                .foregroundStyle(.tint)
                .onTapGesture {
                    press()
                }
                .padding()
        }
 }

Reentrancy

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

На самом деле может произойти следующая последовательность событий:

  • Пользователь нажимает на “звездочку”
  • toggleState начинается, но по какой-то причине работает медленно
  • Пользователь снова нажимает  на “звездочку”
  • Еще один toggleState начинается, но на этот раз работает быстро!
  • Затем его чтение завершается, и UI обновляется
  • Теперь первый toggleState наконец завершается
  • UI снова обновляется, отменяя последнее намерение пользователя

Существует много возможных вариаций этого. Но у нас есть эта проблема, потому что код НЕ атомарный. Функция press может снова начать выполняться до завершения предыдущего вызова. Функция может быть запущена (“enter”), а затем ее можно снова перезапустить (“re-enter”) потенциально множество раз. Это reentrancy!

Термин «reentrancy»

Мне не очень нравится термин «reentrancy». Во-первых, мне просто неудобно его произносить / читать. Во-вторых, исторически, когда говорят, что «функция является reentrant», часто подразумевают, что ее можно безопасно использовать одновременно из нескольких потоков. И, в-третьих, мне кажется, что это не очень хорошо выражает проблему.

Это просто обычное старое состояние гонки. Но в нашем случае это не гонка данных (data race). У нас нет несколько потоков (threads), читающих / пишущих в одни и те же места в памяти. Я предпочитаю называть такие вещи «логическими» гонками (“logicalraces). (Однако, если у вас есть более правильный термин, пожалуйста, дайте мне знать!)

Интересно, что для логических гонок (logical races) вам даже не нужно иметь несколько потоков. Вам просто нужен какой-то способ, чтобы несколько вещей происходили одновременно. Достаточно однопоточной программы с циклом выполнения. Как только у вас будет НЕ синхронное выполнение, у вас могут появиться логические  гонки (logical races).

Actors

Я решил использовать диспетчеризацию Dispatch исключительно для иллюстрации того, что вам не нужны акторы actors или Swift concurrency, чтобы столкнуться с такими проблемами. Тем не менее, люди много говорят о «actor reentrancy», особенно при работе с многопоточностью— и на то есть веская причина! Нам просто нужно проделать некоторую работу, прежде чем мы сможем рассмотреть это более подробно.

Первое, что мы сделаем, это создадим актора actor для моделирования нашей удаленной системы RemoteSystem.

actor RemoteSystem {
	private(set) var state = false

	init() {
	}

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

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

Однако, я думаю, что еще интереснее то, что мы используем актора  actor для моделирования нашей «удаленной системы».

Actor против service

Помните, я хотел найти простую «удаленную» службу (remote  service) . Поскольку она «удаленная», единственный способ взаимодействия с ней — через сетевые запросы. Акторы actor во многом похожи на удаленную службу, в том смысле, что единственный возможный способ взаимодействия с их внутренним состоянием — асинхронный.

Но это еще не все! Чтобы использовать сетевую службу, вы также должны упаковать и отправить любые параметры по сети. И, в свою очередь, эта служба должна сделать то же самое, чтобы вернуть вам любые результаты. Это отличный способ думать об акторах actor с точки зрения дизайна. Все входы inputs и выходы outputs также должны отправляться туда и обратно.

Конечно, удаленной службе нужно, чтобы данные были фактически сериализованы для передачи. С актором actor нам не нужна сериализация. Нам просто нужно убедиться, что эти ТИПы могут безопасно покидать любой поток/очередь, в котором они в данный момент находятся, чтобы «отправить» (“send”)) их актору  actor. Нам нужно, чтобы эти входы inputs и выходы outputs были Sendable.

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

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

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

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

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

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

Для начала нам нужно фактически запустить асинхронные async методы, так что нам нужна задача Task. Вспомните, что «вывод из контекста» (inference) изоляции isolation сделал этот метод press @MainActor. Это, в свою очередь, означает, что тело задачи Task также наследует @MainActor. Вот почему здесь можно присваивать значение self.state.

(Если вы еще этого не понимаете, подробности есть в первом посте. Вы также можете углубиться как в “вывод из контекста” (inference) изоляции isolation, так и в наследование (inheritance), если хотите.)

Я люблю заявлять об этом, потому что это не задача программиста — помнить или даже понимать, как или почему self.state должен быть доступен в основном потоке (main thread). Это требование закодировано в системе ТИПов, что позволяет компилятору гарантировать, что это происходит. Все это резко контрастирует с Dispatch версией, которая требует от разработчика знать, что ему нужен DispatchQueue.main.async для безопасности потоков (threads).

Но здесь есть еще более тонкие моменты. Если вы вернетесь и посмотрите на Dispatch версию вы увидите, что press выполняет вызов system.toggleState синхронно. Выполнение проходит весь путь от UI напрямую внутрь метода RemoteSystem, не выходя из основного потока (main thread).

Здесь это уже не так! Теперь мы ввели новый асинхронный шаг. Эта задача Task, которая устанавливает нужный нам асинхронный контекст, не будет запущена немедленно. Она должна быть запланирована в основной очереди (main queue).

(Есть много тонкостей вокруг создания задачи Task и порядка её выполнения. Однако в общем случае вы не должны думать о задаче Task с семантикой FIFO. Это основное различие между concurrency и Dispatch.)

Это не имеет особого значения в этом конкретном примере, но может стать серьезным изменением в порядке событий. Это то, о чем вы должны помнить каждый раз, когда вы вводите Task. И вдвойне думать, если вы делаете это как часть миграции с обработчиков завершения completionHandlers на async/await. Это была одна из первых серьезных проблем, с которыми я столкнулся, когда начал использовать Swift concurrency.

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

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

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

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

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

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

	usleep(delayRange.randomElement()!)
}

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

private func press() {
	Task {
		randomDelay() // start

		randomDelay() // enter
		await system.toggleState()
		randomDelay() // complete

		randomDelay() // enter
		let value = await system.state
		randomDelay() // complete

		self.state = value
	}
}

Их много!
Первая — это задержка, которую может испытывать Задача Task еще до начала выполнения кода. Это присуще практически всем API, подобным этому,  и что-то вроде метода DispatchQueue.async тоже будет испытывать ее. Очень хорошо всегда помнить об этом.
Далее у нас есть две пары задержек вокруг наших асинхронных вызовов await. Асинхронному вызову await может потребоваться некоторое время для начала. Затем он будет запущен и потребует некоторого времени для завершения.
Помните, эти задержки не являются искусственными. То, что мы делаем здесь, на самом деле просто увеличивает все реальные задержки в выполнении, чтобы помочь нам думать о них и наблюдать их последствия.

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

Сосредоточимся на гонке

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

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

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
		}
	}
}

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

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

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

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

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

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

Это body задачи Task всегда будет выполняться в основном потоке (main thread). Это означает, что хотя потенциально может быть запущено более одной задачи 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
	}
}

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

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

А как насчет акторов actor?

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

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

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

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

await system.toggleState()

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

Кроме того, как и наша проблема с UI выше, у акторов могут быть совершенно похожие проблемы внутри. Давайте представим себе немного более сложную версию toggleState.

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

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

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

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

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

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

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

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *