soranoba
soranoba Author of soranoba.net
programming

[Swift6] アクター分離境界 (Actor isolation boundary) を理解する

Swift6から本格導入された、Actor isolation boundaryについて解説します。
Swift5でもActorは導入されていましたが、Swift6がリリースされるまでの過程でいくつかの変更が入っており、それらを踏まえたものなので、Swift5のActorの説明とは異なる箇所がある点には注意してください。

また、それなりに調べた上で書いていますが、正確な情報を保証する物ではない為、必要に応じて公式ドキュメントを参照してください。この記事において参照したソースについては、末尾に記載しています。

ActorとActor isolation boundaryの基本的な考え方

ActorとExecutor

同時実行性を制御する概念として、Actorが導入されました。
SwiftにおけるActorは、従来のオブジェクト指向にアクターモデルの概念を取り入れた物で、「同時実行性についての指定をClassに加えた物」と捉えて概ね問題ないでしょう。

Actorは1つのSerialExecutorを(多くの場合は暗黙的に)指定します。
このSerialExecutorはDispatchSerialQueueのようなもので、キューイングされたJobを順番に処理する役割を担います。(便宜上、SerialExecutorのことを指して以降はExecutorと書きます)

ActorとExecutorの関係

例えば、上記の図のようにActor AActor Bが同じExecutorを指定した場合、Actor AActor Bのメソッドは同時には1つしか実行されないことが保証されます。
逆に、別のExecutor βを指定したActor CActor Aのメソッドと同時に実行される可能性があることを意味します。

ActorのMethodは決まったExecutorでしか実行されない

Executorによる排他制御で説明しましたが、当然Actor Aのメソッド同士も同時に実行されません。
多くの場合は、Executorを意識することはないのでActorのメソッドは同時に実行されることがないと捉えて問題ありません。
これによって、並列プログラミングによる排他制御を行おうというのが基本的な考え方です。

Actor isolation boundaryとSendable

先ほどの例においてActor AActor Cは別のExecutorで実行されるとしました。
では、Actor AからActor Cの以下のようなメソッドを呼び出す場合はどうなるでしょうか。

func fetch(request: Request) async throws -> Response

Actor間のメッセージのやり取り

この時、RequestとResponseはActor AActor C上の両方で扱われることになり、同じデータ領域に同時に書き込みが行われる可能性があります。
このように複数のActorを跨ぐことを「アクター分離境界を越える (across actor isolation boundaries)」と言います。(Actor boundary, Isolation boundaryなど表記のブレがありますが、以降はアクター境界と書きます)

値コピーされる構造体や、スレッドセーフなクラスを「アクター境界を超えられる」とすることで、Actor間のやり取りが安全であることが分かるようになります。この、「アクター境界」を超えられる値というのが、Sendableです。

Region based isolation

しかし、アクター境界を越える為には、必ずSendable(=スレッドセーフ)でなければならないのでしょうか?
別の形で異なるアクター上で同時に扱われないことが保証されるのであれば、値自体がスレッドセーフでなくても良いはずです。そこで導入されたのが「Region based isolation」です。

/** on Actor C */
func fetch(request: sending Request) async throws -> sending Response {
  let (data, _) = try await urlSession.data(for: request)
  return try decode(data, to: Response.self) // Responseは以降、Actor Cでアクセスされない
}
/** on Actor A */
let req = Request()
self.res = try await c.fetch(request: req) // Requestは以降、Actor Aでアクセスされない

このようなコードがあった場合、RequestはActor AからActor Cに、ResponseはActor CからActor Aにアクセス権を移譲した形になり、安全なコードであると言えます。
このアクセス権の移譲の必要性を判別できるようにする為に、sendingキーワードが導入されました。sendingキーワードがある引数に渡したインスタンス、もしくはsendingキーワードのある返り値にしたインスタンスは、メンバ変数などに保持しておくことができなくなります。

同時実行制御の為の1単位としてのActor

ここまでで基本的なActorの概念を説明しましたが、実際にはもう少し複雑になります。
前言を撤回するようですが、実際はActorはオブジェクト指向におけるClassと同じレイヤーの存在ではありません。時にはClassの一部がActor上で呼び出されず、また、別のClassがActor上で呼び出されることもあります。

つまり、SwiftにおけるActorは、同時実行制御の為の一つの単位でしかなく、クラス・関数・変数を横断的にグループ化したものです。
ここからは、そのグループ化の仕方と、その意義を解説します。

GlobalActor

iOSアプリ開発において、最も一般的な分離はメインスレッドでの実行を指定することです。
UIViewやUIViewControllerなどの、UIに関する操作は今までもメインスレッドで行う必要がありました。
これらについて都度Actorを定義する必要性はないので、GlobalActorという共通で利用できるActorが定義され、その1つとしてMainActorも定義されました。

@MainActor class Service {}
@MainActor var value: Int
@MainActor func someMethod() {}

このように、既存の変数・関数・クラスに対してGlobalActorを紐づけることが可能となります。
これの便利なところは、同じGlobalActor上であればアクター境界を超えないという点にあります。これによってSendableを意識せずとも、クラス間のやり取りができるようになります。

nonisolated

iOSアプリ開発で広く使われてきたデザインパターンに、Delegateパターンがあります。
例えば、以下のようなDelegateがあった場合に、果たしてこれが実行されるのはどのActorでしょうか?

@MainActor
protocol Delegate {
  func reload()
}

actor A: Delegate {
  func someMethodX() {
    reload() // on Actor A?
  }
}
extension A: Delegate {
  func reload() {} // on Actor A?
}

actor B {
  func someMethodY() {
    reload() // on Actor B?
  }
}
extension B: Delegate {
  func reload() {} // on Actor B?
}

protocolを実装する場合、protocolで定義されたActorと実装側のActor定義があり、これが一致しない場合に不都合が生じます。
このようなケースに使われるのがnonisolated(非分離)です。Swift6では、これらが一致しない場合に非分離(= クラスで指定されたActor上では実行されない)とする必要があります

extension A: Delegate {
  nonisolated func reload() {}
}

この場合は、Delegate@MainActorとしているので、reloadActor A上ではなく、MainActor上で実行されることになります。
但し、nonisolatedなメソッドからメンバ変数に直接アクセスしようとすると、アクター境界を越えることになるので、コンパイルエラーになります。

actor A {
  var value: Int
}
extension A: Delegate {
  nonisolated func reload() {
    self.value = 1 // Actor boundary error
  }
}

isolated

逆に特定のメソッドや変数を指定したActor上に分離することもできます。それがisolated(分離)です。

func deposit(amount: Double, to account: isolated BankAccount) {
  // on Actor(BankAccount)
}

これをうまく用いることによって、不必要な非同期処理を削減し、パフォーマンスの向上を図ることができます。
また、アクター境界を越えることができない場合に、この方法でアクター境界を超えない形に実装することもできます。


アクター境界について解説しました。近日中に実践的なSwift6対応についての記事も投稿したいと思います。(多分……)

参考文献

(Updated: )

comments powered by Disqus