actor isolationをより良く制御する
Improved control over actor isolation
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
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 が本当は必要ない 操作まで外から同期的に使えなくなります。たとえば accountNumber は let なので並行に読んでも安全なはずですが、アクターのメンバーというだけで、外からは await しないと読めません。同じ理由で、アクターの外から同期的に呼べる「表示用のプロパティ」をアクター上に定義することもできませんでした。
同期的なプロトコルに適合できない
アクターのメンバーは isolated なので、同期メソッドを要求する既存プロトコル(Hashable、CustomStringConvertible など)の要件を満たせません。そのため、BankAccount を Set<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
}
account が isolated なので、この関数の本体は 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 宣言
アクター型のインスタンス宣言は暗黙に self が isolated ですが、キーワード 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 を使うと、アクター型を Hashable や CustomStringConvertible のような同期メソッドを要求するプロトコルに適合させられます。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 化しなくても、アクターを段階的に導入していけます。
03 今後の見通し
将来の方向性として、以下のような拡張が示されています。いずれも構想の段階であり、実現を約束するものではありません。
複数の isolated パラメータの許可
この提案では、ひとつの関数が複数の isolated パラメータを持つことは禁じられています。仮に許可しても、現状は同時にひとつのアクターに対してしか同期アクセスできないため、同じアクターを2回渡す以外に呼び出しようがないからです。
extension BankAccount {
func g() {
f(a: self, b: self) // 同じアクターを渡す分には安全
}
func h(other: BankAccount) async {
await f(a: self, b: other)
// error: isolated parameters `a` and `b` passed values with potentially-different actors
}
}
将来、custom executor などの仕組みによって「複数のアクターが同じ concurrency domain を共有する」ことを 静的に 保証できるようになれば、この制限を緩めて、複数の isolated パラメータを実用的に書けるようにする余地があります。
Isolated protocol conformances
現状の仕組みでは、アクター型のプロトコル適合は「アクターの isolation domain の外側から使われる」ことが前提になっており、同期メソッドの要件を満たすには nonisolated なメンバーで実装するか、プロトコル側を async 化する必要があります。
これに対し、適合自体に isolated を付け、「その適合はアクターの isolation domain の内側でのみ使える」ことを表現できるようにする構想があります。
actor MyDataActor: isolated DataProcessible {
var data: Data // OK: isolated な stored property で要件を満たせる
func doThing() {
let compressed = compressData() // OK: self が isolated なので使える
}
nonisolated func failToDoTheThing() {
let compressed = compressData()
// error: isolated conformance MyDataActor : DataProcessible cannot be used when non-isolated
}
}
この方向性が実現すれば、アクター内部からの利用に限定した上で、isolated な stored property をそのまま同期プロトコルの要件に当てる、といった書き方が可能になります。一方で、isolated な適合がアクターの外に漏れ出さないようにするための制約(たとえば Sendable 適合との両立を禁じる、など)も併せて必要になります。