Global-actor isolated conformances
01 何が問題だったのか
@MainActor などのグローバルアクターに isolate された型は、シングルスレッド志向のプログラムやUIアプリケーションで頻繁に使われます。こうした型は、自分の isolation domain の中では自然にプロパティや処理を扱えますが、ジェネリックなコードと組み合わせて使おうとすると、プロトコルに適合できないという問題に突き当たります。
たとえば、@MainActor に isolate されたクラスを Equatable に適合させようとすると、== が暗黙に @MainActor になる一方で、Equatable.== の要件は非isolatedであるため、isolation が一致せずにエラーになります。
@MainActor
class MyModelType: Equatable {
var name: String
init(name: String) {
self.name = name
}
// error: main-actor-isolated static function '==' cannot satisfy non-isolated requirement 'Equatable.=='
static func ==(lhs: MyModelType, rhs: MyModelType) -> Bool {
lhs.name == rhs.name
}
}
この問題を回避するには、プロトコル要件を満たす各メンバを nonisolated にする必要がありますが、そうすると @MainActor な stored property にアクセスできなくなります。
nonisolated static func ==(lhs: MyModelType, rhs: MyModelType) -> Bool {
lhs.name == rhs.name
// `- error: main actor-isolated property 'name' can not be referenced from a nonisolated context
}
さらに MainActor.assumeIsolated で包むという回避策もありますが、これには二つの問題があります。ひとつは、静的に検証できるはずのデータ競合安全性を動的なランタイムチェックに委ねてしまうこと。もうひとつは、プロトコル要件を満たすメンバ一つひとつに同じ nonisolated + assumeIsolated のパターンを繰り返し書く必要があり、ボイラープレートが膨大になることです。
nonisolated static func ==(lhs: MyModelType, rhs: MyModelType) -> Bool {
MainActor.assumeIsolated {
lhs.name == rhs.name
}
}
結果として、グローバルアクターに isolate された型は、ジェネリクスを利用する多くのライブラリとの接続を事実上断たれていました。
02 どのように解決されるのか
プロトコル適合そのものをグローバルアクターに isolate できるようにします。これを isolated conformance(isolated な適合)と呼び、適合宣言のプロトコル名の前に @MainActor などのグローバルアクター属性を付けて表します。
@MainActor
class MyModelType: @MainActor Equatable {
var name: String
init(name: String) {
self.name = name
}
static func ==(lhs: MyModelType, rhs: MyModelType) -> Bool {
lhs.name == rhs.name
}
}
この書き方によって、MyModelType の Equatable への適合は「メインアクター上でのみ使える Equatable 適合」として扱われます。nonisolated / assumeIsolated のパターンが不要になり、動的ではなく静的にデータ競合安全性がチェックされます。型自体の isolation と、その型が適合するプロトコルの isolation は別物として扱えるため、actor や非isolatedな struct が @MainActor 適合を持つこともできます。
isolated conformance の3つのルール
isolated conformance が安全に機能するよう、コンパイラは次の3つのルールを強制します。
ルール1: isolated conformance は、自分の isolation domain の中でしか使えない。
対応するグローバルアクターに isolate されていないコンテキストからは、その適合を経由した操作(メソッド呼び出し、any P への型消去、ジェネリック引数としての受け渡しなど)はエラーになります。
struct S: @MainActor P { }
func badFunc() -> any P {
S() // error: non-@MainActor-isolated function uses @MainActor-isolated conformance `S: P`
}
@MainActor
func useName() {
let named = Named()
Task.detached {
named[keyPath: \HasName.name] // error: isolated conformance を main actor の外で使っている
}
}
別のプロトコル P2 の associatedtype に、isolated conformance を持つ型を割り当てると、P2 への適合もまた isolated になる必要があります。
ルール2: isolated conformance を抽象化して渡せるのは、型パラメータが SendableMetatype でない場合に限る。
このProposalでは、マーカープロトコル SendableMetatype が新たに導入されます。
protocol SendableMetatype { }
protocol Sendable: SendableMetatype { }
SendableMetatype は「T.Type(メタタイプ)を isolation boundary を越えて送れる」ことを示します。構造体・列挙型・クラス・アクターなどの具象型は暗黙にこれに適合しますが、ジェネリック型パラメータ T はその旨の制約が明示されない限り SendableMetatype ではありません。
ジェネリック関数の呼び出し側では、T: P 要件を isolated conformance で満たせるのは、同じジェネリック署名に T: Sendable も T: SendableMetatype もない場合に限られます。Sendable は SendableMetatype を継承しているため、Sendable 制約があれば自動的にこの制限が効きます。
func acceptsP<T: P>(_ value: T) { }
func acceptsSendableMetatypeP<T: SendableMetatype & P>(_ value: T) { }
@MainActor func passIsolated(s: S) {
acceptsP(s) // OK: T に SendableMetatype 要件がない
acceptsSendableMetatypeP(s) // error: T が SendableMetatype なので isolated conformance は渡せない
}
同じルールは、any P や some P のように型パラメータが隠れている場合にも適用されます。any SendableMetatype & P や some Sendable & P に isolated conformance を持つ値を入れようとするとエラーになります。
ジェネリック関数の実装側でも、T.Type は T: SendableMetatype がないと Sendable とみなされません。したがって、以下のように型パラメータのメタタイプを別の isolation domain に渡す関数は、SendableMetatype 制約を付けないとコンパイルできません。
nonisolated func callQGElsewhere<T: Q>(_: T.Type) {
Task.detached {
T.g() // error: non-sendable metatype of 'T' captured in 'sending' closure
}
}
// 修正: SendableMetatype を要求する
nonisolated func callQGElsewhere<T: Q & SendableMetatype>(_: T.Type) {
Task.detached {
T.g()
}
}
ほとんどの既存ジェネリックコードは並行性に関与しないため影響を受けず、並行性を使うコードはたいてい既に Sendable 制約を持っているため、結果的に大半のコードは変更なしで isolated conformance と共存できます。
ルール3: isolated conformance に依存する値は、そのグローバルアクターの region に属する。
region-based isolation による非Sendable値の転送(transfer)に対しては、isolated conformance を経由した値をそのグローバルアクターの region と合流させる、というルールが追加されます。これにより、sending 引数や sending 戻り値を通じて isolated conformance を含む値が別の isolation domain に持ち出されるのを防ぎます。
@MainActor func acceptSending(_ value: sending any P) { }
@MainActor func passSending() {
let c1 = C() // 独自の region
let ap1: any P = c1 // C: P の適合が @MainActor なので、c1 の region が MainActor の region に合流
acceptSending(ap1) // error: 引数は MainActor region に属している
}
動的キャスト
as? / is による適合の動的な問い合わせも、isolated conformance を考慮します。isolated conformance に一致するキャストは、そのグローバルアクターの executor 上で実行されているときのみ成功し、そうでなければ nil を返します。一方、as? any Sendable & P のように Sendable や SendableMetatype を伴うキャストは、たとえ該当アクター上で動いていても isolated conformance を受け入れません。
適合の isolation 推論
グローバルアクターに isolate された型の適合は、同じアクターに isolate したい場合が多いため、upcoming feature flag InferIsolatedConformances を有効にすると、そのような適合を自動的に isolated として推論します。
@MainActor
class MyModelType: /* 推論で @MainActor */ P {
func f() { } // P.f を満たす。暗黙に @MainActor
}
推論を避けたい場合は、適合側に nonisolated を明示します。
@MainActor
class MyModelType: nonisolated Q {
nonisolated static func g() { }
}
また、以下のいずれかに当てはまる場合は、InferIsolatedConformances 下でも適合は nonisolated と推論されます。
- プロトコルが
SendableMetatype(Sendableを含む)を継承している。isolated conformance は元々使えないため。 - プロトコル要件を満たすすべての宣言が
nonisolatedである。
SE-0466 でモジュール既定の @MainActor 化を有効にした場合、この InferIsolatedConformances も併用することで、型の isolation と適合の isolation が揃い、「ほぼシングルスレッド」というモデルがジェネリックコードとの境界でも自然に機能します。
Future Directions
現状のスコープはグローバルアクターに isolate された適合に限られ、アクターインスタンスに isolate された適合(例: actor A: isolated P)は対象外です。アクターインスタンスの適合は、具体的なアクターインスタンスと紐付けて扱う必要があり、ルール1の強制だけでも region-based isolation への大きな依存が必要になるなど、設計上のハードルが高いためです。十分な需要と実装方針が見つかれば、将来的に検討される可能性がありますが、現時点では speculative な方向性です。