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

Параллелизм шаг за шагом: чтение данных из хранилища

Не так давно я перечитывал вводную статью, которую написал ранее. Честно говоря, мне было трудно дочитать её до конца. Думаю, большая часть проблемы заключалась в том, что моё понимание того, что такое «введение» в контексте параллелизма, сильно изменилось. Когда я читал эту статью, я представлял себе новичка, который делает то же самое. Это было неловко! Я не собираюсь удалять этот пост, но чувствую себя не очень хорошо по этому поводу. Тем не менее, я всё ещё считаю, что крайне важно уделять больше внимания тому, чтобы люди начинали работать с параллельными вычислениями. Сейчас, возможно, даже больше, чем когда-либо. Всё это вдохновило меня наконец завершить этот пост, над которым я работал уже некоторое время. Одна из тем, которая постоянно возникает при работе со Swift-параллелизмом, заключается в попытке «сделать компилятор счастливым». Вы просто хотите избавиться от всех этих глупых ошибок. Пытаясь сделать это, вы сталкиваетесь с такими вещами, как Sendable или @preconcurrency. В
Оглавление

Не так давно я перечитывал вводную статью, которую написал ранее. Честно говоря, мне было трудно дочитать её до конца. Думаю, большая часть проблемы заключалась в том, что моё понимание того, что такое «введение» в контексте параллелизма, сильно изменилось. Когда я читал эту статью, я представлял себе новичка, который делает то же самое. Это было неловко! Я не собираюсь удалять этот пост, но чувствую себя не очень хорошо по этому поводу.

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

Одна из тем, которая постоянно возникает при работе со Swift-параллелизмом, заключается в попытке «сделать компилятор счастливым». Вы просто хотите избавиться от всех этих глупых ошибок. Пытаясь сделать это, вы сталкиваетесь с такими вещами, как Sendable или @preconcurrency. Возможно, вы даже начнёте менять класс на актёр (actor), а ведь они отличаются всего несколькими символами. Так что вы просто начинаете добавлять синтаксические конструкции к проблеме. Это вполне понятно!

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

Добро пожаловать ко второй части серии «Шаг за шагом в Swift-параллелизме». Цель этих статей – рассмотреть выполнение общей задачи, чтобы помочь лучше понять, что происходит. В прошлый раз мы рассмотрели сетевой запрос. Теперь мы займёмся загрузкой модели из хранилища данных.

Краткое примечание

  • Я собираюсь игнорировать обработку ошибок, чтобы сосредоточиться на главном.
  • Я не очень хорош в SwiftUI.
  • Для этого требуется Xcode 16 или новее.
  • Мне было довольно сложно придумать пример, который был бы одновременно простым и иллюстративным. Думаю, локальное хранилище будет хорошим вариантом, хотя нам придётся немного упростить ситуацию. Я не думаю, что это сильно повлияет на идеи, которые мы рассмотрим. Тем не менее, хочу подчеркнуть это, потому что наше представление о «хранилище данных» здесь совсем не похоже на SwiftData, CoreData или другие инструменты, которые могут использоваться в реальных приложениях.

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

Собираем все вместе

Хорошо, давайте начнём с определения интерфейса нашей системы хранения.

class DataModel {
let name: String

init(name: String) {
self.name = name
}
}

class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}

Я говорил вам, что это будет упрощённый пример! Здесь у нас есть простой тип DataModel, который хранит значение, и класс Store, который «загружает» модели для нас. Ни тот ни другой ничего полезного не делают. Однако нас интересуют именно типы и их интерфейсы.

Теперь нам нужен вид SwiftUI, чтобы связать всё это вместе.

struct ContentView: View {
@State private var store = Store()
@State private var name: String = "---"

var body: some View {
Text("hello \(name)!")
.task {
self.name = await store.loadModel(named: "friends").name
}
}
}

Эти два фрагмента кода должны легко поместиться на одном экране. Неплохо!

Примечание: система типов

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

Но нас действительно интересует только типы и их интерфейсы.
Это важный момент, и он достаточно тонкий! Параллелизм в Swift является расширением системы типов. Я повторяю это снова и снова, потому что это важно понимать. Это означает, что мы можем экспериментировать с нашими типами, их API и структурой, и компилятор даст обратную связь об их поведении в условиях параллельных вычислений. Часто вы можете проделать значительную работу без необходимости запускать код! Это даёт вам быстрый цикл обратной связи.

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

Отлично, но это не работает

Этот пример кода небольшой и красивый, но он не компилируется в режиме Swift 6. Проблема кроется в одной строке внутри модификатора task.

.task {
// ошибка: Несендабельный тип 'DataModel', возвращаемый неявным асинхронным вызовом неизолированной функции, не может пересекать границу актёра
self.name = await store.loadModel(named: "friends").name
}

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

Давайте разберём её на три части.

Несендабельный тип 'DataModel'…
Итак, речь идёт о нашем типе DataModel, определённом выше.

class DataModel {
// ...
}

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

Перейдем дальше!

…возвращенный неявным асинхронным вызовом неизолированной функции…
Ого. Это непросто расшифровать. У нас есть подсказки: «возвращён вызовом функции» и «неизолированная функция». Мы знаем, какая функция здесь вызывается. Речь идет о вызове метода Store.loadModel. Давайте посмотрим на него внимательнее.

class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}

Правильно. Эта функция возвращает экземпляр типа DataModel, который, как мы знаем, не соответствует протоколу Sendable. Но компилятор говорит нам, что вызов этой функции является «неявно асинхронным», и что сама функция «неизолирована».

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

Важная деталь состоит в том, что компилятор сообщает нам, что функция «неизолирована». Как мы исследовали в предыдущем посте, изоляция определяется через объявления. Функция loadModel не указывает никакой изоляции. Также нет изоляции и у типа Store. Отсутствие изоляции – это настройка по умолчанию, и поскольку мы не видим аннотаций вроде @MainActor или других средств установления изоляции в этих определениях, применяется именно это умолчание.

Две части разобраны.

…не может пересечь границу актёра
Хммм. Граница актёра? Мы нигде не использовали актёры, и мы пока не понимаем, что значит граница.

Что мы знаем, так это то, что метод Store.loadModel является асинхронным и неизолированным. Неизолированный + асинхронный означает выполнение в фоновом режиме. Таким образом, эта функция фактически создаёт экземпляр DataModel в фоновом режиме и затем передаёт его обратно вызывающему коду.

Однако вызывающий код – это наш вид SwiftUI. Этот вид находится не в фоновом режиме, он привязан к главному потоку (MainActor). Между этими двумя сущностями существует «граница», предотвращающая небезопасные доступы. Вот та строка снова:

self.name = await store.loadModel(named: "friends").name

Перепишем ошибку компилятора:

Эй! Ты пытаешься покинуть главный поток, получить экземпляр DataModel в фоновом режиме и вернуть его обратно в главный поток. Но я могу передавать между акторами (например, главным потоком здесь) только сендабельные типы. Если созданный экземпляр продолжит обрабатываться в фоновом режиме, это приведёт к гонке данных!
Ох.

Просто сделайте его сендабельным

Надеюсь, теперь стало ясно, почему компилятор недоволен. И кажется, что есть простое и очевидное решение. Просто сделайте DataModel соответствующим протоколу Sendable!

final class DataModel: Sendable {
// ...
}

Обратите внимание, что для этого нужно также сделать класс финальным. Сендабельные классы не могут иметь подклассов. В этом конкретном примере это всё, что потребовалось. Но такие классы также не могут иметь суперклассов. Они не могут содержать изменяемое состояние. А любые свойства, которые они содержат, тоже должны соответствовать протоколу Sendable.

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

(Есть несколько веских причин для создания сендабельных классов, таких как совместное использование единственной копии большой неизменяемой структуры.)

Хорошо, мы сделали этот объект сендабельным, и теперь всё готово, правда? Нет, не готово. Теперь появилась другая ошибка.

.task {
// ошибка: Отправка 'self.store' рискует привести к гонкам данных
self.name = await store.loadModel(named: "friends").name
}

О боже, что теперь?

Отправка self?
Эта проблема тонкая. Подсказками являются слова «Отправка» и ссылка на self.store.

Мы видим, что self.store – это переменная экземпляра нашего вида. Поскольку она является членом типа, изолированного к главному потоку (через соответствие протоколу View в SwiftUI), она также изолирована к главному потоку.

// Это @MainActor ...
struct ContentView: View {
// ... поэтому это тоже
@State private var store = Store()

// ...
}

И вот где проявляется тонкость. Я добавлю немного кода и поставлю эти две вещи рядом, чтобы показать, что происходит.

// "store" здесь...
self.name = await store.loadModel(named: "friends").name
func loadModel(named name: String) async -> DataModel {
print(self) // ... должно стать "self" здесь!

return DataModel(name: name)
}

Хорошо, давайте подумаем о том, что происходит. У нас есть экземпляр хранилища, который изолирован до MainActor. Чтобы сделать этот вызов для загрузки модели, нам нужно перейти в фоновый режим. А поскольку это метод экземпляра, переменная store должна стать self внутри тела метода.

Получатель вызова метода является неявным параметром!

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

Только типы Sendable могут это делать!

Кстати: MainActor означает Sendable
Вот что-то интересное и очень важное знать. Вы получаете неявную совместимость с Sendable при маркировке типа с помощью MainActor.

Когда я впервые узнал об этом, меня это действительно удивило!

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

Вот то, с чем я иногда сталкиваюсь.

@MainActor // Это делает тип Sendable
class SomeClass: Sendable {
}

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

Вы можете углубиться в это, если хотите. Или вы можете просто запомнить, что @MainActor означает Sendable. Хорошо.

Не-Sendable + async, говоришь?
(Вот код снова, просто для справки.)

class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}

Когда мы говорим о «границах» в данном случае, мы имеем в виду переходы между MainActor и фоном. Есть одна граница при вызове, а затем вторая при возврате. Компилятор требует, чтобы все, что пересекает границы, было Sendable. Вот почему несендовые типы, у которых есть асинхронные методы или участвующие в конкурентном выполнении, всегда должны вызывать тревогу. Их очень трудно использовать!

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

Существует очень сильная корреляция между людьми, испытывающими трудности со Swift-конкурентностью, и попаданием в эту ловушку.

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

Теперь требуется мышление


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

Вдумчивые читатели первого поста заметят, что я использовал это, чтобы упростить содержание. Границы пересечения были повсюду, но все типы были Sendable, и всё работало. Это не было (полностью) искусственным — на практике так тоже бывает. Приложение, которое мы создали, делало реальное, хотя и абсурдное дело! Как часто это действительно случается, зависит главным образом от того, сколько неизменяемых данных вы обрабатываете.

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

Просто удалите границы
Если вы не можете сделать значения Sendable, самым простым решением здесь будет просто прекратить пересечение границ. Но как?

Ну, мы знаем, что идём от MainActor -> фон при вызове, а затем возвращаемся фон -> MainActor при возврате. Давайте просто перестанем переходить в фон.

@MainActor // <- применим изоляцию!
class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}

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

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

«Разделённая изоляция»

Прежде чем мы погрузимся во всё это, хочу отметить альтернативное, похожее решение.

class Store {
@MainActor // <- применить изоляцию только к методу
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}

Вместо изоляции всего типа мы применяем её только к асинхронной функции. Это работает! Однако эта схема чрезвычайно проблематична. Я встречаюсь с этим так часто, и она вызывает столько проблем, что я дал ей название: «разделённая изоляция».

Чтобы понять, почему это проблема, подумайте только об этом методе. Мы знаем, что self необходимо переместить из места вызова в loadModel. Но обратите внимание, что Store не является Sendable. Это означает, что для вызова этого метода на экземпляре, сам экземпляр должен уже находиться на MainActor! И поскольку он не является Sendable, участие в конкурентных операциях в любых методах с другой изоляцией будет очень сложным/невозможным.

У вас есть этот единственный экземпляр, но только некоторые части могут использоваться вне MainActor. Он был «разбит» между MainActor и неизолированным. Не знаю, если вы придумаете лучшее название, дайте мне знать.

Опять же, существуют законные применения этой схемы. Но если вы не можете точно объяснить, зачем вы это делаете и как будете справляться с последствиями, вам не следует это делать. Если вы собираетесь использовать @MainActor, применяйте его ко всему типу.

Просто хотел это прояснить. Вперёд.

Типы значений

Хорошо, хорошо, но что, если вы просто хотите выполнить какую-то работу в фоновом режиме? Забудьте возможное, разве это не должно быть легко?

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

struct DataModel {
let name: String

init(name: String) {
self.name = name
}
}

@MainActor
class Store {
func loadModel(named name: String) async -> DataModel {
let data = await Self.readModelDataFromDisk(named: name)
let model = await Self.decodeModel(data)

return model
}

private nonisolated func readModelDataFromDisk(named name: String) async -> Data {
// обращение к диску в фоновом режиме
}

private nonisolated func decodeModel(_ data: Data) async -> DataModel {
// обработка сырых данных в фоновом режиме
}
}

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

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

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

К сожалению, реальные системы могут оказаться не такими простыми для преобразования в чистую форму ввода-вывода Sendable. И вот тогда вам придется начать рассматривать альтернативы.

Модуль Swift 5

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

class Store {
func loadModel(named name: String, completionHandler: @escaping @MainActor (DataModel) -> Void) {
someQueue.async {
// ресурсоемкая работа выполняется здесь
let model = DataModel(name: name)

DispatchQueue.main.async {
completionHandler(model)
}
}
}
}

Вся причина для этого заключается в получении несэндового типа из фона в основной поток. Я пометил API с помощью @MainActor, чтобы для вызывающих сторон казалось, будто нет никакой границы вовсе. Готово.

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

(Система, выполняющая этот анализ, называется «изоляция на основе региона», и я писал об этом.)

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

Ключевое слово sending


Давным-давно я переписал сообщение об ошибке компилятора и закончил следующим:

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

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

Тем не менее, Swift 6 признает, насколько это может быть болезненно, и представил новую функцию, чтобы помочь. Ключевое слово sending позволяет нам закодировать это обещание безопасности в нашем API. Мы можем использовать его, чтобы выразить идею о том, что мы производим новое, независимое значение и просто передаем его вызывающему объекту. Вот идея.

@MainActor
final class Store {
nonisolated func loadModel(named name: String) async -> sending DataModel {
DataModel(name: name)
}
}

Мы сделали две вещи. Во-первых, мы убрали привязанность к MainActor с помощью ключевого слова nonisolated, благодаря которому мы попадаем в фон. Тип по-прежнему является Sendable, потому что остается изолированным на уровне MainActor.Но интересный момент — это ключевое слово sending, примененное к возвращаемому значению. Что делает sending, так это обмен. Оно принимает ограничение в теле функции, но взамен ослабляет ограничения в местах вызова.Мы гарантируем, что наш API всегда будет предоставлять экземпляр DataModel, который можно безопасно вернуть. Это немного похоже на Sendable, но вместо применения ко всему типу, оно применяется только к этому конкретному месту. Чтобы это сработало, компилятор должен доказать, что loadModel действительно обеспечивает эту гарантию.Sending не является магией. Существуют ситуации, когда это просто не сработает, даже если кажется, что должно. И я обнаружил, что эти случаи сложно отладить. Тем не менее, это очень мощный инструмент!

Акторы


До сих пор мы говорили только о MainActor или «фоне». Но вы также можете создавать свои собственные акторы! Это даёт вам возможность определять новый маленький уголок изоляции

.actor Store {
func loadModel(named name: String) async -> sending DataModel {
DataModel(name: name)
}
}


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

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

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

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

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

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

Конец...


Поверишь ли ты, что я на самом деле убрал кучу контента отсюда перед публикацией? Изначально у меня тут было кое-что о использовании различных небезопасных инструментов отключения, предоставляемых языком. Но, перечитав, я решил, что это просто тема для другого дня. Такие вещи, как @unchecked Sendable и nonisolated(unsafe), существуют по какой-то причине и позволяют вам делать практически всё, что угодно. Но у них гораздо больше недостатков, чем вы думаете!

Я также действительно колебался, прежде чем представить sending и акторы в этом посте. Но, в конечном итоге, я решил, что это имеет смысл, потому что они являются настоящими полезными инструментами. Но оба они должны дать вам повод задуматься. Они поощряют еще большую конкуренцию. Большая конкуренция означает большую сложность. И я твердо убежден, что такую сложность следует вводить только тогда, когда можно оправдать компромисс.

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

Источник