0316 — Глобальные актеры

Введение

Актеры — это новый тип ссылочного типа, который защищает данные своего экземпляра от одновременного доступа. Субъекты Swift достигают этого с помощью изоляции акторов, которая гарантирует (во время компиляции), что все обращения к этим данным экземпляра проходят через механизм синхронизации, который сериализует выполнение.

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

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

Потоки быстрой эволюции: Шаг №1, Шаг №2

Мотивация

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

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

Предложенное решение

Глобальный актор — это глобально уникальный актор, определяемый типом. Этот тип становится настраиваемым атрибутом (аналогично типам-оболочкам свойств или типам построителей результатов). В любом объявлении можно указать, что оно изолировано от субъекта по отношению к этому конкретному глобальному актеру назвав тип глобального субъекта в качестве атрибута, после чего вступают в силу все обычные ограничения изоляции субъекта: к объявлению можно получить синхронный доступ только из другого объявления. На том же глобальном субъекте, но к ним можно получить асинхронный доступ откуда угодно. Например, в этом предложении MainActor представлен как глобальный актор, описывающий основной поток. Его можно использовать, чтобы потребовать, чтобы определенные функции выполнялись только в основном потоке:

@MainActor var globalTextSize: Int

@MainActor func increaseTextSize() { 
  globalTextSize += 2   // okay: 
}

func notOnTheMainActor() async {
  globalTextSize = 12  // error: globalTextSize is isolated to MainActor
  increaseTextSize()   // error: increaseTextSize is isolated to MainActor, cannot call synchronously
  await increaseTextSize() // okay: asynchronous call hops over to the main thread and executes there
}

Определение глобальных участников

Глобальный актор — это тип, который имеет атрибут @globalActor и содержит static свойство с именем shared, предоставляющее общий экземпляр актора. Например:

@globalActor
public struct SomeGlobalActor {
  public actor MyActor { }

  public static let shared = MyActor()
}

Глобальный тип субъекта может быть структурой, перечислением, субъектом или final классом. По сути, это просто тип маркера, который обеспечивает доступ к фактическому общему экземпляру актора через shared. Общий экземпляр — это глобально уникальный экземпляр субъекта, который становится синонимом типа глобального субъекта и будет использоваться для синхронизации доступа к любому коду или данным, аннотированным глобальным субъектом.

Глобальные акторы неявно соответствуют протоколу GlobalActor, который описывает требование shared. Соответствие типа @globalActor протоколу GlobalActor должно происходить в том же исходном файле, что и определение типа, и само соответствие не может быть условным.

The main Actor

The main Actor — это глобальный актор, описывающий основной поток:

@globalActor
public actor MainActor {
  public static let shared = MainActor(...)
}

Примечание. Интеграция основного актора с основным потоком системы требует поддержки пользовательских исполнителей, что является предметом другого предложения, а также специальной интеграции с системным понятием основного потока. Для систем, использующих библиотеку Apple Dispatch в качестве базовой реализации параллелизма, главный субъект использует настраиваемый исполнитель, который обертывает основную очередь отправки. Он также определяет, когда код динамически выполняется на основном действующем лице, чтобы избежать дополнительных «прыжков» при выполнении асинхронного вызова функции @MainActor.

Использование глобальных акторов в функциях и данных

Как показано в нашем первом примере, и функциям, и данным можно присвоить тип глобального субъекта, чтобы изолировать их от этого глобального субъекта. Обратите внимание, что глобальные субъекты не ограничены глобальными функциями или данными, как в первом примере. Члены типов и протоколов также можно помечать как принадлежащие глобальному актору. Например, в контроллере представления для графического пользовательского интерфейса мы ожидаем получать уведомления о взаимодействиях с пользователем в основном потоке и должны обновлять пользовательский интерфейс в основном потоке. Поэтому нужно, чтобы как методы, вызываемые при уведомлении, так и данные, которые они используют, находились на основном действующем потоке. Вот небольшая часть контроллера представления из примера кода AppKit:

class IconViewController: NSViewController {
  @MainActor @objc private dynamic var icons: [[String: Any]] = []
    
  @MainActor var url: URL?
    
  @MainActor private func updateIcons(_ iconArray: [[String: Any]]) {
    icons = iconArray
        
    // Notify interested view controllers that the content has been obtained.
    // ...
  }
}

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

Пример кода фактически запускает обновление, когда установлено свойство url. С глобальными акторами это будет выглядеть примерно так:

@MainActor var url: URL? {
  didSet {
    // Asynchronously perform an update
    Task.detached { [url] in                   // not isolated to any actor
      guard let url = url else { return }
      let newIcons = self.gatherContents(url)
      await self.updateIcons(newIcons)         // 'await' required so we can hop over to the main actor
    }
  }
}

Типы функций глобального субъекта

Синхронный тип функции может быть определен, чтобы указать, что функция может быть вызвана только для определенного глобального субъекта:

var callback: @MainActor (Int) -> Void

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

Ссылка на функцию, изолированную от глобального субъекта, будет иметь тип функции с глобальным субъектом. Сами ссылки не подлежат проверке на изоляцию актора, потому что изоляция актора описывается результирующим типом функции. Например:

func functionsAsValues(controller: IconViewController) {
  let fn = controller.updateIcons // okay, type is @MainActor ([[String: Any]]) -> Void
  let fn2 = IconViewController.controller.updateIcons // okay, type is (IconViewController) -> (@MainActor ([[String: Any]]) -> Void)
  fn([]) // error: cannot call main actor-isolated function synchronously from outside the actor
}

Значения могут быть преобразованы из типа функции без квалификатора глобального субъекта в функцию с квалификатором глобального субъекта. Например:

func acceptInt(_: Int) { } // not on any actor

callback = acceptInt // okay: conversion to @MainActor (Int) -> Void

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

let fn3: (Int) -> Void = callback // error: removed global actor `MainActor` from function type

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

let callbackAsynchly: (Int) async -> Void = callback   // okay: implicitly hops to main actor

Это можно рассматривать как синтаксический сахар для следующего:

let callbackAsynchly: (Int) async -> Void = {
  await callback() // `await` is required because callback is `@MainActor`
}

В остальном квалификатор глобального субъекта для типа функции не зависит от @Sendable, async, throws и большинства других атрибутов и модификаторов типа функции. Единственным исключением является случай, когда сама функция также изолирована от актора-экземпляра, что обсуждается в следующем разделе, посвященном глобальным субъектам и субъектам-экземплярам.

Замыкания

Замыкание может быть явно указано для изоляции от глобального субъекта путем предоставления атрибута до in в спецификаторе замыкания, например,

callback = { @MainActor in
  print($0)
}

callback = { @MainActor (i) in 
  print(i)
}

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

Примечание. Это можно использовать для замены распространенного шаблона, используемого в библиотеке Apple Dispatch для выполнения кода основного потока через DispatchQueue.main.async { … }. Вместо этого можно было бы написать:
Task.detached { @MainActor in
  // ...
}

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

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

@MainActor var globalTextSize: Int

var callback: @MainActor (Int) -> Void
callback = { // closure is inferred to be @MainActor due to the type of 'callback'
  globalTextSize = $0  // okay: closure is on @MainActor
}

Глобальные и статические переменные

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

@MainActor var globalCounter = 0

@MainActor func incrementGlobalCounter() {
  globalCounter += 1   // okay, we are on the main actor
}

func readCounter() async {
  print(globalCounter)         // error: cross-actor read requires 'await'
  print(await globalCounter)   // okay
}

Как и везде, перекрестные ссылки требуют, чтобы вовлеченные типы соответствовали Sendable.

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

Использование глобальных акторов для типа

Обычно целые типы (и даже иерархии классов) требуют выполнения преимущественно в основном потоке, а асинхронная работа является особым случаем. В таких случаях сам тип может быть аннотирован глобальным субъектом, и все методы, свойства и индексы будут неявно изолированы от этого глобального субъекта. Любые члены типа, которые не хотят быть частью глобального субъекта, могут отказаться, например, с помощью модификатора, nonisolated. Например:

@MainActor
class IconViewController: NSViewController {
  @objc private dynamic var icons: [[String: Any]] = [] // implicitly @MainActor
    
  var url: URL? // implicitly @MainActor
  
  private func updateIcons(_ iconArray: [[String: Any]]) { // implicitly @MainActor
    icons = iconArray
        
    // Notify interested view controllers that the content has been obtained.
    // ...
  }
  
  nonisolated private func gatherContents(url: URL) -> [[String: Any]] {
    // ...
  }
}

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

Класс может быть аннотирован глобальным актором только в том случае, если у него нет суперкласса, суперкласс аннотирован тем же глобальным актором или суперклассом является NSObject. Подкласс класса, аннотированного глобальным субъектом, должен быть изолирован от одного и того же глобального субъекта.

Вывод глобального субъекта

Объявления, которые явно не аннотированы ни глобальным субъектом, ни nonisolated, могут сделать вывод об изоляции глобального субъекта из нескольких разных мест:

  • Подклассы определяют изоляцию актера от его суперкласса:
class RemoteIconViewController: IconViewController { // implicitly @MainActor
    func connect() { ... } // implicitly @MainActor
}
  • Объявление переопределения подразумевает изоляцию актора от объявления, которое оно переопределяет:
class A {
  @MainActor func updateUI() { ... }
}

class B: A {
  override func updateUI() { ... } // implicitly @MainActor
}
  • Свидетель, который не находится внутри типа субъекта, делает вывод об изоляции субъекта от требования протокола, которое удовлетворяет, если соответствие протоколу указано в том же определении или расширении типа, что и свидетель:
protocol P {
  @MainActor func f()
}

struct X { }

extension X: P {
  func f() { } // implicitly @MainActor
}

struct Y: P { }

extension Y {
  func f() { } // okay, not implicitly @MainActor because it's in a separate extension
               // from the conformance to P
}
  • Тип, не являющийся субъектом, который соответствует протоколу с квалификацией глобального субъекта в том же исходном файле, что и его первичное определение, подразумевает изоляцию субъекта от этого протокола:
@MainActor protocol P {
  func updateUI() { } // implicitly @MainActor
}

class C: P { } // C is implicitly @MainActor

// source file D.swift
class D { }

// different source file D-extensions.swift
extension D: P { // D is not implicitly @MainActor
  func updateUI() { } // okay, implicitly @MainActor
}
  • Структура или класс, содержащий обернутое свойство экземпляра с глобальным атрибутом wrapValue, уточненным субъектом, делает вывод об изоляции субъекта от этой оболочки свойства:
@propertyWrapper
struct UIUpdating<Wrapped> {
  @MainActor var wrappedValue: Wrapped
}

struct CounterView { // infers @MainActor from use of @UIUpdating
  @UIUpdating var intValue: Int = 0
}

Глобальные субъекты и субъекты-экземпляры

Объявление не может быть одновременно изолировано от глобального актора и изолировано от актора-экземпляра. Если объявление экземпляра в типе актора аннотировано глобальным актором, оно изолировано от глобального актора, но не от окружающего его экземпляра актора:

actor Counter {
  var value = 0
  
  @MainActor func updateUI(view: CounterView) async {
    view.intValue = value  // error: `value` is actor-isolated to `Counter` but we are in a 'MainActor'-isolated context
    view.intValue = await value // okay to asynchronously read the value
  }
}

С изолированными параметрами, описанными в SE-0313, ни один тип функции не может содержать как изолированный параметр, так и квалификатор глобального субъекта:

@MainActor func tooManyActors(counter: isolated Counter) { } // error: 'isolated' parameter on a global-actor-qualified function

Детальный дизайн

Атрибуты глобального субъекта применяются к объявлениям следующим образом:

  • Объявление не может иметь несколько атрибутов глобального субъекта. В приведенных ниже правилах говорится, что в некоторых случаях глобальный атрибут субъекта распространяется от одного объявления к другому. Если в правилах указано, что атрибут «распространяется по умолчанию», то распространение не выполняется, если объявление назначения имеет явный атрибут глобального субъекта. Если в правилах сказано, что атрибут «распространяется в обязательном порядке», то будет ошибкой, если объявление назначения имеет явный глобальный атрибут субъекта, который не идентифицирует того же субъекта. В любом случае, это ошибка, если атрибуты глобального субъекта, которые не идентифицируют один и тот же субъект, распространяются на одно и то же объявление.
  • Функция, объявленная с атрибутом глобального субъекта, становится изолированной от данного глобального субъекта.
  • Сохраненная переменная или константа, объявленная с атрибутом глобального субъекта, становится частью изолированного состояния данного глобального субъекта.
  • Методы доступа к переменной или индексу, объявленные с атрибутом глобального субъекта, становятся изолированными от данного глобального субъекта. (Это включает в себя наблюдение за методами доступа к хранимой переменной.)
  • Локальные переменные и константы не могут быть помечены глобальным атрибутом субъекта.
  • Тип, объявленный с атрибутом глобального субъекта, по умолчанию распространяет этот атрибут на все методы, свойства, индексы и расширения типа.
  • Расширение, объявленное с атрибутом глобального субъекта, по умолчанию распространяет этот атрибут на всех членов расширения.
  • Протокол, объявленный с атрибутом глобального субъекта, распространяет этот атрибут на любой тип, который соответствует ему в определении первичного типа по умолчанию.
  • Требование протокола, объявленное с атрибутом глобального субъекта, требует, чтобы данный свидетель либо имел такой же атрибут глобального субъекта, либо был неизолированным. (Это то же правило, которое соблюдается всеми свидетелями для требований, изолированных от актера).
  • Класс, объявленный с глобальным атрибутом актора, в обязательном порядке распространяет этот атрибут на свои подклассы.
  • Переопределенное объявление распространяет свой глобальный атрибут актора (если есть) на свои переопределения в обязательном порядке. Другие формы распространения не применяются к переопределениям. Будет ошибкой, если объявление с глобальным атрибутом актора переопределяет объявление без атрибута.
  • Тип субъекта не может иметь глобальный атрибут субъекта. Сохраненные свойства экземпляров типов субъектов не могут иметь глобальных атрибутов субъектов. Другие члены типа субъекта могут иметь глобальные атрибуты субъекта; такие члены изолированы от глобального субъекта, но не от окружающего субъекта. (Согласно предложению об улучшенном контроле над изоляцией акторов, само по себе такие методы не изолированы).
  • Deinit не может иметь атрибут глобального субъекта и никогда не является целью для распространения.

Протокол GlobalActor

Протокол GlobalActor определяется следующим образом:

/// A type that represents a globally-unique actor that can be used to isolate
/// various declarations anywhere in the program.
///
/// A type that conforms to the `GlobalActor` protocol and is marked with the
/// the `@globalActor` attribute can be used as a custom attribute. Such types
/// are called global actor types, and can be applied to any declaration to
/// specify that such types are isolated to that global actor type. When using
/// such a declaration from another actor (or from nonisolated code),
/// synchronization is performed through the \c shared actor instance to ensure
/// mutually-exclusive access to the declaration.
public protocol GlobalActor {
  /// The type of the shared actor instance that will be used to provide
  /// mutually-exclusive access to declarations annotated with the given global
  /// actor type.
  associatedtype ActorType: Actor

  /// The shared actor instance that will be used to provide mutually-exclusive
  /// access to declarations annotated with the given global actor type.
  ///
  /// The value of this property must always evaluate to the same actor
  /// instance.
  static var shared: ActorType { get }
}

Атрибуты замыкания

Замыкания глобального актора — это один из потенциально допустимых атрибутов замыкания. Атрибуты предшествуют списку захвата в грамматике:

closure-expression → { closure-signature opt statements opt }
closure-signature → attributes[opt] capture-list[opt] closure-parameter-clause async[opt] throws[opt] function-result[opt] in
closure-signature → attributes[opt] capture-list in
closure-signature → attributes in

Совместимость источника

Глобальные действующие лица — это дополнительная функция, не влияющая на существующий исходный код.

Влияние на стабильность ABI

Аннотация глобального актера является частью типа объекта и, следовательно, частью его искаженного имени. В противном случае глобальный актер не влияет на ABI.

Влияние на устойчивость API

Атрибут @globalActor можно добавить к типу, не нарушая API.

Атрибут глобального субъекта (например, @MainActor) нельзя ни добавить, ни удалить из API; любой из них вызовет критические изменения для исходного кода, использующего API.

Влияние на среду выполнения и стандартную библиотеку

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

@MainActor func f(_ potentialCallback: Any) {
  let Callback = @MainActor () -> Void
  if let callback = potentialCallback as? Callback {
    callback()
  }
}

Динамическое приведение к типу функции, квалифицированному глобальным субъектом, требует изменений в среде выполнения Swift для представления типов функций, квалифицированных глобальным субъектом, и моделирования динамического приведения к ним неизвестных значений. Аналогичные изменения для типов функций с квалификациями global-actor требуются для изменения имен, что также влияет на время выполнения.

Будущие направления

Ограничение глобальных и статических переменных

Аннотация глобального субъекта к глобальной или статической переменной синхронизирует весь доступ к этой переменной через этот глобальный субъект. Мы могли бы потребовать, чтобы все изменяемые глобальные и статические переменные были аннотированы глобальным актором, тем самым исключая их как источник гонок данных. В частности, мы можем потребовать, чтобы каждая глобальная или статическая переменная выполняла одно из следующих действий:

  • Явно заявить, что он является частью глобального субъекта, или
  • Быть неизменяемым (вводится через let), неизолированным и иметь тип Sendable.

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

Глобальные общие параметры, ограниченные действующим лицом

Общий параметр, ограниченный GlobalActor, потенциально может использоваться в качестве глобального субъекта. Например:

@T
class X<T: GlobalActor> {
  func f() { ... } // constrained to the global actor T
}

@MainActor func g(x: X<MainActor>, y: X<OtherGlobalActor>) async {
  x.f() // okay, on the main actor
  await y.f() // okay, but requires asynchronous call because y.f() is on OtherGlobalActor
}

Здесь есть некоторые сложности: без пометки универсального параметра T атрибутом @globalActor было бы неясно, что это за настраиваемый атрибут T. Следовательно, это может потребоваться выразить как, например,

@T class X<@globalActor T> { … }

что подразумевало бы требование T: GlobalActor. Однако для этого потребуется, чтобы Swift также поддерживал атрибуты общих параметров, которых в настоящее время не существует. Это перспективное направление для последующего предложения.

Рассмотрены альтернативы

Поддержка синглтона

Глобальные акторы, по сути, создают соглашение для синглетонов в языке. Синглтоны иногда используются в Swift, и если бы они получили специальный синтаксис языка, глобальные акторы можно было бы ввести с меньшим количеством шаблонов как «актеры-одиночки», например,

singleton actor MainActor {
  // integration with system's main thread
}

Это исключит атрибут @globalActor из предложения, но оставит его без изменений.

Предлагайте только главного актера

Основной мотивацией для глобальных акторов является главный актор, и семантика этой функции настроена на потребности выполнения основного потока. Абстрактно мы знаем, что есть и другие подобные варианты использования, но возможно, что глобальные акторы не подходят для этих вариантов использования. Вместо того, чтобы предоставлять общую функцию для глобальных субъектов сейчас, мы могли бы сузить это предложение только до @MainActor, а затем предоставить глобальных субъектов (или какую-либо другую абстракцию) в какой-то более поздний момент, чтобы включить @MainActor и другие важные варианты использования.