Это перевод статьи Concurrency Step-by-Step: Reading from Storage
Тема, которая снова и снова возникает в связи с многопоточностью (concurrency) в Swift, — это попытка «сделать компилятор довольным». Вы просто хотите, чтобы глупые ошибки исчезли. Пытаясь сделать это, вы натыкаетесь на множество вещей, таких как Sendable
или @preconcurrency
. Вы даже можете начать менять класс class
на актор actor
, и непонятно, насколько это может отличаться, но это даже то же самое количество символов. Поэтому вы просто начинаете бросаться синтаксисом в проблему. Это понятно!
Иногда это может даже работать в краткосрочной перспективе. Но этот путь обычно приводит к разочарованию и гневу. Часто он приводит к чрезвычайно сложным проектам, которые просто приводят к еще большим проблемам.
Добро пожаловать во вторую часть «Concurrency Swift шаг за шагом». Первая часть «Concurrency 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
для хранения простого значения name
, а также Store
, который «загружает» для нас модели. Ни один из них не делает ничего полезного. Но на самом деле нас интересуют только ТИПы и их интерфейсы.
Теперь нам нужен SwiftUI View
, чтобы связать все это воедино.
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 concurrency — это расширение системы ТИПов. Я говорю это снова и снова, потому что это важно понимать. Это означает, что мы можем просто поиграть с нашими ТИПами, их API и структурой, а компилятор даст нам обратную связь об их многопоточном поведении. Часто можно выполнить значительный объем работы, даже не запуская код! Это дает вам действительно быстрый цикл обратной связи.
Возможность итерировать на конструировании поведения во время выполнения — это круто.
Отлично, только это не работает
Этот пример хорош и краток, но на самом деле он НЕ компилируется в режиме Swift 6. Проблема в этой единственной строке в модификаторе .task
.
.task {
// error: Non-sendable type 'DataModel' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
self.name = await store.loadModel(named: "friends").name
}
Ошибка получается… ну, скажем так, не идеальная:
ошибка: НЕ-sendable ТИП ‘DataModel’ возвращаемый неявным асинхронным вызовом nonisolated функции не может пересекать границу актора actor
Но это нормально, потому что мы многому научимся, разбираясь с ней!
Давайте разобьем ее на три части.
| Non-sendable type ‘DataModel’ …
| НЕ-sendable ТИП ‘DataModel’ …
Хорошо, это относится к нашему ТИПу DataModel
из вышеприведенного примера. Выглядит это так:
class DataModel {
// ...
}
Это класс class
. В отличие от структур struct
, классы class
не соответствуют протоколу Sendable
по умолчанию. И мы не добавили явного соответствия. Поэтому вполне логично, что компилятор сообщает нам, что он не является Sendable
.
Далее!
|… returned by implicitly asynchronous call to nonisolated function …
|… возвращаемому неявным асинхронным вызовом nonisolated функции …
Уф. Это трудно расшифровать. Подсказки, которые у нас есть, это “returned by blah blah call to blah blah function” («возвращается бла бла вызовом бла-бла-бла функции»). И мы знаем, какая функция здесь вызывается. Здесь говорится о нашем вызове Store.loadModel
. Давайте рассмотрим это более подробно.
Правильно. Эта функция возвращает DataModel
, которая, как мы знаем, не соответствует Sendable
. Но компилятор сообщает нам, что этот вызов «неявно асинхронный» “implicitly asynchronous” , а функция «неизолированная» “nonisolated
”.
Во-первых, мне очень жаль говорить, что слово «неявно» здесь — ошибка компилятора. Она была исправлена довольно давно, но это исправление еще не вошло в релиз. Это “явный асинхронный вызов” explicitly asynchronous call.
Самое важное, что компилятор сообщает нам, что функция «неизолированная» “nonisolated
”. Как мы выяснили в предыдущем посте, изоляция isolation
происходит из определений. Функция loadModel
не указывает никакой изоляции isolation
. И ТИП Store
тоже не указывает. Отсутствие изоляции isolation
— это значение по умолчанию, и поскольку мы не видим @MainActor
или какие-либо другие средства установления изоляции isolation
в этих определениях, тогда применяется значение по умолчанию.
Следующая часть сообщения:
… cannot cross actor boundary.
… нельзя пересекать границу актора actor.
Хм-м-м. Граница актора actor
? Мы нигде здесь не использовали актор actor
, и мы (пока!) не знаем, что вообще означает граница boundary.
Что мы знаем, так это то, что Store.loadModel
является как асинхронной async
, так и неизолированный nonisolated
функцией. Неизолированный nonisolated
+ асинхронный async
означает выполнение в фоновом режиме (background). Так что эта функция на самом деле создает экземпляр DataModel
в фоновом режиме (background) и затем передает его обратно тому, кто его вызывал.
Но вызывает нас SwiftUI View
. Это View НЕ в фоновом режиме (background) , оно находится на MainActor
. Между этими двумя вещами есть «граница» boundary, чтобы предотвратить небезопасный доступ. Вот эта строка снова:
self.name = await store.loadModel(named: "friends").name
Давайте перепишем эту ошибку компилятора в более понятной форме:
Эй! Вы пытаетесь покинуть MainActor, получить DataModel
в фоновом режиме (background) , а затем передать его обратно в MainActor. Но единственные ТИПы, которые мне разрешено передавать в или из акторов (например, MainActor здесь), — это типы Sendable. Если возвращаемый экземпляр продолжит использоваться в фоновом режиме (background) , это будет гонка данных (data race)!
Просто сделайте его Sendable
Надеюсь, теперь понятно, почему компилятор недоволен. И теперь, кажется, есть действительно простое и очевидное решение. Просто сделайте DataModel
соответствующим Sendable
!
final class DataModel: Sendable {
// ...
}
Обратите внимание, что для этого вам также нужно сделать класс class final
. Классы class Sendable
не могут иметь подклассов subclasses
. В этом конкретном, выдуманном примере этого было достаточно. Но они также не могут иметь суперклассов superclass
. Они не могут содержать изменяемое состояние. И любые свойства, которые у них есть, также должны быть Sendable
.
Оказывается, когда вы хотите сделать класс class Sendable
, это действительно возможно только тогда, когда этот «класс» на самом деле является структурой struct
. Структуры struct
, имеющие Value
семантику, намного проще для компилятора (и для людей…). Мы могли бы сделать это здесь, но только потому, что наш конкретный пример невероятно прост, я собираюсь оставить его.
(Есть несколько веских причин для создания class Sendable
, например, совместное использование единственной копии большой неизменяемой структуры.)
Итак, мы сделали это Sendable
, и мы закончили, верно? Нет, не закончили. Теперь есть другая ошибка.
.task {
// error: Sending 'self.store' risks causing data races
self.name = await store.loadModel(named: "friends").name
}
| error: Sending ‘self.store’ risks causing data races
| ошибка: Sending ‘self.store’ рискует привести к «гонке данных«
Ох, черт возьми, что теперь?
Sending self?
Это тонкая проблема. Подсказки, по которым нам нужно идти, — это слово “Sending
” и ссылка на self.store
.
Мы видим, что self.store
— это переменная var
экземпляра нашего View
. И поскольку она является членом MainActor
ТИПа (через соответствие SwiftUI View
протоколу View
), он также MainActor
-изолирован.
// This is @MainActor ...
struct ContentView: View {
// ... so this is too
@State private var store = Store()
// ...
}
А вот здесь и появляется тонкость. Я добавлю немного кода и размещу эти две вещи рядом, чтобы показать, что происходит.
// the "store" here...
self.name = await store.loadModel(named: "friends").name
func loadModel(named name: String) async -> DataModel {
print(self) // ... needs to end up as "self" here!
return DataModel(name: name)
}
Хорошо, подумайте о том, что происходит.
У нас есть экземпляр хранилища store
, который изолирован isolated
как MainActor
. Чтобы сделать этот вызов loadModel
, нам нужно перейти в фоновый режим (background). И поскольку это метод экземпляра, эта переменная store
должна стать self
внутри тела метода.
Получатель вызова метода — это неявный параметр!
Итак, экземпляр должен быть «послан» (“sent”) из MainActor
в фоновый режим (background) , который является “границей актора” (actor boundary
). Звучит знакомо?
Это могут делать только Sendable
ТИПы !
Отступление: MainActor означает Sendable
Вот что интересно и что действительно важно знать. Вы получаете неявное (implicit) соответствие протоколу Sendable
, когда помечаете ТИП с помощью @MainActor.
Когда я впервые узнал об этом, это меня очень удивило!
ТИПы, которые изолированы (isolated
) внутри глобального актора, предоставляют компилятору достаточно информации, чтобы гарантировать безопасный доступ, где бы он ни происходил. И этого достаточно, чтобы удовлетворить требованиям Sendable
.
Вот с чем я иногда сталкиваюсь.
@MainActor // This makes the type Sendable
class SomeClass: Sendable {
}
Избыточный Sendable
не является неправильным, но он должен заставить вас поднять бровь, то есть удивиться. Возможно, это просто остаток от экспериментов по ходу дела, но это то, с чем я обычно сталкиваюсь.
Вы можете углубиться здесь, если хотите. Или вы можете просто запомнить, что @MainActor
означает Sendable
.
Вы говорите НЕ-Sendable + async?
(Еще раз привожу код, просто для справки.)
class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}
Когда мы говорим о «границах» (“boundaries” ), в этом случае мы говорим о переходах между MainActor
и фоновым режимом (background). Один такой переход осуществляет вызов call
, а затем есть второй, когда мы возвращаемся с помощью return
. Компилятор требует, чтобы все, что пересекает границы, было Sendable
. Вот почему non-Sendable
ТИПы, которые имеют асинхронные async
методы или вообще участвуют в многопоточности (concurrency) , всегда должны вызывать тревогу. Их очень сложно использовать!
Однако сложно не значит невозможно. И на самом деле они могут быть довольно мощными. Но они очень продвинуты, и люди очень часто вводят их в свой код, не осознавая последствий.
Существует очень сильная корреляция между людьми, испытывающими проблемы с Swift concurrency, и попаданием в эту ловушку.
(Не то чтобы я их виню. В языке не должно быть таких ловушек! Это не достается даром, но, к счастью, команда Swift работает над изменением семантики языка, чтобы решить эту проблему.)
К сожалению, теперь требуется думать
Как только вам нужно пересечь одну из этих границ, перемещая значения в или из актора actor
, это требует некоторого размышления. Все ТИПы, которые задействованы, теперь должны стать Sendable
. Это ограничение может варьироваться от незначительных до полнейшего провала.
Проницательные читатели первого поста заметят, что я использовал это, чтобы сделать контент проще. Повсюду были пересечения границ, но все ТИПы были Sendable
, и все просто работало. Это также не было (полностью) надуманным — это действительно происходит на практике. Приложение, которое мы создали, сделало реальную, хотя и нелепую, вещь! То, как часто это происходит на самом деле, во многом зависит от того, с каким объемом неизменяемых данных вы работаете.
Теперь, давайте подумаем и выясним, что здесь делать.
Просто удалите границы
Если вы не можете сделать значения Sendable
, самое простое решение здесь — просто прекратить пересекать границы в первую очередь. Но как?
Ну, мы знаем, что мы переходим от MainActor -> к background при вызове, а затем переходим от background -> к MainActor при возврате. Давайте просто перестанем переходить в background.
@MainActor // <- apply isolation!
class Store {
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
}
Изолируя Store
, мы устраняем все наши проблемы. Теперь не имеет значения, что DataModel
не является Sendable, потому что ему не нужно пересекать никаких границ. Наш тип Store
теперь также становится Sendable
, поэтому его гораздо проще использовать с другими конструкциями многопоточности (concurrency).
Конечно, это влечет за собой довольно большой компромисс. Теперь мы вынуждены выполнять любую работу, связанную с созданием этого экземпляра DataModel
в основном потоке (main thread). И это может стать проблемой.
Разделенная изоляция “split isolation”
Прежде чем мы углубимся во все это, я хотел бы выделить альтернативное, похожее решение.
class Store {
@MainActor // <- apply isolation to just the method
func loadModel(named name: String) async -> DataModel {
DataModel(name: name)
}
Вместо того, чтобы изолировать весь ТИП, мы применяем @MainActor
только к асинхронной async
функции. Это работает!
Однако этот шаблон невероятно проблематичен. Я сталкиваюсь с этим так часто, и это вызывает так много проблем, что я дал ему название: «разделенная изоляция» “split isolation”.
Чтобы понять, почему это проблема, подумайте только об этом методе. Мы знаем, что self
нужно переместить из места вызова в функцию loadModel
. Но обратите внимание, что Store
не является Sendable
. Это означает, что для вызова этого метода для экземпляра, экземпляр уже должен быть в MainActor
! И поскольку он не является Sendable
, участие в многопоточности в любых методах с другой изоляцией isolation будет действительно сложным / невозможным.
У вас есть этот один экземпляр, но только некоторые части могут использоваться вне MainActor
. Он был «разделен» между MainActor
и неизолированным nonisolated
. Я не знаю, если вы придумаете лучшее название, дайте мне знать.
Опять же, есть допустимые применения для этого шаблона. Но если вы не можете точно сформулировать, почему вы это делаете и как вы будете справляться с последствиями, вам не следует этого делать. Если вы собираетесь использовать @MainActor,
примените его ко всему ТИПу.
Просто нужно было убрать это с дороги. Вперед.
Value ТИПы
Хорошо, но что, если вы просто хотите выполнить какую-то работу в фоновом режиме (background)? Забудьте о том, что это возможно, разве это не должно быть легко?
Мы видели выше, что создание Sendable
класса class
почти эквивалентно созданию структуры struct
. Работа с Value
ТИПами дает нам много вариантов. Давайте сделаем это сейчас.
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 {
// hit the disk in the background
}
private nonisolated func decodeModel(_ data: Data) async -> DataModel {
// process the raw data in the background
}
}
Когда дело доходит до выгрузки работы в другие потоки, детали всегда будут иметь значение. Но здесь я только что придумал два правдоподобных этапа обработки.
Оба являются nonisolated
async
функциями, и это означает, что они работают в фоновом режиме (background). Поскольку они nonisolated
, они не могут обращаться к членам MainActor Store
синхронно, но это может быть нормально! Это могут быть просто старые, простые функции без состояния. Они принимают неизменяемые входные данные и производят неизменяемые выходные данные.
Часто это работает очень хорошо. Это может быть отличным решением для классической загрузки и декодирования, как эта. Это чрезвычайно распространенный шаблон — он также упоминался в предыдущем посте. Я думаю, что это всегда должно быть тем, к чему вы должны стремиться в первую очередь.
К сожалению, реальные системы не так легко перейти к этой чистой форме Sendable-in
, Sendable-out
. И вот тогда вам нужно начать искать альтернативы.
Swift 5 module
Настройка, которая вам может понравиться, — это модуль Swift 5 без включенных предупреждений. Это просто хорошее место для хранения вещей, которые вы не можете или не хотите заставить работать в режиме Swift 6. Это также отличный вариант при взаимодействии с существующими системами. Модульность — один из ключей к успешной миграции Swift 6.
class Store {
func loadModel(named name: String,
completionHandler: @escaping @MainActor (DataModel) -> Void) {
someQueue.async {
// expensive work goes here
let model = DataModel(name: name)
DispatchQueue.main.async {
completionHandler(model)
}
}
}
}
Вся причина этого в том, чтобы получить non-Sendable
ТИП из фонового потока (background) в основной поток (main thread). Я аннотировал API с помощью @MainActor,
чтобы для того, кто будет вызывать эту функцию, она выглядела бы так, будто границ вообще нет. Готово.
Возможно, это удивительно, но этот код на самом деле компилируется без ошибок в режиме Swift 6. Внутри много всего происходит, чтобы сделать это возможным. Но вам действительно нужно знать, что компилятор понимает, что модель model может безопасно перейти из фонового (background) в основной поток (main thread) в этом конкретном случае. Он может определить это только потому, что
- а) все это происходит в одной функции и
- б) этот пример простой.
Но все равно довольно интересно знать, что такие вещи возможны.
(Система, которая выполняет этот анализ, называется «изоляция на основе региона» “region-based isolation”, и я писал о ней.)
Я предпочитаю четко различать API, которые используют многопоточность (concurrency), и те, которые созданы для обхода проблемы. Я не думаю, что вам следует писать async
/ await
в модулях, в которых отключены предупреждения о многопоточности. Если вы хотите создать асинхронную async
обертку вокруг этой функции, вам следует сделать это в расширении extension внутри модуля, в котором включены предупреждения.
Ключевое слово sending
Вернемся к тому, что я переписал сообщение об ошибке компилятора и закончил так:
If the returned instance continues to be accessed in the background it would be a data race!
(Если к возвращенному экземпляру по-прежнему будет осуществляться доступ в фоновом режиме, это приведет к гонке данных!)
Обратите внимание на if, если это true, но на самом деле мы НЕ продолжаем к нему обращаться. Это предупреждение, которое мы пытаемся обойти, но на самом деле это false.
Дело в том, что сейчас это false. Но это действительно функция нашей реализации. Мы могли бы внести небольшие изменения, которые могли бы очень легко сделать это небезопасным.
Swift 6 осознает, насколько это может быть болезненно, и представил новую функцию, чтобы помочь. Ключевое слово sending
позволяет нам кодировать это обещание безопасности в нашем API. Мы можем использовать его, чтобы выразить идею о том, что мы создаем новое, независимое значение и просто передаем его вызывающей стороне. Вот идея.
@MainActor
final class Store {
nonisolated func loadModel(named name: String) async -> sending DataModel {
DataModel(name: name)
}
}
Мы сделали две вещи.
Во-первых, мы удалили MainActor
-ness с помощью ключевого слова nonisolated
, что переносит нас в фоновый режим (background). ТИП по-прежнему Sendable
, потому что он остается MainActor
-изолированным.
Но интересный момент заключается в том, что ключевое слово sending
применяется к возвращаемому значению. Что делает sending
, так это совершает сделку. Он принимает ограничение с телом функции, но взамен ослабляет ограничения в местах вызова.
Мы гарантируем, что наш API всегда будет предоставлять экземпляр DataModel
, который можно безопасно вернуть. Это похоже на Sendable
, но вместо того, чтобы применяться ко всему ТИПу, это действует только в одном месте. Чтобы это работало, компилятор должен доказать, что loadModel
действительно предоставляет эту гарантию.
sending
— это не магия. Есть ситуации, когда он просто не будет работать, даже когда кажется, что должен. И я обнаружил, что их трудно отлаживать. Но, несмотря на это, это очень мощный инструмент!
Actors
До сих пор мы говорили только о MainActor,
или фоновом режиме (background). Но вы также можете создавать своих собственных акторов actor
! Это дает вам возможность определить новый маленький кусок изоляции isolation
.
actor Store {
func loadModel(named name: String) async -> sending DataModel {
DataModel(name: name)
}
}
Нам все еще нужен sending
здесь, потому что DataModel
все еще должен перейти от изоляции этого актора к внешнему миру. Но это выглядит проще. Нам также больше не нужен nonisolated
метод, чтобы выйти из основного потока (main thread). И поскольку метод изолирован, все свойства этого актора синхронно доступны внутри его тела. Пока что это кажется отличным!
Что ж, дело в том, что акторы actor
2,5 проблемы.
Первая заключается в том, что их интерфейс строго асинхронен. Версия @MainActor Store
может быть доступна синхронно, если это необходимо, но эта actor
версия не может. Это может быть убийственно, особенно для существующей системы. Вы всегда должны уделять очень пристальное внимание тому, где вам нужен синхронный доступ к данным при работе со Swift concurrency.
Вторая заключается в том, что изоляция, которую обеспечивает актор actor
, является палкой о двух концах. Каждый отдельный вход и выход для актора actor
теперь должен пересекать границу. Это может быть огромной проблемой. В общем, когда вы добавляете актор actor
в систему, вы увеличиваете потребность в еще большем количестве ТИПов Sendable
. Больше акторов actor
— больше границ.
Последняя половина проблемы заключается в том, что акторы actor
чаще сталкиваются с reentrancy проблемами. Причина, по которой я считаю это половиной, заключается в том, что reentrancy может быть проблемой даже в чисто MainActor
системе. На самом деле, это даже не уникально для Swift concurrency— эта проблема может и случается и с GCD.
reentrancy — слишком большая тема, чтобы разбираться с ней здесь, но я хочу, по крайней мере, вложить ее в вашу голову. Акторы actor
— это не первое, на что вам следует обратить внимание. На самом деле, я думаю, что они используются чрезвычайно часто (они явно переоценены)!
В завершение …
Трудно поверить, но я действительно удалил кучу контента из этого поста перед публикацией. Изначально у меня было немного материала об использовании различных небезопасных инструментов, которые предоставляет язык. Но, перечитав, я решил, что это просто нужно поместить в другой пост. Такие вещи, как @unchecked Sendable
и nonisolated (unsafe)
существуют не просто так и позволяют вам делать практически все, что вы хотите. Но у них больше недостатков, чем вы могли бы подумать!
Я также колебался, стоит ли вводить sending
и actors
в этом посте. Но в конечном итоге я решил, что это имеет смысл, потому что это реальные, полезные инструменты. Но они оба должны заставить вас задуматься. Они поощряют еще большую многопоточность. Больше многопоточности означает большую сложность. И я твердо убежден, что вы должны вводить такую сложность только тогда, когда компромисс может быть оправдан.
Если вы можете добиться того, что вам нужно, используя некоторые Value
ТИПы и несколько nonisolated
функций здесь и там, вы должны это сделать. Это просто намного проще. К сожалению, вы столкнетесь с ситуациями, когда это не сработает. Вот для этого и существуют другие варианты.