0306 — Акторы

Введение

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

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

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

Актеры

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

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  init(accountNumber: Int, initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }
}

Как и другие типы Swift, акторы могут иметь инициализаторы, методы, свойства и индексы. Они могут быть расширены и соответствовать протоколам, быть универсальными.

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

Изоляция актера

Изоляция акторов — это то, как акторы защищают свое изменяемое состояние. Для акторов основным механизмом этой защиты является разрешение доступа только к их сохраненным свойствам экземпляра непосредственно на self. Например, вот метод, который пытается перевести деньги с одного счета на другой:

extension BankAccount {
  enum BankError: Error {
    case insufficientFunds
  }
  
  func transfer(amount: Double, to other: BankAccount) throws {
    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")

    balance = balance - amount
    other.balance = other.balance + amount  // error: actor-isolated property 'balance' can only be referenced on 'self'
  }
}

Если бы BankAccount был классом, метод transfer(amount:to:) был бы правильно сформирован, но мог бы подвергаться гонкам данных в параллельном коде без внешнего механизма блокировки.

С актерами попытка сослаться на other.balance вызывает ошибку компилятора, потому что на balance можно ссылаться только на self. В сообщении об ошибке отмечается, что balance изолирован от актера, что означает, что к нему можно получить доступ только непосредственно из определенного актера, к которому он привязан или «изолирован». В данном случае это экземпляр BankAccount, на который ссылается self. Все объявления экземпляра актора, включая хранимые и вычисляемые свойства экземпляра (например, balance), методы экземпляра (например, transfer(amount:to:)) и индексы экземпляра, по умолчанию изолированы от актора. Объявления, изолированные от акторов, могут свободно ссылаться на другие объявления, изолированные от акторов, в том же экземпляре актора (на self). Любое объявление, не изолированное от субъекта, является неизолированным и не может синхронно обращаться к любому объявлению, изолированному от субъекта.

Ссылка на объявление, изолированное от актора, извне этого актора называется кросс-актерной ссылкой. Такие ссылки допустимы одним из двух способов. Во-первых, кросс-актерная ссылка на неизменяемое состояние разрешена из любого места в том же модуле, в котором определен актор, потому что после инициализации это состояние никогда не может быть изменено (ни внутри актора, ни вне его), поэтому данные отсутствуют. гонки по определению. Ссылка на other.accountNumber разрешена на основании этого правила, так как accountNumber объявлен через let и имеет смыслово-значный тип Int.

Вторая форма допустимой межакторной ссылки — это та, которая выполняется с асинхронным вызовом функции. Такие асинхронные вызовы функций превращаются в «сообщения», запрашивающие, чтобы актор выполнил соответствующую задачу, когда он может это сделать безопасно. Эти сообщения хранятся в «почтовом ящике» актора, и вызывающая сторона, инициирующая асинхронный вызов функции, может быть приостановлена до тех пор, пока актор не сможет обработать соответствующее сообщение в своем почтовом ящике. Актер обрабатывает сообщения в своем почтовом ящике по одному, так что у данного субъекта никогда не будет двух одновременно выполняемых задач, выполняющих изолированный от актера код. Это гарантирует отсутствие гонок данных в изменяемом состоянии, изолированном от субъекта, поскольку нет параллелизма в любом коде, который может получить доступ к состоянию, изолированному от субъекта. Например, если бы мы хотели внести депозит на определенный банковский счет, account, мы могли бы вызвать метод deposit(amount:) другого актера, и этот вызов превратился бы в сообщение, помещенное в почтовый ящик актера, а вызывающая сторона приостановить. Когда этот актор обрабатывает сообщения, он в конечном итоге обрабатывает сообщение, соответствующее депозиту, выполняя этот вызов в домене изоляции актора, когда никакой другой код не выполняется в домене изоляции этого актора.

Примечание по реализации: на уровне реализации сообщения являются частичными задачами (описанными в предложении структурированного параллелизма) для асинхронного вызова, и каждый экземпляр субъекта содержит своего собственного последовательного исполнителя (также в предложении структурированного параллелизма). Последовательный исполнитель по умолчанию отвечает за выполнение частичных задач по одной за раз. Концептуально это похоже на последовательную очередь DispatchQueue, но с важным отличием: задачи, ожидающие субъекта, не обязательно будут выполняться в том же порядке, в котором они первоначально ожидали этого субъекта. Система времени выполнения Swift стремится по возможности избегать инверсии приоритетов, используя такие методы, как повышение приоритета. Таким образом, система выполнения учитывает приоритет задачи при выборе следующей задачи для выполнения на акторе из его очереди. Это отличается от последовательной очереди DispatchQueue, которая действует строго по принципу «первым пришел – первым обслужен». Кроме того, среда выполнения актора Swift использует более легкую реализацию очереди, чем Dispatch, чтобы в полной мере использовать Async функции Swift.

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

Основываясь на вышеизложенном, мы можем реализовать правильную версию transfer(amount:to:), которая является асинхронной:

extension BankAccount {
  func transfer(amount: Double, to other: BankAccount) async throws {
    assert(amount > 0)

    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")

    // Safe: this operation is the only one that has access to the actor's isolated
    // state right now, and there have not been any suspension points between
    // the place where we checked for sufficient funds and here.
    balance = balance - amount
    
    // Safe: the deposit operation is placed in the `other` actor's mailbox; when
    // that actor retrieves the operation from its mailbox to execute it, the
    // other account's balance will get updated.
    await other.deposit(amount: amount)
  }
}

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

extension BankAccount {
  func deposit(amount: Double) async {
    assert(amount >= 0)
    balance = balance + amount
  }
}

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

extension BankAccount {
  func deposit(amount: Double) {
    assert(amount >= 0)
    balance = balance + amount
  }
}

Синхронные функции актора могут быть вызваны синхронно на самом акторе, но межакторные ссылки на этот метод требуют асинхронного вызова. Функция transfer(amount:to:) вызывает ее асинхронно (на other), а следующая функция passGo вызывает ее синхронно (на неявном self):

extension BankAccount {
  // Pass go and collect $200
  func passGo() {
    self.deposit(amount: 200.0)  // synchronous is okay because `self` is isolated
  }
}

Межактерные ссылки на свойство актора разрешены как асинхронный вызов, если они доступны только для чтения:

func checkBalance(account: BankAccount) async {
  print(await account.balance)   // okay
  await account.balance = 1000.0 // error: cross-actor property mutations are not permitted
}

Обоснование: можно поддерживать наборы свойств для разных субъектов. Тем не менее, кросс-акторные операции inout не могут быть разумно поддержаны, потому что между «get» и «set» будет неявная точка приостановки, которая может привести к тому, что фактически будет условиями гонки. Более того, асинхронная установка свойств может упростить непреднамеренное нарушение инвариантов, если, например, для сохранения инварианта необходимо обновить два свойства одновременно.

Извне модуля на неизменяемые lets нужно ссылаться асинхронно из-за пределов актора. Например:

// From another module
func printAccount(account: BankAccount) {
  print("Account #\(await account.accountNumber)")
}

Это сохраняет способность модуля, который определяет BankAccount, превращать let в var, не нарушая работу клиентов, что является свойством, которое Swift всегда поддерживал:

actor BankAccount { // version 2
  var accountNumber: Int
  var balance: Double  
}

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

Кросс-актерные ссылки и типы Sendable

SE-0302 представляет протокол Sendable. Значения типов, соответствующих протоколу Sendable, можно безопасно использовать в параллельно выполняющемся коде. Существуют различные виды типов, которые хорошо работают таким образом: семантические типы, такие как Int и String, семантические коллекции таких типов, как [String] или [Int: String], неизменяемые классы, классы, которые выполняют внутреннюю синхронизацию ( как параллельная хеш-таблица) и так далее.

Актеры защищают свое изменяемое состояние, поэтому экземпляры акторов можно свободно использовать в параллельно выполняющемся коде, а сам актор будет поддерживать внутреннюю синхронизацию. Следовательно, каждый тип актора неявно соответствует протоколу Sendable.

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

class Person {
  var name: String
  let birthDate: Date
}

actor BankAccount {
  // ...
  var owners: [Person]

  func primaryOwner() -> Person? { return owners.first }
}

Функция primaryOwner может быть вызвана асинхронно из другого актора, а затем экземпляр Person может быть изменен откуда угодно:

if let primary = await account.primaryOwner() {
  primary.name = "The Honorable " + primary.name  // problem: concurrent mutation of actor-isolated state
}

Даже доступ без изменения проблематичен, потому что name человека может быть изменено внутри актора в то же время, когда исходный вызов пытается получить к нему доступ. Чтобы предотвратить эту возможность одновременной мутации изолированного состояния субъекта, все ссылки между субъектами могут включать только типы, соответствующие Sendable. Для межсубъектного асинхронного вызова типы аргументов и результатов должны соответствовать Sendable. Для перекрестной ссылки на неизменяемое свойство тип свойства должен соответствовать Sendable. Настаивая на том, чтобы все межакторные ссылки использовали только типы Sendable, мы можем гарантировать, что никакие ссылки на совместно используемое изменяемое состояние не перетекают в домен изоляции актора или из него. Компилятор произведет диагностику для таких проблем. Например, вызов account.primaryOwner() about приведет к следующей ошибке:

error: cannot call function returning non-Sendable type 'Person?' across actors

Обратите внимание, что функция primaryOwner(), определенная выше, по-прежнему может использоваться с кодом, изолированным от актера. Например, мы можем определить функцию для получения имени основного владельца, например:

extension BankAccount {
  func primaryOwnerName() -> String? {
    return primaryOwner()?.name
  }
}

Функция primaryOwnerName() безопасна для асинхронного вызова между субъектами, потому что String (и, следовательно, String?) соответствует Sendable.

Закрытия

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

extension BankAccount {
  func endOfMonth(month: Int, year: Int) {
    // Schedule a task to prepare an end-of-month report.
    detach {
      let transactions = await self.transactions(month: month, year: year)
      let report = Report(accountNumber: self.accountNumber, transactions: transactions)
      await report.email(to: self.accountOwnerEmailAddress)
    }
  }
}

Задача, созданная с помощью команды detach, выполняется одновременно со всем остальным кодом. Если бы замыкание, переданное для detach, было изолировано от субъекта, мы бы ввели гонку данных при доступе к изменяемому состоянию в BankAccount. Субъекты предотвращают эту гонку данных, указывая, что замыкание @Sendable (описанное в замыканиях Sendable и @Sendable и используемое в определении detach в предложении структурированного параллелизма) всегда неизолировано. Следовательно, требуется асинхронный доступ к любым объявлениям, изолированным от актера.

Замыкание, отличное от @Sendable, не может выйти за пределы домена параллелизма, в котором оно было сформировано. Следовательно, такое замыкание будет изолированным от актера, если оно сформировано в изолированном от актера контексте. Это полезно, например, при применении алгоритмов последовательности, таких как forEach, где предоставленное замыкание будет вызываться последовательно:

extension BankAccount {
  func close(distributingTo accounts: [BankAccount]) async {
    let transferAmount = balance / accounts.count

    accounts.forEach { account in                        // okay, closure is actor-isolated to `self`
      balance = balance - transferAmount            
      await account.deposit(amount: transferAmount)
    }
    
    await thief.deposit(amount: balance)
  }
}

Замыкание, сформированное в контексте, изолированном от субъекта, является изолированным от субъекта, если оно не является @Sendable, и неизолированным, если оно является @Sendable. Для приведенных выше примеров:

  • Замыкание, переданное в detach, не является изолированным, потому что эта функция требует, чтобы ей была передана функция @Sendable.
  • Замыкание, переданное forEach, изолировано от субъекта по отношению к self, потому что оно принимает функцию, не относящуюся к @Sendable.

Повторный вход актера

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

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

Выполнение «чередования» с повторный вход актерами

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

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

actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                       // <1>
    await friend.tell(opinion, heldBy: self)  // <2>
    return opinion // 🤨                      // <3>
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea                       // <4>
    await friend.tell(opinion, heldBy: self) // <5>
    return opinion // 🤨                     // <6>
  }
}

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

Примером этого является следующий фрагмент кода, реализующий актор DecisionMaker:

let goodThink = detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = detach { await person.thinkOfBadIdea() } // runs async

let shouldBeGood = await goodThink.get()
let shouldBeBad = await badThink.get()

await shouldBeGood // could be .goodIdea or .badIdea ☠️
await shouldBeBad

Этот фрагмент может привести (в зависимости от времени возобновления) к следующему выполнению:

opinion = .goodIdea                // <1>
// suspend: await friend.tell(...) // <2>
opinion = .badIdea                 // | <4> (!)
// suspend: await friend.tell(...) // | <5>
// resume: await friend.tell(...)  // <2>
return opinion                     // <3>
// resume: await friend.tell(...)  // <5>
return opinion                     // <6>

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

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

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

Тупики с невозвращающимися актерами

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

Если мы возьмем пример из предыдущего раздела и воспользуемся нереентерабельным актором, он будет выполняться правильно, потому что никакая работа над актором не может быть запланирована до тех пор, пока не завершится работа friend.tell:

// assume non-reentrant
actor DecisionMaker {
  let friend: DecisionMaker
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                                   
    await friend.tell(opinion, heldBy: self)
    return opinion // ✅ always .goodIdea
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea
    await friend.tell(opinion, heldBy: self)
    return opinion // ✅ always .badIdea
  }
}

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

extension DecisionMaker {
  func tell(_ opinion: Judgment, heldBy friend: DecisionMaker) async {
    if opinion == .badIdea {
      await friend.convinceOtherwise(opinion)
    }
  }
}

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

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

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

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

Зашедшие в тупик актеры навсегда останутся бездействующими зомби. Некоторые среды выполнения решают подобные взаимоблокировки, заставляя каждый отдельный вызов актора иметь тайм-аут (такие тайм-ауты уже полезны для распределенных систем акторов). Это означало бы, что каждое await потенциально может вызывать throws, и что либо тайм-ауты, либо обнаружение взаимоблокировок должны быть всегда включены. Мы считаем, что это будет непомерно дорого, потому что мы предполагаем, что актеры будут использоваться в подавляющем большинстве параллельных приложений Swift. Это также замутит воду в отношении отмены, которая намеренно разработана так, чтобы быть явной и совместной. Поэтому мы считаем, что подход автоматической отмены взаимоблокировок не очень хорошо согласуется с направлением Swift Concurrency.

Ненужная блокировка с нереентерабельными акторами

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

// assume non-reentrant
actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

Этот актор функционально корректен независимо от того, реентерабельный он или нет. Однако, если он не является реентерабельным, он полностью сериализует загрузку изображений: как только один клиент запрашивает изображение, все остальные клиенты блокируются от запуска любых запросов — даже тех, которые попадут в кеш или запрашивают изображения. по разным URL-адресам — до тех пор, пока изображение первого клиента не будет полностью загружено и декодировано.

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

Существующая практика

Существует ряд существующих реализаций акторов, в которых учитывается понятие повторного входа:

  • Erlang/Elixir (gen_server) демонстрирует простой сценарий «петля/взаимная блокировка» и способы его обнаружения и исправления,
  • Akka (Persistence persist/persistAsync) по умолчанию не является реентерабельным поведением, а определенные API-интерфейсы позволяют программистам выбирать реентерабельность всякий раз, когда это необходимо. В связанной документации persistAsync — это реентерабельная версия API, и она на практике используется очень редко.Постоянство Akka и этот API использовались для реализации банковских транзакций и менеджеров процессов, полагаясь на отсутствие повторного входа в persist() как на убийственную функцию, что делает реализации простыми для понимания и безопасными. Обратите внимание, что Akka построен на основе Scala, который не обеспечивает async/await, что означает, что методы обработки почтовых ящиков более синхронны по своей природе, и вместо того, чтобы блокировать актора в ожидании ответа, они будут обрабатывать ответ как отдельное получение сообщения. .
  • Орлеаны (зёрна) также не являются реентерабельными по умолчанию, но предлагают обширную конфигурацию для реентерабельности. Границы и определенные методы могут быть помечены как повторно входящие, и существует даже динамический механизм, с помощью которого можно реализовать предикат времени выполнения, чтобы определить, может ли вызов чередоваться. Orleans, возможно, наиболее близок к описанному здесь подходу Swift, потому что он построен на основе языка, поддерживающего async/await (C#). Обратите внимание, что в Orleans была функция, называемая повторным входом в цепочку вызовов, которая, по нашему мнению, является многообещающим потенциальным направлением: мы рассмотрим ее позже в этом предложении в нашем разделе, посвященном повторному входу в цепочку задач.

Сводка по реентерабельности

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

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

Соответствие протоколу

Все типы субъектов неявно соответствуют новому протоколу, Актор:

protocol Actor : AnyObject, Sendable { }

Примечание. Определение протокола Актера намеренно оставлено пустым. Предложение пользовательских исполнителей введет требования в протокол Актера. Эти требования будут неявно синтезированы реализацией, если они не предоставлены явно, но могут быть предоставлены явно, чтобы позволить субъектам управлять своим собственным сериализованным выполнением.

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

protocol DataProcessible: Actor {  // only actor types can conform to this protocol
  var data: Data { get }           // actor-isolated to self
}

extension DataProcessible {
  func compressData() -> Data {    // actor-isolated to self
    // use data synchronously
  }
}

actor MyProcessor : DataProcessible {
  var data: Data                   // okay, actor-isolated to self
  
  func doSomething() {
    let newData = compressData()   // okay, calling actor-isolated method on self
    // use new data
  }
}

func doProcessing<T: DataProcessible>(processor: T) async {
  await processor.compressData() // not actor-isolated, so we must interact asynchronously with the actor
}

Никакой другой конкретный тип (класс, перечисление, структура и т. д.) не может соответствовать протоколу актора, поскольку они не могут определять операции, изолированные от актора.

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

protocol Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply
}

actor MyActor: Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply { // okay: this method is actor-isolated to 'self', satisfies asynchronous requirement
  }
}

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

Детальная разработка

Актеры

Тип актора может быть объявлен с помощью ключевого слова актора:

/// Declares a new type BankAccount
actor BankAccount {
  // ...
}

Каждый экземпляр субъекта представляет уникального субъекта. Термин «актор» может использоваться для обозначения либо экземпляра, либо типа; при необходимости можно обратиться к «экземпляру актера» или «типу актера», чтобы устранить неоднозначность.

Актеры похожи на другие конкретные именные типы в Swift (перечисления, структуры и классы). Типы субъектов могут иметь static и экземплярные методы, свойства и индексы. У них есть сохраненные свойства и инициализаторы, такие как структуры и классы. Они являются ссылочными типами, как и классы, но не поддерживают наследование и, следовательно, не имеют (или не нуждаются) таких функций, как required и convenience инициализаторы, переопределение или члены class, open и final. Если типы акторов отличаются по поведению от других типов, в первую очередь это определяется правилами изоляции акторов, описанными ниже.

По умолчанию методы экземпляра, свойства и индексы актора имеют изолированный параметр self. Это верно даже для методов, добавленных к актору задним числом через расширение, как и для любого другого типа Swift. Статические методы, свойства и индексы не имеют параметра self, который является экземпляром субъекта, поэтому они не изолированы от субъекта.

extension BankAccount {
  func acceptTransfer(amount: Double) async { // actor-isolated
    balance += amount
  }
} 

Проверка изоляции актера

Любое данное объявление в программе либо изолировано от актора, либо не изолировано. Функция (включая средства доступа) является изолированной от субъекта, если она определена для типа субъекта (включая протоколы, в которых Self соответствует Актеру, и их расширения). Изменяемое свойство экземпляра или индекс экземпляра изолированы от субъекта, если они определены для типа субъекта. Объявления, которые не изолированы от актера, называются неизолированными.

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

  • Когда определение одного объявления (например, тело функции) ссылается на другое объявление, например, вызов функции, доступ к свойству или вычисление индекса.
  • Когда одно объявление удовлетворяет требованию протокола.

Мы подробно опишем каждый сценарий.

Ссылки и изоляция актера

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

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

Например:

actor MyActor {
  let name: String
  var counter: Int = 0
  func f()
}

extension MyActor {
  func g(other: MyActor) async {
    print(name)          // okay, name is non-isolated
    print(other.name)    // okay, name is non-isolated
    print(counter)       // okay, g() is isolated to MyActor
    print(other.counter) // error: g() is isolated to "self", not "other"
    f()                  // okay, g() is isolated to MyActor
    await other.f()      // okay, other is not isolated to "self" but asynchronous access is permitted
  }
}

Соответствие протоколу

Когда данное заявление («свидетель») удовлетворяет требованию протокола («требование»), требование протокола может быть выполнено свидетелем, если:

  • Требование Async
  • требование и свидетель изолированы от актора.

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

protocol Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply
}

actor MyServer : Server {
  func send<Message: MessageType>(message: Message) throws -> Message.Reply { ... }  // okay, asynchronously accessed from clients of the protocol
}

Частичные приложения

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

func runLater<T>(_ operation: @Sendable @escaping () -> T) -> T { ... }

actor A {
  func f(_: Int) -> Double { ... }
  func g() -> Double { ... }
  
  func useAF(array: [Int]) {
    array.map(self.f)                     // okay
    detach(operation: self.g)             // error: self.g has non-sendable type () -> Double that cannot be converted to a @Sendable function type
    runLater(self.g)                      // error: cannot convert value of non-sendable function type () -> Double to sendable function type
  }
}

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

extension A {
  func useAFDesugared(a: A, array: [Int]) {
    array.map { f($0) } )      // okay
    detach { g() }             // error: self is non-isolated, so call to `g` cannot be synchronous
    runLater { g() }           // error: self is non-isolated, so the call to `g` cannot be synchronous
  }
}

Ключевые пути

Ключевой путь не может включать ссылку на объявление, изолированное от актера:

actor A {
  var storage: Int
}

let kp = \A.storage  // error: key path would permit access to actor-isolated storage

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

Inout параметры

Сохраненные свойства, изолированные от актера, можно передавать в синхронные функции через Inout параметры, но неправильно передавать их в асинхронные функции через Inout параметры. Например:

func modifiesSynchronously(_: inout Double) { }
func modifiesAsynchronously(_: inout Double) async { }

extension BankAccount {
  func wildcardBalance() async {
    modifiesSynchronously(&balance)        // okay
    await modifiesAsynchronously(&balance) // error: actor-isolated property 'balance' cannot be passed 'inout' to an asynchronous function
  }
}  

class C { var state : Double }
struct Pair { var a, b : Double }
actor A {
  let someC : C
  var somePair : Pair

  func inoutModifications() async {
    modifiesSynchronously(&someC.state)        // okay
    await modifiesAsynchronously(&someC.state) // not okay
    modifiesSynchronously(&somePair.a)         // okay
    await modifiesAsynchronously(&somePair.a)  // not okay
  }
}

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

Взаимодействие актеров с Objective-C

Тип актора может быть объявлен @objc, что неявно обеспечивает соответствие NSObjectProtocol:

@objc actor MyActor { ... }

Член актора может быть @objc только в том случае, если он либо Async, либо не изолирован от актора. Синхронный код, который находится в домене изоляции актора, может быть вызван только для self (в Swift). Objective-C не знает об изоляции акторов, поэтому этим членам не разрешается подвергать себя воздействию Objective-C. Например:

@objc actor MyActor {
    @objc func synchronous() { } // error: part of actor's isolation domain
    @objc func asynchronous() async { } // okay: asynchronous, exposed to Objective-C as a method that accepts a completion handler
    @objc nonisolated func notIsolated() { } // okay: non-isolated
}

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

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

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

Это чисто дополнение к ABI. Изоляция актеров сама по себе является статическим понятием, которое не является частью ABI.

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

Почти все изменения в изоляции акторов являются критическими, поскольку правила изоляции акторов требуют согласованности между объявлением и его пользователями:

  • Класс нельзя превратить в актера или наоборот.
  • Изоляцию актера публичного объявления нельзя изменить.

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

Нереентерабельность

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

  • @reentrant: указывает, что каждая потенциальная точка приостановки в телах функций, охватываемых атрибутом, является реентерабельной.
  • @reentrant(never): указывает, что каждая потенциальная точка приостановки в телах функций, охватываемых атрибутом, не является реентерабельной.

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

Обоснование: Разрешение прямых вызовов self устраняет очевидный набор взаимоблокировок и требует только тех же статических знаний, что и проверка изолирования актора для синхронного доступа к состоянию изолированности актера.

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

  • Сама декларация.
  • Если объявление является нетиповым членом расширения, расширение.
  • Если объявление является нетиповым членом типа (или его расширения), определение типа.

Если нет подходящего атрибута @reentrant, объявление, изолированное от актера, является реентерабельным.

Вот пример, иллюстрирующий, как атрибут @reentrant может применяться в различных точках:

actor Stage {
  @reentrant(never) func f() async { ... }    // not reentrant
  func g() async { ... }                      // reentrant
}

@reentrant(never)
extension Stage {
  func h() async { ... }                      // not reentrant
  @reentrant func i() async { ... }           // reentrant

  actor InnerChild {                          // reentrant, not affected by enclosing extension
    func j() async { ... }                    // reentrant
  }

  nonisolated func k() async { .. }     // okay, reentrancy is uninteresting
  nonisolated @reentrant func l() async { .. } // error: @reentrant on non-actor-isolated
}

@reentrant func m() async { ... } // error: @reentrant on non-actor-isolated

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

await(blocking) friend.tell(opinion, heldBy: self)

Повторный вход в цепочку задач

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

class OddOddySync {
  let evan: EvenEvanSync!

  func isOdd(_ n: Int) -> Bool {
    if n == 0 { return true }
    return evan.isEven(n - 1)
  }
}

class EvenEvanSync {
  let oddy: OddOddySync!

  func isEven(_ n: Int) -> Bool {
    if n == 0 { return false }
    return oddy.isOdd(n - 1)
  }
}

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

@reentrant(never)
actor OddOddy {
  let evan: EvenEvan!

  func isOdd(_ n: Int) async -> Bool {
    if n == 0 { return true }
    return await evan.isEven(n - 1)
  }
}

@reentrant(never)
actor EvenEvan {
  let oddy: OddOddy!

  func isEven(_ n: Int) async -> Bool {
    if n == 0 { return false }
    return await oddy.isOdd(n - 1)
  }
}

При @reentrant(never) этот код заблокируется, потому что вызов от EvanEvan.isEven к OddOddy.isOdd будет зависеть от другого вызова EvanEvan.isEven, который не может продолжаться, пока исходный вызов не завершится. Чтобы устранить взаимоблокировку, нужно сделать эти методы реентерабельными.

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

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

Есть несколько причин, по которым в настоящее время мы не согласны с повторным входом в цепочку задач:

  • Подход с повторным входом на основе задач, похоже, не был опробован в масштабе. Документы Orleans поддерживают повторный вход в цепочку вызовов, но реализация была довольно ограниченной, и в конечном итоге она была удалена. Исходя из опыта Орлеана, трудно понять, в чем проблема — в идее или в конкретной реализации.
  • Нам пока неизвестен эффективный метод реализации этого подхода в среде выполнения актора.

Если мы сможем решить вышеизложенное, повторный вход цепочки задач может быть введен в модель актора с другим написанием атрибута повторного входа, таким как @reentrant(task), и может обеспечить наилучшее значение по умолчанию.

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

Наследование актера

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

  • Актер не может наследовать от класса, и наоборот.
  • Переопределяющее объявление не должно быть более изолированным, чем переопределяемое объявление.

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

Кросс-актер позволяет

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

// in module BankActors
public actor BankAccount {
  public let accountNumber: Int
}

func print(account: BankAccount) {
  print(account.accountNumber) // okay: synchronous access to an actor's let property
} 

Вне модуля доступ должен быть асинхронным:

import BankActors

func otherPrint(account: BankAccount) async {
  print(account.accountNumber)         // error: cannot synchronously access immutable 'let' outside the actor's module
  print(await account.accountNumber)   // okay to asynchronously access
}

Требование асинхронного доступа извне модуля сохраняет давнюю свободу для разработчиков библиотек, что позволяет рефакторингу общедоступного let в var без нарушения каких-либо клиентов. Это согласуется с политикой Swift, заключающейся в максимальной свободе разработчиков библиотек для изменения реализации без нарушения работы клиентов. Не требуя асинхронного доступа от других модулей, вышеприведенной функции otherPrint(account:) было разрешено синхронно ссылаться на accountNumber. Если бы автор BankActors затем изменил номер счета на переменную, это сломало бы существующий клиентский код:

public actor BankAccount {
  public var accountNumber: Int     // version 2 makes this mutable, but would break clients if synchronous access to 'let's were allowed outside the module
}

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

  • По умолчанию управление доступом является внутренним, поэтому вы можете использовать объявление во всем модуле, но должны явно согласиться сделать его доступным за пределами вашего модуля (например, через public). Другими словами, вы можете игнорировать контроль доступа до тех пор, пока вам не понадобится сделать что-то public для использования из другого модуля.
  • Неявный поэлементный инициализатор структуры является internal. Вам нужно написать public инициализатор самостоятельно, чтобы разрешить инициализацию этой структуры именно с этим набором параметров.
  • Наследование от класса разрешено по умолчанию, если суперкласс находится в том же модуле. Чтобы наследоваться от суперкласса, определенного в другом модуле, этот суперкласс должен быть явно помечен как open. Вы можете игнорировать open, пока не захотите гарантировать эту возможность клиентам за пределами модуля.
  • Переопределение объявления в классе разрешено по умолчанию, если переопределенное объявление находится в том же модуле. Чтобы переопределить объявление в другом модуле, это объявление переопределения должно быть явно помечено как open.

SE-0313 «Улучшенный контроль над изоляцией субъектов» предоставляет явный способ предоставить клиентам свободу синхронного доступа к неизменяемому состоянию субъектов с помощью ключевого слова nonisolated, например,

// in module BankActors
public actor BankAccount {
  public nonisolated let accountNumber: Int  // can be accessed synchronously from any module due to the explicit 'nonisolated'
}

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

  • Разработчики почти сразу же столкнулись с необходимостью использования неизолированного кода при написании кода актора. Это противоречит принципу постепенного раскрытия информации, которому Swift пытается следовать для расширенных функций. Помимо nonisolated let, использование nonisolated встречается довольно редко.
  • Неизменяемое состояние — ключевой инструмент для написания безопасного параллельного кода. Let типа Sendable концептуально безопасен для ссылок из кода параллелизма и работает в других контекстах (например, с локальными переменными). Создание одного неизменяемого состояния безопасным для параллелизма, в то время как другое состояние не является безопасным, усложняет историю параллельного программирования, безопасного от гонки данных. Вот пример существующих ограничений для @Sendable, которые были определены в SE-0302:
func test() {
  let total = 100
  var counter = 0

  asyncDetached {
    print(total) // okay to reference immutable state
    print(counter) // error, cannot reference a `var` from a @Sendable closure
  }
  
  counter += 1
}

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