Swift Digest
SE-0313 | Swift Evolution

Improved control over actor isolation

Proposal
SE-0313
Authors
Doug Gregor, Chris Lattner
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.5)

01 何が問題だったのか

Swift の actors proposal(SE-0306)では、アクター型のインスタンスメソッド・プロパティ・subscript は 常に actor-isolated で、それ以外の宣言は actor-isolated になれない、というルールが採られていました。self が暗黙に isolated なアクターのメンバーだけが、そのアクターの保護された状態に同期的にアクセスできるわけです。

この「すべて or なし」の仕組みは、次のような素朴な要求すら表現できないという問題を生みました。

アクターの操作を自由に切り出せない

例として、典型的な銀行口座アクターを考えます。

actor BankAccount {
  let accountNumber: Int
  var balance: Double

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

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

deposit(amount:) のような操作を、同じロジックのままアクターの外にグローバル関数として切り出すことができません。balance に同期的に触れるのはアクター自身のメソッドに限られるためです。アクターに対する操作を、メンバーとしてしか書けないのです。

アクター上の「isolation が要らない」メンバーも isolated になってしまう

逆に、アクターのインスタンスメンバーはすべて isolated 扱いなので、isolation が本当は必要ない 操作まで外から同期的に使えなくなります。たとえば accountNumberlet なので並行に読んでも安全なはずですが、アクターのメンバーというだけで、外からは await しないと読めません。同じ理由で、アクターの外から同期的に呼べる「表示用のプロパティ」をアクター上に定義することもできませんでした。

同期的なプロトコルに適合できない

アクターのメンバーは isolated なので、同期メソッドを要求する既存プロトコル(HashableCustomStringConvertible など)の要件を満たせません。そのため、BankAccountSet<BankAccount> に入れるための Hashable 適合すら、素直には書けませんでした。

既存の completion handler ベースのプロトコルに橋渡しできない

Swift Concurrency が導入されても、既存のコードベースには completion handler を使った非同期プロトコルが大量に残ります。たとえば次のようなものです。

protocol OldServer {
  func send<Message: MessageType>(
    message: Message,
    completionHandler: (Result<Message.Reply>) -> Void
  )
}

アクター型で OldServer に適合させたいとき、要件は同期シグネチャなので、isolated なメンバーでは満たせません。かといってアクターの外に実装を置くと、アクターの内部状態にアクセスする手段がありません。既存の非同期プロトコルをアクターに橋渡しする方法が、素直には存在しなかったのです。

02 どのように解決されるのか

actor isolation を「アクター型のメンバーかどうか」で自動的に決めるのではなく、どのパラメータが isolated か で明示的に指定できるように一般化します。これによって次の2つが揃います。

  • アクター型のメンバーでなくても、isolated なパラメータを持つ関数は actor-isolated になれます。
  • アクター型のメンバーであっても、nonisolated を付ければ isolated から外せます。

従来の「アクターのインスタンスメンバーは isolated」というルールは、この一般化の特殊ケース、つまり「self が暗黙に isolated なパラメータである」と位置付け直されます。

isolated パラメータ

関数のパラメータにキーワード isolated を付けると、その関数はそのパラメータが指すアクターに対して actor-isolated になります。たとえば deposit(amount:) は、アクターの外のグローバル関数として次のように書けます。

func deposit(amount: Double, to account: isolated BankAccount) {
  assert(amount >= 0)
  account.balance = account.balance + amount
}

accountisolated なので、この関数の本体は account のアクター状態(balance など)に同期的にアクセスできます。呼び出し側のルールは、これまでアクターのメンバーに対して適用されてきたのと同じです。

extension BankAccount {
  func giveSomeGetSome(amount: Double, friend: BankAccount) async {
    deposit(amount: amount, to: self)         // self は isolated なので同期呼び出し
    await deposit(amount: amount, to: friend) // friend は isolated でないので await が必要
  }
}

アクター型のインスタンスメソッドが「特別」なのは、self が暗黙に isolated なパラメータになっているから、というだけのことです。メソッドのカリー化された型を見るとこれがよく分かります。

let fn = BankAccount.deposit(amount:)
// 型: (isolated BankAccount) -> (Double) -> Void

ひとつの関数が isolated なパラメータを複数持つことは、この提案では禁じられています。同時に2つのアクターに同期アクセスすることを安全に保証する仕組みが、現時点では存在しないためです。

func f(a: isolated BankAccount, b: isolated BankAccount) {
  // error: multiple isolated parameters in function 'f(a:b:)'
}

nonisolated 宣言

アクター型のインスタンス宣言は暗黙に selfisolated ですが、キーワード nonisolated を付けるとその暗黙の isolation を解除できます。「アクター上にあるが、アクター状態に依存しない」メンバーを表現するためのものです。

actor BankAccount {
  nonisolated let accountNumber: Int
  var balance: Double

  // ...
}

extension BankAccount {
  // 口座番号を末尾4桁だけ残して "X" で埋めた、書類に載せても安全な表示文字列を返す
  nonisolated func safeAccountNumberDisplayString() -> String {
    let digits = String(accountNumber)   // accountNumber も nonisolated なので OK
    return String(repeating: "X", count: digits.count - 4) + String(digits.suffix(4))
  }
}

nonisolated な宣言の中では、self は isolated でないため、アクター状態への同期アクセスはできません。isolated なプロパティやメソッドに触ろうとするとコンパイルエラーになるか、await が必要になります。

extension BankAccount {
  nonisolated func steal(amount: Double) {
    balance -= amount
    // error: actor-isolated property 'balance' can not be referenced on non-isolated parameter 'self'
  }
}

また、nonisolated な宣言は任意のアクターや並行コードから使われうるため、関わる型はすべて Sendable でなければなりません。たとえば non-Sendable なクラスを返すような nonisolated 関数は定義できません。

class SomeClass { } // Sendable ではない

extension BankAccount {
  nonisolated func f() -> SomeClass? { nil }
  // error: `nonisolated` declaration returns non-Sendable type `SomeClass?`
}

同期的なプロトコルへの適合

この nonisolated を使うと、アクター型を HashableCustomStringConvertible のような同期メソッドを要求するプロトコルに適合させられます。nonisolated なメンバーはアクター状態に触れないため、外部から同期的に呼ばれても安全だからです。口座番号でハッシュ化するなら次のようになります。

extension BankAccount: Hashable {
  nonisolated func hash(into hasher: inout Hasher) {
    hasher.combine(accountNumber)
  }
}

extension BankAccount: CustomStringConvertible {
  nonisolated var description: String {
    "Bank account #\(safeAccountNumberDisplayString())"
  }
}

これで Set<BankAccount>print(account) が素直に書けるようになります。

逆に、actor-isolated な関数は、同期でも非 actor-isolated でもないプロトコル要件を満たすことはできません。そのような要件を満たすには、アクター側で nonisolated にするか、プロトコル側で async にする必要があります。

completion handler ベースの既存プロトコルに橋渡しする

nonisolated は、既存の completion handler ベースの非同期プロトコルをアクターに適合させる場面で特に効きます。要件は同期シグネチャなので、nonisolated で受け取り、その中で detached なタスクを起動して「本物の」非同期実装に処理を委ねます。

actor MyActorServer {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply { ... }
  // これが本来書きたい非同期実装
}

extension MyActorServer: OldServer {
  nonisolated func send<Message: MessageType>(
    message: Message,
    completionHandler: (Result<Message.Reply>) -> Void
  ) {
    detach {
      do {
        let reply = try await send(message: message)
        completionHandler(.success(reply))
      } catch {
        completionHandler(.failure(error))
      }
    }
  }
}

これによって、既存のコードベース全体を一気に async 化しなくても、アクターを段階的に導入していけます。

Future Directions

将来の方向性として、以下のような拡張が speculative に言及されています(実現を約束するものではありません)。

  • 複数の isolated パラメータの許可。現状は禁止されていますが、カスタムエグゼキュータ等で「複数のアクターが同じ concurrency domain を共有する」ことを静的に保証できれば、将来的に許容しうる余地があります。
  • Isolated protocol conformances。アクター型のプロトコル適合を「アクターの isolation domain の内側でのみ使える」ものとして表現する仕組みです。actor MyDataActor: isolated DataProcessible のように、適合自体に isolated を付けて、その適合をアクターの外から使うと型エラーになる、といった方向が検討されています。これが入ると、isolated な var data: Data で同期プロトコル要件を満たすといった書き方が可能になります。