Improved control over actor isolation
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 化しなくても、アクターを段階的に導入していけます。
Future Directions
将来の方向性として、以下のような拡張が speculative に言及されています(実現を約束するものではありません)。
- 複数の
isolatedパラメータの許可。現状は禁止されていますが、カスタムエグゼキュータ等で「複数のアクターが同じ concurrency domain を共有する」ことを静的に保証できれば、将来的に許容しうる余地があります。 - Isolated protocol conformances。アクター型のプロトコル適合を「アクターの isolation domain の内側でのみ使える」ものとして表現する仕組みです。
actor MyDataActor: isolated DataProcessibleのように、適合自体にisolatedを付けて、その適合をアクターの外から使うと型エラーになる、といった方向が検討されています。これが入ると、isolated なvar data: Dataで同期プロトコル要件を満たすといった書き方が可能になります。