Актеры — это относительно новый номинальный тип в Swift, который обеспечивает безопасность гонок данных для своего изменяемого состояния. Защита достигается за счет изоляции изменяемого состояния каждого экземпляра субъекта не более чем от одной задачи за раз. Предложение, в котором представлены акторы (SE-0306), довольно объемно и подробно, но в нем упущены некоторые тонкие аспекты создания и уничтожения изолированного состояния актера. Это предложение направлено на то, чтобы укрепить определение актора, уточнить, когда начинается и заканчивается изоляция данных для экземпляра актора, а также что можно сделать внутри тела объявлений init и deinit актора.
Мотивация
Хотя не существует спецификации того, как должны работать инициализация и деинициализация актора, это само по себе не является единственной мотивацией для этого предложения. Ожидаемое де-факто поведение, вызванное существующей реализацией в Swift 5.5, также проблематично. Подводя итог, проблемы включают в себя:
- Неасинхронные инициализаторы слишком строго относятся к тому, что можно сделать с self.
- Деинициализаторы акторов могут вызывать гонки данных.
- Изоляция глобального субъекта для значений хранимых свойств по умолчанию не всегда может соблюдаться во время инициализации.
- Делегирование инициализатора требует использования convenience ключевого слова, такого как классы, хотя акторы не поддерживают наследование.
Важно иметь в виду, что это не исчерпывающий список. В частности, изолированные типы глобального субъекта фактически сами являются субъектами, поэтому к ним также должны применяться многие из тех же средств защиты.
В следующих подразделах эти проблемы высокого уровня будут обсуждаться более подробно.
Чрезмерно ограничивающие неасинхронные инициализаторы
Исполнитель актора выступает в качестве арбитра для бесконфликтного доступа к сохраненным свойствам актора, аналогично замку. Задача может получить доступ к изолированному состоянию субъекта, если она выполняется на исполнителе субъекта. Процесс получения доступа к исполнителю может выполняться только асинхронно из задачи, поскольку блокирование потока для ожидания доступа противоречит духу Swift Concurrency. Вот почему для вызова неасинхронного метода экземпляра субъекта из-за пределов домена изоляции субъекта требуется await, чтобы пометить возможную приостановку. В этом предложении процесс получения доступа к исполнителю актора будет называться «переходом» на исполнителя.
Неасинхронные инициализаторы и все деинициализаторы актора не могут перейти к исполнителю актора, что защитило бы его состояние от одновременного доступа других задач. Без выполнения прыжка может произойти гонка между новой задачей и кодом, появляющимся в инициализации:
actor Clicker {
var count: Int
func click() { self.count += 1 }
init(bad: Void) {
self.count = 0
// no actor hop happens, because non-async init.
Task { await self.click() }
self.click() // 💥 this mutation races with the task!
print(self.count) // 💥 Can print 1 or 2!
}
}
Чтобы предотвратить описанную выше гонку в init(bad:), Swift 5.5 наложил ограничение на то, что можно сделать с self в неасинхронном инициализаторе. В частности, наличие self escape в захвате замыкания или передача (неявно) в вызове метода click вызывает предупреждение о том, что такое использование self будет ошибкой в Swift 6. Но эти ограничения слишком широки, потому что они также отклонил бы инициализаторы, которые не участвуют в гонках, такие как init(ok:) ниже:
actor Clicker {
var count: Int
func click() { self.count += 1 }
nonisolated func announce() { print("performing a click!") }
init(ok: Void) {
self.count = 0
Task { await self.click() }
self.announce() // rejected in Swift 5.5, but is race-free.
}
}
Используя модель изоляции актера, мы знаем, что announce не может касаться счетчика сохраненных свойств, чтобы наблюдать, что щелчок произошел одновременно с инициализатором. Это связано с тем, что объявление не изолировано от экземпляра актора. На самом деле гонка может произойти только в инициализаторе, если после создания задачи появится доступ к count. Это предложение направлено на то, чтобы обобщить эту идею в то, что мы называем изоляцией, чувствительной к потоку, которая использует анализ потока данных для статического доказательства того, что неасинхронные инициализаторы актера свободны от гонок.
Гонки данных в деинициализаторах
В то время как неасинхронные инициализаторы получили ограничения для предотвращения гонок данных в Swift 5.5, деинициализаторы этого не сделали. Тем не менее, deinit все еще могут демонстрировать гонку и незаконные пожизненные расширения self, а это означает, что self переживает вызов deinit. Один из возможных видов гонки в deinit концептуально аналогичен описанному ранее для неасинхронных инициализаторов:
actor Clicker {
var count: Int = 0
func click(_ times: Int) {
for _ in 0..<times {
self.count += 1
}
}
deinit {
let old = count
let moreClicks = 10000
Task { await self.click(moreClicks) } // ❌ This might keep `self` alive after the `deinit`!
for _ in 0..<moreClicks {
self.count += 1 // 💥 these mutations race with the task
}
assert(count == old + moreClicks) // 💥 Might fail due to data-race!
}
}
Есть еще один, более тонкий сценарий для гонок в deinit изолированных типов глобального актора (GAIT). GAIT похож на актора, который использует своего постоянного исполнителя совместно с другими экземплярами. Когда актор или исполнитель GAIT не принадлежат исключительно экземпляру типа, тогда небезопасно обращаться к хранимому свойству, не подлежащему отправке, из deinit:
class NonSendableAhmed {
var state: Int = 0
}
@MainActor
class Maria {
let friend: NonSendableAhmed
init() {
self.friend = NonSendableAhmed()
}
init(sharingFriendOf otherMaria: Maria) {
// While the friend is non-Sendable, this initializer and
// and the otherMaria are isolated to the MainActor. That is,
// they share the same executor. So, it's OK for the non-Sendable value
// to cross between otherMaria and self.
self.friend = otherMaria.friend
}
deinit {
friend.state += 1 // 💥 the deinit is not isolated to the MainActor,
// so this mutation can happen concurrently with other
// accesses to the same underlying instance of
// NonSendableAhmed.
}
}
func example() async {
let m1 = await Maria()
let m2 = await Maria(sharingFriendOf: m1)
doSomething(m1, m2)
}
В приведенном выше примере доступ к изолированным и не подлежащим отправке сохраненным свойствам Марии из deinit небезопасен. Поскольку исполнитель для двух экземпляров Maria является общим, эти два экземпляра могут иметь ссылку на одно и то же изменяемое состояние, которым в данном примере является общий друг. Таким образом, если deinit имел доступ к другу, не получив доступа к общему исполнителю, то могут произойти несинхронизированные одновременные мутации friend. Если бы friend является Sendable, то доступ исполнителя не требовался бы, потому что два экземпляра друга не были бы изменяемыми.
В Swift, когда счетчик ссылок на объект достигает нуля, вызывается его deinit. Это поведение undefined для этого экземпляра self, чтобы затем избежать deinit, потому что это увеличило бы счетчик ссылок объекта обратно до единицы после того, как deinit уже был вызван. Это проблема и для обычных классов, приводящая к случайным сбоям, когда счетчик ссылок достигает нуля во второй раз. Поскольку проблемы с продлением срока службы в deinit ссылочного типа являются общей проблемой, мы откладываем ее решение для будущего предложения.
Изоляция хранимого имущества
Классы, структуры и перечисления в настоящее время могут иметь изоляцию глобального субъекта, независимо применяемую к каждому из их сохраненных свойств. Когда программисты задают значение по умолчанию для хранимого свойства, эти значения по умолчанию вычисляются каждым из неделегирующих инициализаторов типа. Проблема в том, что выражения для этих значений по умолчанию обрабатываются так, как если бы они выполнялись на исполнителе этого глобального актера. Таким образом, можно создать невозможные ограничения для этих инициализаторов:
@MainActor func getStatus() -> Int { /* ... */ }
@PIDActor func genPID() -> ProcessID { /* ... */ }
class Process {
@MainActor var status: Int = getStatus()
@PIDActor var pid: ProcessID = genPID()
init() {} // Problem: what is the isolation of this init?
}
Приведенный выше пример принят в Swift 5.5, но его невозможно реализовать. Поскольку Status и pid изолированы для двух разных глобальных акторов, для неасинхронной инициализации нельзя указать единую изоляцию актора. На самом деле невозможно выполнить соответствующие переходы актора в неасинхронном инициализаторе. В результате getStatus и genPID вызываются без перехода к соответствующему исполнителю.
Делегирование инициализатора
Все номинальные типы в Swift поддерживают делегирование инициализатора, когда один инициализатор вызывает другой для выполнения остальной части инициализации. Для классов правила делегирования инициализатора сложны из-за наличия наследования. Таким образом, классы имеют обязательный и явный convenience модификатор, чтобы различать инициализаторы, которые делегируют. Напротив, типы значений не поддерживают наследование, поэтому правила намного проще: любой init может делегировать, но если это так, то он должен делегировать или присваивать self во всех случаях:
struct S {
var x: Int
init(_ v: Int) { self.x = v }
init(b: Bool) {
if b {
self.init(1)
} else {
self.x = 0 // error: 'self' used before 'self.init' call or assignment to 'self'
}
}
}
В отличие от классов, акторы не поддерживают наследование. Но в предложении для субъектов не указывалось, требуется ли convenience для наличия делегирующего инициализатора. Тем не менее, Swift 5.5 требует использования модификатора convenience для пометки инициализаторов акторов, выполняющих делегирование.
Предлагаемая функциональность
В предыдущих разделах кратко описаны некоторые проблемы с текущим состоянием инициализации и деинициализации в Swift. Оставшаяся часть этого раздела направлена на устранение этих проблем при определении того, чем инициализаторы и деинициализаторы изолированного типа субъекта и глобального субъекта (GAIT) отличаются от тех, которые принадлежат обычному классу. При этом в этом предложении будет показано, как решаются вышеуказанные проблемы.
Инициализаторы без делегирования
Неделегирующий инициализатор субъекта или изолированного типа глобального субъекта (GAIT) требуется для инициализации всех сохраненных свойств этого типа.
Изоляция актеров, чувствительная к потоку
В этом разделе основное внимание уделяется инициализаторам без делегирования для типов акторов, а не GAIT. В Swift 5.5 инициализатор актора, который подчиняется ограничению использования экранирования, означает, что во всем инициализаторе отклоняются следующие элементы:
- Захват self в замыкании.
- Вызов метода или вычисляемого свойства для self.
- Передача self в качестве любого аргумента, будь то по значению, автозамыканию или inout.
Но эти правила являются чрезмерным приближением к ограничениям, необходимым для предотвращения гонок, описанных ранее. Это предложение снимает ограничение на экранирование для инициализаторов. Вместо этого мы предлагаем более простой набор правил. Сначала мы определяем две категории инициализаторов, отличающихся своей изоляцией:
— Инициализатор имеет nonisolated self, если он:
- неасинхронный
- или изолированный глобальный актер
- или nonisolated
— Инициализаторы асинхронных акторов имеют isolated self.
В оставшейся части этого раздела обсуждается, как работают эти два класса инициализаторов.
Инициализаторы с изолированным self
Для асинхронного инициализатора переход к исполнителю актора будет выполнен сразу после того, как self станет полностью инициализированным, чтобы приписать self изоляцию. Выбор этого места для выполнения прыжка исполнителя сохраняет концепцию self на протяжении всего асинхронного инициализатора. То есть до того, как в инициализаторе может произойти какое-либо экранирование использования self, выполняется прыжок исполнителя.
Важно признать, что прыжок исполнителя является точкой приостановки. В инициализаторе есть много возможных точек, где могут произойти эти приостановки, поскольку есть несколько мест, где хранилище само вызывает его инициализацию. Рассмотрим этот пример Боба:
actor Bob {
var x: Int
var y: Int = 2
func f() {}
init(_ cond: Bool) async {
if cond {
self.x = 1 // initializing store
}
self.x = 2 // initializing store
f() // this is ok, since we're on the executor here.
}
}
Проблема с попыткой явно пометить точки приостановки в Bob.init заключается в том, что программистам нелегко отслеживать их, и при этом они недостаточно последовательны, чтобы оставаться неизменными при простом рефакторинге. Добавление или удаление значения по умолчанию для сохраненного свойства или изменение количества сохраненных свойств может сильно повлиять на то, где могут происходить переходы. Рассмотрим этот немного измененный пример из предыдущего:
actor EvolvedBob {
var x: Int
var y: Int
func f() {}
init(_ cond: Bool) async {
if cond {
self.x = 1
}
self.x = 2
self.y = 2 // initializing store
f() // this is ok, since we're on the executor here.
}
}
По сравнению с Bob, единственное изменение, внесенное в EvolvedBob, заключается в том, что его значение по умолчанию для y было преобразовано в безусловное хранилище в теле инициализатора. С точки зрения наблюдения Bob.init и EvolvedBob.init идентичны. Но с точки зрения реализации точки приостановки выполнения прыжка исполнителя существенно различаются. Если эти точки требуют какой-то аннотации в Swift, например, с ожиданием, то причину, по которой эти точки приостановки перемещаются, трудно объяснить программистам.
Подводя итог, мы предлагаем неявно выполнять приостановку для перехода к исполнителю акторов после инициализации self вместо того, чтобы программисты отмечали эти точки явно, по следующим причинам:
- Поиск и постоянное обновление точек приостановки раздражает программистов.
- Причина, по которой некоторые простые операции сохранения свойства могут вызвать приостановку, связана с деталями реализации, которые трудно объяснить программистам.
- Польза от маркировки этих подвесок очень низкая. Известно, что к моменту приостановки ссылка на self уникальна, поэтому невозможно создать ситуацию повторного входа актера.
- В языке уже есть прецедент для выполнения неявных приостановок, а именно для Async let, когда преимущества перевешивают недостатки.
Чистый эффект этих неявных переходов между исполнителями заключается в том, что для программистов Async инициализатор не имеет добавленных к нему дополнительных правил! То есть программисты могут просто рассматривать инициализатор как изолированный, как любой обычный Async метод! Точки, чувствительные к потоку, где прыжок вставляется в инициализатор, можно безопасно игнорировать как деталь реализации для всех, кроме самых редких ситуаций. Например:
actor OddActor {
var x: Int
init() async {
let name = Thread.current.name
self.x = 0 // initializing store
assert(name == Thread.current.name) // may fail
}
}
Обратите внимание, что вызывающие OddActor.init не могут предполагать, что вызываемый объект не выполнил приостановку, как и в случае с любым асинхронным методом, поскольку для входа в инициализатор требуется ожидание. Таким образом, эта возможность наблюдения за немаркированной подвеской крайне ограничена.
Углубленные обсуждения
Оставшаяся часть этого подраздела охватывает некоторые технические детали, которые не требуются для понимания этого предложения и могут быть безопасно пропущены.
Замечания по реализации компилятора: Идентификация присваивания, которое полностью инициализирует self, требует нетривиального анализа потока данных. Такой анализ невозможно выполнить в начале компилятора, во время проверки типов. Означает ли принятие этого предложения, что средство проверки изоляции субъектов, запускаемое как часть проверки типов, потребует дополнительного анализа или значительных изменений? Неа! Мы можем полагаться на существующие ограничения на использование self до инициализации, чтобы исключить все места, где self может считаться только nonisolated:
func isolatedFunc(_ a: isolated Alice) {}
actor Alice {
var x: Int
var y: Task<Void, Never>
nonisolated func nonisolatedMethod() {}
func isolatedMethod() {}
init() async {
self.x = self.nonisolatedMethod() // error: illegal use of `self` before initialization.
self.y = Task { self.isolatedMethod() } // error: illegal capture of `self` before initialization
Task {
self.isolatedMethod() // no await needed, since `self` is isolated.
}
self.isolatedMethod() // OK
isolatedFunc(self) // OK
}
}
Это означает, что средство проверки изоляции субъектов, запущенное до преобразования программы в SIL, может единообразно рассматривать параметр self как имеющий isolated self для асинхронного инициализатора, описанного выше. Позже в SIL проверка «определено перед использованием» (т. Е. «Определенная инициализация») найдет и выдаст указанные выше ошибки. В качестве бонуса тот же анализ можно использовать для поиска инициализирующего присваивания и введения приостановки перехода к исполнителю актора.
Безопасность состязания гонки: С точки зрения корректности предлагаемые isolated self не допускают состязаний, потому что переход к исполнителю актора происходит сразу после инициализирующего сохранения в self, но до начала выполнения следующего оператора. Получение доступа к исполнителю именно в этот момент предотвращает гонки, потому что убежать от себя к другой задаче можно только после этого момента. В приведенном выше примере с Алисой мы можем увидеть это в действии, где отклоненное присвоение self.y связано с незаконным захватом self.
Выполняется только одна приостановка: можно создать инициализатор с потоком управления, который несколько раз пересекает неявные точки приостановки, как показано в Бобе выше, и такие циклы, как:
actor LoopyBob {
var x: Int
init(_ counter: Int) async {
var i = 0
repeat {
self.x = 0 // initializing store
i += 1
} while i < counter
}
}
Получив доступ к исполнителю путем пересечения первой точки приостановки, пересечение другой точки приостановки не изменяет исполнителя и фактически не выполняет приостановку. Избегание этих ненужных переходов исполнителя — это оптимизация, которая выполняется во всем Swift (например, саморекурсивные async и isolated функции).
Инициализаторы с неизолированным self
Категория инициализаторов субъектов, которые имеют nonisolated self, включает те, которые не являются асинхронными или имеют изоляцию, отличную от изоляции по отношению к себе. В отличие от своих методов, неасинхронный инициализатор актора не требует вызова await, потому что нет экземпляра актора для синхронизации. Кроме того, инициализатор с nonisolated self может получить доступ к сохраненным свойствам экземпляра без синхронизации, когда это безопасно.
Доступ к сохраненным свойствам self необходим для начальной загрузки экземпляра актора. Такой доступ считается более слабой формой изоляции, основанной на монопольном доступе к эталонному self. Если self ускользает от инициализатора, такая уникальность больше не может быть гарантирована без трудоемкого анализа. Таким образом, изоляция self затухает (или изменяется) на nonisolated во время любого использования self, которое не является прямым доступом к хранимому свойству. Это изменение происходит один раз на заданном пути потока управления и сохраняется до конца инициализатора. Вот несколько примеров использования self в инициализаторе, которые заставляют его распадаться на nonisolated ссылку:
- Передача self в качестве аргумента при любом вызове процедуры. Это включает в self:
- Вызов метода self.
- Доступ к вычисляемому свойству self, в том числе с использованием оболочки свойства.
- Запуск наблюдаемого свойства (т. е. свойства didSet и/или willSet).
2. Захват self в замыкании (или автозамыкании).
3. Сохранение self в памяти.
Рассмотрим следующий пример, который помогает продемонстрировать, как работает это затухание изоляции:
class NotSendableString { /* ... */ }
class Address: Sendable { /* ... */ }
func greetCharlie(_ charlie: Charlie) {}
actor Charlie {
var score: Int
let fixedNonSendable: NotSendableString
let fixedSendable: Address
var me: Self? = nil
func incrementScore() { self.score += 1 }
nonisolated func nonisolatedMethod() {}
init(_ initialScore: Int) {
self.score = initialScore
self.fixedNonSendable = NotSendableString("Charlie")
self.fixedSendable = NotSendableString("123 Main St.")
if score > 50 {
nonisolatedMethod() // ✅ a nonisolated use of `self`
greetCharlie(self) // ✅ a nonisolated use of `self`
self.me = self // ✅ a nonisolated use of `self`
} else if score < 50 {
score = 50
}
assert(score >= 50) // ❌ error: cannot access mutable isolated storage after `nonisolated` use of `self`
_ = self.fixedNonSendable // ❌ error: cannot access non-Sendable property after `nonisolated` use of `self`
_ = self.fixedSendable
Task { await self.incrementScore() } // ✅ a nonisolated use of `self`
}
}
Центральной частью этого примера является цепочка операторов if-else, которая вводит несколько путей потока управления в инициализаторе. В теле одного из первых условных блоков появляется несколько разных nonisolated употребления self. В других условных случаях (блок else-if и неявно пустое else) чтение и запись оценки по-прежнему допустимы. Но как только поток управления встречается после оператора if-else в assert, self считается nonisolated, потому что один из блоков, которые могут достичь этой точки, вводит неизоляцию.
Как следствие, единственными сохраненными свойствами, доступными после того, как self становится nonisolated, являются свойства с привязкой let, тип которых — Sendable. Диагностика, выдаваемая для нелегального доступа к другим сохраненным свойствам, укажет на одно из более ранних применений self, которое вызвало изменение изоляции. Смысл «ранее» здесь с точки зрения потока управления, а не с точки зрения того, где операторы появляются в программе. Чтобы увидеть, как это может происходить на практике, рассмотрим альтернативное определение Charlie.init, в котором используется defer:
init(hasADefer: Void) {
self.score = 0
defer {
print(self.score) // ❌ error: cannot access mutable isolated storage after `nonisolated` use of `self`
}
Task { await self.incrementScore() } // note: a nonisolated use of `self`
}
Здесь мы откладываем печать self.score до конца инициализатора. Но, поскольку self фиксируется в замыкании до выполнения defer выполнения, такое чтение self.score не всегда защищено от гонок данных, поэтому оно помечается как ошибка. Другой сценарий, в котором неправомерный доступ к свойству может визуально предшествовать затуханию использования, — это циклы for:
init(hasALoop: Void) {
self.score = 0
for i in 0..<10 {
self.score += i // error: cannot access mutable isolated storage after `nonisolated` use of `self`
greetCharlie(self) // note: a nonisolated use of `self`
}
}
В этом примере с циклом for мы по-прежнему должны помечать мутацию self.score в цикле как ошибку, потому что это безопасно только на первой итерации цикла. В последующих итерациях цикла это будет небезопасно, потому что self может быть одновременно доступен после экранирования в вызове процедуры.
Другие примеры
За исключением неасинхронных инициализаций, изолированный инициализатор глобального субъекта или тот, который помечен как nonisolated, будет иметь nonisolated self. Рассмотрим этот пример такого инициализатора:
func printStatus(_ s: Status) { /* ... */}
actor Status {
var valid: Bool
// an isolated method
func exchange(with new: Bool) {
let old = valid
valid = new
return old
}
// an isolated method
func isValid() { return self.valid }
// A `nonisolated self` initializer that calls isolated methods with `await`.
@MainActor init(_ val: Bool) async {
self.valid = val
let old = await self.exchange(with: false) // note: a non-isolated use
assert(old == val)
_ = self.valid // ❌ error: cannot access mutable isolated storage after non-isolated use of `self`
let isValid = await self.isValid() // ✅ OK
assert(isValid == false)
}
}
Обратите внимание, что вызов изолированного метода из инициализатора с nonisolated self разрешен при условии, что вы можете await вызова. Этот вызов считается неизолированным использованием, т.е. Это первое использование self, кроме доступа к хранимому свойству. После этого доступ к большинству сохраненных свойств в init теряется, как и в случае неасинхронного режима. Поскольку этот инициализатор является асинхронным, технически он может await чтения значения Sendable из self.valid. Но мы решили запретить ожидаемый доступ к сохраненным свойствам в этой ситуации. См. обсуждение в разделе «Рассматриваемые альтернативы» для получения более подробной информации.
Углубленные обсуждения
Оставшаяся часть этого подраздела охватывает некоторые технические детали, которые не требуются для понимания этого предложения и могут быть безопасно пропущены.
Ограничения статического анализа Не все циклы повторяются более одного раза или вообще повторяются. Компилятор Swift может свободно отклонять программы, которые могут никогда не демонстрировать гонку динамически, основываясь на статическом предположении, что циклы могут повторяться более одного раза, а условные блоки могут выполняться. Чтобы сделать это более конкретным, рассмотрим эти два глупых цикла:
init(hasASillyLoop1: Void) {
self.score = 0
while false {
self.score += i // error: cannot access isolated storage after `nonisolated` use of `self`
greetCharlie(self) // note: a nonisolated use of `self`
}
}
init(hasASillyLoop2: Void) {
self.score = 0
repeat {
self.score += i // error: cannot access isolated storage after `nonisolated` use of `self`
greetCharlie(self) // note: a nonisolated use of `self`
} while false
}
В обоих приведенных выше циклах программисту ясно, что никакой гонки не произойдет, потому что поток управления не будет динамически достигать инструкции, увеличивающей score, после передачи self в вызове процедуры. Для этих тривиальных примеров компилятор может доказать, что эти циклы не выполняются более одного раза, но это не гарантируется из-за ограничений статического анализа.
Безопасность в гонке данных
По сути, концепция распада изоляции предотвращает гонку данных, запрещая доступ к сохраненным свойствам, когда компилятор больше не может доказать, что ссылка на себя не будет использоваться одновременно. Из соображений эффективности компилятор может не выполнять межпроцедурный анализ, чтобы доказать, что передача self другой функции защищена от параллельного доступа другой задачи. Межпроцедурный анализ по своей природе ограничен из-за природы модулей в Swift (т.е. отдельной компиляции). Сразу же после того, как self вышел из инициализатора, обработка self в инициализаторе изменяется, чтобы соответствовать статусу unacquired исполнителя актора.
Изолированные типы глобального актора
Неизолированный инициализатор изолированного типа глобального субъекта (GAIT) находится в той же ситуации, что и инициализатор неасинхронного актора, поскольку он должен запускать экземпляр без защиты исполнителя. Таким образом, мы можем построить гонку данных точно так же, как и раньше:
@MainActor
class RequiresFlowIsolation<T>
where T: Sendable, T: Equatable {
var item: T
func mutateItem() { /* ... */ }
nonisolated init(with t: T) {
self.item = t
Task { await self.mutateItem() }
self.item = t // 💥 races with the task!
}
}
Чтобы решить эту проблему, мы предлагаем применить изоляцию актеров, чувствительных к потоку, к инициализаторам GAIT, которые помечены как неизолированные.
Для изолированных инициализаторов GAIT имеют возможность получить изоляцию актера до вызова самого инициализатора. Это связано с тем, что его исполнителем является статический экземпляр, существующий даже до выделения неинициализированной памяти для экземпляра GAIT. Таким образом, все изолированные инициализаторы GAIT требуют await вызывающих объектов, которые получат доступ к нужному исполнителю до начала инициализации. Этот исполнитель удерживается до тех пор, пока инициализатор не вернется. Таким образом, для изолированных инициализаторов GAIT нет опасности гонки среди изолированных хранимых свойств:
@MainActor
class ProtectedByExecutor<T: Equatable> {
var item: T
func mutateItem() { /* ... */ }
init(with t: T) {
self.item = t
Task { self.mutateItem() } // ✅ we're on the executor when creating this task.
assert(self.item == t) // ✅ always true, since we hold the executor here.
}
}
GAIT с nonisolated хранимыми свойствами полагаются на существующие ограничения Swift для Sendable, чтобы помочь предотвратить гонки данных.
Делегирование инициализаторов
В этом разделе определяются синтаксическая форма и правила делегирования инициализаторов для типов Акторов и изолированных от глобальных Акторов (GAIT).
Синтаксическая форма
Хотя акторы являются ссылочными типами, их делегирующие инициализаторы будут следовать тем же основным правилам, которые существуют для типов значений, а именно:
- Если тело инициализатора содержит вызов некоторого self.init, то это делегирующий инициализатор. Ключевое слово convenience не требуется.
- Для делегирования инициализаторов необходимо всегда вызывать self.init на всех путях, прежде чем можно будет использовать self.
Причина такого различия между типами актора и класса заключается в том, что акторы не поддерживают наследование, поэтому они могут избавиться от сложности делегирования инициализатора класса. GAIT используют ту же синтаксическую форму, что и обычные классы, для определения делегирующих инициализаторов.
Изоляция
Как и их аналоги без делегирования, инициализатор делегирования актора имеет либо isolated self ссылку, либо nonisolated self ссылку. Процедура принятия решения для категоризации этих инициализаторов точно такая же: неасинхронные делегирующие инициализаторы имеют nonisolated self и т. д.
Но делегирующие инициализаторы актора имеют более простые правила относительно того, что может отображаться в их теле, потому что им не требуется инициализировать сохраненные свойства экземпляра. Таким образом, вместо использования чувствительной к потоку изоляции акторов делегирующие инициализаторы имеют единую изоляцию для self, очень похожую на обычную функцию.
Возможность отправки
При передаче значений любому из инициализаторов актора из-за пределов этого актора эти значения должны быть доступны для Sendable. Таким образом, во время инициализации нового экземпляра «граница» актора с точки зрения возможности отправки начинается с исходного места вызова к одному из его инициализаторов. Это правило заставляет программистов правильно обрабатывать значения Sendable при создании нового экземпляра актора. По сути, у программистов будет только два варианта инициализации non-Sendable хранимого свойства актора:
class NotSendableType { /* ... */ }
struct Piece: Sendable { /* ... */ }
actor Greg {
var ns: NotSendableType
// Option 1: an initializer that can be called from anywhere,
// because its arguments are Sendable.
init(fromPieces ps: (Piece, Piece)) {
self.ns = NotSendableType(ps)
}
// Option 2: an initializer that can only be delegated to,
// because its arguments are not Sendable.
init(with ns: NotSendableType) {
self.init(fromPieces: ns.getPieces())
}
}
Как показано в приведенном выше примере, вы можете создать актор с сохраненным свойством, не подлежащим отправке. Но вы должны создать новый экземпляр этого типа из фрагментов данных, доступных для отправки, чтобы сохранить их в экземпляре актора. Оказавшись внутри инициализатора актора, значения, не подлежащие отправке, могут свободно передаваться при делегировании другому инициализатору, вызове его методов и т. д. Следующий пример иллюстрирует это правило:
class NotSendableType { /* ... */ }
struct Piece: Sendable { /* ... */ }
actor Gene {
var ns: NotSendableType
init(_ ns: NotSendableType) {
self.ns = ns
}
init(with ns: NotSendableType) async {
self.init(ns) // ✅ non-Sendable is OK during initializer delegation...
someMethod(ns) // ✅ and when calling a method from an initializer, etc.
}
init(fromPieces ps: (Piece, Piece)) async {
let ns = NotSendableType(ps)
await self.init(with: ns) // ✅ non-Sendable is OK during initializer delegation
}
func someMethod(_: NotSendableType) { /* ... */ }
}
func someFunc() async {
let ns = NotSendableType()
_ = Gene(ns) // ❌ error: cannot pass non-Sendable value across actor boundary
_ = await Gene(with: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
_ = await Gene(fromPieces: ns.getPieces()) // ✅ OK because (Piece, Piece) is Sendable
_ = await SomeGAIT(isolated: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
_ = await SomeGAIT(secondNonIso: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
}
Для изолированного типа глобального субъекта (GAIT) то же правило применяется к его неизолированным инициализаторам. Таким образом, при входе в такой инициализатор из-за пределов актора значения должны быть Sendable. Отличия от актера в том, что:
- Вызывающая сторона первого инициализатора может быть уже изолирована от глобального актора, поэтому нет барьера Sendable (как обычно).
- При делегировании от неизолированного инициализатора к тому, который изолирован от глобального субъекта, значение должно быть Sendable.
Второе отличие проявляется только тогда, когда nonisolated и async инициализатор делегирует полномочия изолированному инициализатору GAIT:
@MainActor
class SomeGAIT {
var ns: NotSendableType
init(isolated ns: NotSendableType) {
self.ns = ns
}
nonisolated init(firstNonIso ns: NotSendableType) async {
await self.init(isolated: ns) // ❌ error: cannot pass non-Sendable value across actor boundary
}
nonisolated init(secondNonIso ns: NotSendableType) async {
await self.init(firstNonIso: ns) // ✅
}
}
Барьер в приведенном выше примере можно устранить, удалив nonisolated атрибут, чтобы инициализатор имел соответствующую изоляцию.
Деинициализаторы
В Swift 5.5 внутри deinit можно создать два разных типа гонок данных с актором или изолированным типом глобального актора (GAIT), как показано в предыдущем разделе. Первый включает в себя ссылку на себя, совместно используемого с другой задачей, а второй — на акторов, имеющих общих исполнителей.
Чтобы решить первый тип гонки, мы предлагаем использовать те же правила изоляции чувствительного к потоку актора, которые обсуждались ранее для неизолированного self, применяя их к deinit актора. Deinit подпадает под категорию неизолированного self, потому что он фактически является неасинхронным, неделегирующим инициализатором, целью которого является очистка или удаление, а не начальная загрузка. В частности, deinit начинается с уникальной ссылки на self, поэтому правила распада на неизолированное self идеально совпадают. Это решение применимо к деинициализации как типов актеров, так и GAIT.
Чтобы решить вторую гонку, мы предлагаем, чтобы deinit мог получить доступ только к сохраненным свойствам self, которые являются Sendable. Это означает, что, даже если self является уникальной ссылкой и не превратилось в неизолированное, можно получить доступ только Sendable хранимым свойствам актора или GAIT. Это ограничение не требуется для инициализации, потому что у инициализатора есть известные сайты вызовов, которые проверяются на изоляцию и отправляемые аргументы. Недостаток знания о том, когда и где будет вызываться deinit, является причиной того, что дейниты должны нести это дополнительное бремя. По сути, изолированное от субъекта состояние, не подлежащее отправке, может быть деинициализировано субъектом только путем вызова deinit этого состояния.
Вот пример, иллюстрирующий новые правила для deinit:
actor A {
let immutableSendable = SendableType()
var mutableSendable = SendableType()
let nonSendable = NonSendableType()
init() {
_ = self.immutableSendable // ✅ ok
_ = self.mutableSendable // ✅ ok
_ = self.nonSendable // ✅ ok
f(self) // trigger a decay to `nonisolated self`
_ = self.immutableSendable // ✅ ok
_ = self.mutableSendable // ❌ error: must be immutable
_ = self.nonSendable // ❌ error: must be sendable
}
deinit {
_ = self.immutableSendable // ✅ ok
_ = self.mutableSendable // ✅ ok
_ = self.nonSendable // ❌ error: must be sendable
f(self) // trigger a decay to `nonisolated self`
_ = self.immutableSendable // ✅ ok
_ = self.mutableSendable // ❌ error: must be immutable
_ = self.nonSendable // ❌ error: must be sendable
}
}
В приведенном выше примере единственная разница между init и deinit состоит в том, что deinit может получить доступ только к, доступным свойствам Sendable, тогда как init может получить доступ к свойствам, не предназначенным для отправки, до распада изоляции.
Изоляция глобального субъекта и члены экземпляра
Основная проблема с изоляцией глобального субъекта для хранимых свойств типа заключается в том, что если свойство изолировано для глобального субъекта, то его выражение значения по умолчанию также изолировано для этого субъекта. Поскольку изоляция глобального субъекта может применяться независимо к каждому хранимому свойству, может быть выдвинуто невозможное требование изоляции. Изоляция, необходимая для неделегирующих и неасинхронных инициализаторов типа, будет представлять собой объединение всей изоляции, применяемой к его сохраненным свойствам, имеющим значение по умолчанию. Это связано с тем, что неасинхронный инициализатор не может перейти к любому исполнителю, а функция не может быть изолирована от двух глобальных акторов. В настоящее время Swift 5.5 принимает программы с такими невыполнимыми требованиями.
Чтобы решить эту проблему, мы предлагаем удалить любую изоляцию, применяемую к выражениям значений по умолчанию сохраненных свойств, которые являются членами номинального типа. Вместо этого эти выражения будут рассматриваться системой типов как неизолированные. Если для инициализации этих свойств требуется изоляция, то всегда можно определить init и обеспечить соответствующую изоляцию.
Для глобальных или статических хранимых свойств изоляция выражения значения по умолчанию будет по-прежнему соответствовать изоляции, примененной к свойству. Эта изоляция необходима для поддержки таких объявлений, как:
@MainActor
var x = 20
@MainActor
var y = x + 2
Удаление избыточной изоляции
Изоляция глобального субъекта для хранимого свойства обеспечивает безопасный одновременный доступ к хранилищу, занимаемому этим хранимым свойством, в экземплярах типа. Например, если pid является хранимым свойством, изолированным от актора (т. е. без наблюдателя или оболочки свойства), то доступ p.pid.reset() защищает только чтение памяти pid из p, а не вызов reset после. Таким образом, для типов значений (перечисления и структуры) изоляция глобального субъекта для этих хранимых свойств принципиально бесполезна: мутации памяти, занимаемой хранимым свойством в типе значения, по умолчанию безопасны для параллелизма, поскольку изменяемые переменные не могут использоваться совместно. между задачами. Например, это ошибка при попытке захватить изменяемую переменную в закрытии Sendable:
@MainActor
struct StatTracker {
var count = 0
mutating func update() {
count += 1
}
}
var st = StatTracker()
Task { await st.update() } // error: mutation of captured var 'st' in concurrently-executing code
В результате нет возможности одновременно изменить память структуры, независимо от того, изолированы ли сохраненные свойства структуры для глобального субъекта. Возможность совместного использования экземпляра зависит только от того, привязан он к переменной или нет, и единственный разрешенный вид совместного использования — это копирование. Любые мутации ссылочных типов, хранящихся в структуре, требуют обычной изоляции акторов, применяемой к самому ссылочному типу. Другими словами, применение изоляции глобального субъекта к хранимому свойству, содержащему тип класса, не защищает членов этого экземпляра класса от одновременного доступа. Поэтому мы предлагаем убрать требование, чтобы доступ к этим свойствам был защищен изоляцией. То есть доступ к этим сохраненным свойствам не требует await.
Предложение глобальных акторов явно исключает типы субъектов из хранимых свойств, изолированных от глобального субъекта. Но в Swift 5.5 это не применяется компилятором. Мы считаем, что это правило должно соблюдаться, т. е. хранилище актора должно быть единообразно изолировано от экземпляра актора. Одним из преимуществ этого правила является то, что оно снижает вероятность ложного совместного использования потоков. В частности, только один поток будет иметь доступ для записи к памяти, занимаемой экземпляром актора в любой момент времени.
Совместимость источника
В этом предложении есть некоторые изменения, совместимые с предыдущими версиями или легко переносимые:
- Набор объявлений инициализации, принятых компилятором в Swift 5.5 (без выдаваемых предупреждений), является строгим подмножеством тех, которые будут разрешены, если это предложение будет принято, т. е. изоляция, чувствительная к потоку, расширяет набор разрешенных программ.
- Появления удобства в инициализаторе актора могут быть проигнорированы и/или исправлено исправление.
- Появление избыточных аннотаций изоляции глобального субъекта в обычных хранимых свойствах (скажем, в типах значений) можно игнорировать и/или выпустить исправление.
Но есть и другие, которые вызовут нетривиальный разрыв исходного кода для исправления дыр в модели параллелизма Swift 5.5, например:
- Набор деинитов, принимаемых компилятором для акторов и GAIT’ов, будет сужен.
- GAIT будут иметь защиту от гонки данных, применяемую к их неизолированным инициализациям, что немного сужает набор допустимых объявлений инициализации.
- Изоляция глобального субъекта для членов хранимого свойства типа субъекта запрещена.
- Члены хранимого свойства, к которым по-прежнему разрешено применять изоляцию субъектов, будут иметь неизолированное выражение значения по умолчанию.
Обратите внимание, что эти изменения в GAIT будут применяться только к классам, определенным в Swift. Предполагается, что классы, импортированные из Objective-C с применением изоляции MainActor, не имеют гонок данных.
Рассмотрены альтернативы
В этом разделе объясняются альтернативные подходы, которые в конечном итоге не были выбраны для этого предложения.
Введение неизоляции после полной инициализации self
Заманчиво сказать, что, чтобы избежать введения в язык еще одного понятия, неизоляция должна начинаться в точке, где self становится полностью инициализированным. Но, поскольку поток управления может переходить из области, где self полностью инициализирован, в другую область, где self может быть полностью инициализирован, этого правила недостаточно, чтобы определить, есть ли у инициализатора гонка. Вот два примера инициализаторов, где это упрощенное правило не работает:
actor CounterExampleActor {
var x: Int
func mutate() { self.x += 1 }
nonisolated func f() {
Task { await self.mutate() }
}
init(ex1 cond: Bool) {
if cond {
self.x = 0
f()
}
self.x = 1 // if cond is true, this might race!
}
init(ex2 max: Int) {
var i = 0
repeat {
self.x = i // after first loop iteration, this might race!
f()
i += 1
} while i < max
}
}
В Swift self можно свободно использовать сразу после полной инициализации. Таким образом, если мы свяжем неизолированность с тем, полностью ли инициализируется self при каждом использовании, оба приведенных выше инициализатора должны быть приняты, даже если они допускают гонки данных: f может ускользнуть от self в задачу, которая мутирует актор, но инициализатор продолжит работу после возврата из f с несинхронизированным доступом к его сохраненным свойствам.
С учетом правил изоляции потока в этом предложении оба вышеприведенных доступа к свойствам, которые могут соперничать, отклоняются из-за ошибки изоляции потока. Источник неизолированности будет идентифицирован как вызов f(), чтобы программисты могли исправить свой код.
А теперь представьте, что произойдет, если вызовы f, указанные выше, будут удалены. С предложенными правилами изоляции программы теперь будут приняты, потому что они безопасны: нет источника неизоляции. Если бы мы сказали, что неизоляция всегда начинается сразу после полной инициализации self и сохраняется до конца инициализатора, то даже без вызовов f инициализаторы выше были бы без необходимости отклонены.
Разрешение await для доступа к свойствам в неизолированных самоинициализаторах
В неизолированном self инициализаторе мы отклоняем доступ к хранимым свойствам после первого неизолированного использования. Для неасинхронного инициализатора нет альтернативы отклонению программы, поскольку в этом контексте нельзя перейти к исполнителю актора. Но асинхронный инициализатор, который не изолирован от self, может выполнить этот прыжок:
actor AwkwardActor {
var x: SomeClass
nonisolated func f() { /* ... */ }
nonisolated init() async {
self.x = SomeClass()
let a = self.x
f()
let b = await self.x // SomeClass would need to be Sendable for this access.
print(a + b)
}
}
С точки зрения реализации можно поддерживать описанную выше программу, в которой доступ к свойствам может стать асинхронным выражением на основе изоляции, чувствительной к потоку. Но это предложение занимает субъективную позицию, согласно которой такой код следует отвергнуть.
Выразительность, полученная за счет поддержки такого чувствительного к потоку асинхронного доступа к свойствам, не стоит той путаницы, которую они могут создать. Для программистов, которые просто читают этот допустимый код в проекте, ожидание может показаться ненужным и бросить вызов их пониманию изоляции, применяемой ко всем функциям. Но этот конкретный вид неизолированного self и async инициализатора был бы единственным местом, где можно было бы продемонстрировать читателям, что изоляция может изменить промежуточную функцию в правильном коде Swift.
Возможность наблюдать промежуточную функцию изменения изоляции в правильном коде Swift является причиной отклонения вышеуказанной программы. В этом предложении говорится, что для неасинхронного и неизолированного self инициализатора доступ к некоторым свойствам отклоняется из-за нарушения одного и того же концептуального изменения изоляции. Правильная формулировка таких инициализаторов не имеет заметного изменения изоляции, поэтому случайные читатели не замечают ничего необычного. Только при изменении этого кода концепция распада изоляции становится актуальной. Но «распад» изоляции — это всего лишь инструмент, используемый для объяснения концепции в этом предложении. Программистам нужно только иметь в виду, что доступ к сохраненным свойствам теряется после того, как вы экранируете self в инициализаторе.
Деинициализаторы асинхронных акторов
Одна из идей для обхода невозможности синхронизации из deinit с актором или исполнителем GAIT перед уничтожением состоит в том, чтобы обернуть тело deinit в задачу. Это фактически позволит неасинхронному deinit действовать так, как если бы он был асинхронным в своем теле. Нет другого способа определить асинхронный deinit, так как никогда не гарантируется, что вызывающие deinit будут находиться в асинхронном контексте.
Основная опасность здесь заключается в том, что в настоящее время в Swift поведение undefined для ссылки на self, чтобы избежать deinit и сохраниться после завершения deinit, что должно быть возможно, если deinit был асинхронным. Единственным другим вариантом была бы блокировка deinit, но параллелизм Swift предназначен для предотвращения блокировки.
Требование аргументов Sendable только для делегирования инициализаторов
Делегирование инициализаторов концептуально является хорошим местом для создания нового экземпляра неотправляемого значения для передачи актору во время инициализации. Это может работать, только если сказать, что только неизолированные self делегирующие инициализаторы могут принимать значение, не подлежащее отправке, из любого контекста. Но также статус делегирования инициализатора теперь должен быть опубликован в интерфейсе типа, т. е. требуется некоторая аннотация, например, для convenience. Устранение необходимости convenience было выбрано вместо значений, не подлежащих отправке, для определенного типа делегирующего инициализатора по нескольким причинам:
- Правила для значений и инициализаторов, доступных для отправки, станут сложными, поскольку будут зависеть от трех факторов: статуса делегирования, изоляции от self и контекста вызывающей стороны. Обычные правила Sendable зависят только от двух.
- Требовать convenience только для одного узкого варианта использования не стоит.
- Статические функции, использующие фабричный шаблон, могут заменить необходимость в инициализаторах аргументами без возможности отправки, которые можно вызывать из любого места.
Влияние на стабильность ABI
Это предложение не влияет на стабильность ABI.
Влияние на устойчивость API
Это предложение не влияет на отказоустойчивость API.
Благодарности
Благодарим участников Swift Forums за время, потраченное на чтение этого предложения и его предыдущих версий и предоставление комментариев.