[Swift6] アクター分離境界 (Actor isolation boundary) を考慮したasync関数の定義パターン
アクター分離境界(以降、アクター境界とする)を意識しなければならない箇所はいくつかありますが、その際たる例はasync関数です。
この記事ではasync関数におけるアクター境界の考え方について解説します。
前回の記事でアクター境界について解説をしている為、必要に応じて参照してください。
前提
前提として、Sendable
であれば常にアクター境界を越えることができる為、アクター境界を意識する必要はありません。その為、この記事ではRequest
とResponse
というnon-Sendable
な型を使って考えます。
class Request {}
@available(*, unavailable)
extension Request: Sendable {}
class Response {}
@available(*, unavailable)
extension Response: Sendable {}
async関数が呼び出された時の実行スレッド
Swift6からアクターの紐付けのないasync関数の実行は、汎用Executorに割り当てられるようになりました。
その結果、実行スレッドは以下のようになります。
func fetch(_ request: Request) async throws -> Response {
// On Thread2
let (data, _) = try await urlSession.data(for: request.makeRequest())
// On Thread2
return Response(data: data)
}
Task {
// On Thread1
let res = try await fetch(Request())
// On Thread1
}
よって、async関数を呼び出した際の引数と返り値がアクター境界を越える必要が出てきます。
アクターのasync関数の場合
1. sendingを用いる
では、アクターのasync関数について考えます。
この場合、最もオススメの定義はsending
を用いた以下の定義です。
func fetch(_ request: sending Request) async throws -> sending Response {}
sending
キーワードを用いることで、引数と返り値がアクター境界を越えることができるようにすることができます。
但し、引数は呼び出し側、返り値は実装側のメンバ変数に値を保持しておくことができなくなります。
その理由については、前回の記事を参照してください。
2. GlobalActorを用いる
メンバ変数として保持したい場合は、呼び出し側と同じGlobalActorを用いる必要があります。
@MainActor class Service {
func fetch(_ request: Request) async throws -> Response {}
}
@MainActor class Controller {
func run() {
Task {
self.response = try await self.service.fetch(self.request)
}
}
}
このようなケースであれば、そもそもアクター境界を越えることがない為、メンバ変数に保持したとしても常に同じスレッドからしかアクセスされない為、問題がありません。
アクター以外のasync関数の場合
isolationの指定をする
func fetch(_ request: Request, isolation: isolated (any Actor)? = #isolation) async throws -> Response {}
アクター以外の場合は呼び出し側からisolationを引き継ぐのがオススメです。
この方法をアクターに用いる場合はnonisolated
と扱われてしまい、関数内でのメンバ変数へのアクセスが困難になりますが、アクターでない場合はこの問題がありません。
但し、Sendable
のasync関数の場合は、メンバ変数に保存する場合は異なるスレッドからアクセスされる可能性がある点には注意して実装する必要があります。
@unchecked Sendable
にしている為コンパイルエラーにはなりませんが、この場合に限らず、スレッドセーフとして扱うのであれば常に意識すべき点でもあります。
let service = SendableService()
actor Actor1 {
func run() async throws -> Response {
// On Thread1
return try await service.fetch(Request())
}
}
actor Actor2 {
func run() async throws -> Response {
// On Thread2
return try await service.fetch(Request())
}
}
class SendableService: @unchecked Sendable {
private let urlSession = URLSession.shared
private var request: Request?
private var response: Response?
init() {}
func fetch(_ request: Request, isolation: isolated (any Actor)? = #isolation) async throws -> Response {
// On AnyThread
self.request = request
let (data, _) = try await self.urlSession.data(for: request.makeRequest())
// On AnyThread
let response = Response(data: data)
self.response = response
return response
}
}
一方、non-Sendable
の場合は、そもそも同じスレッドからしか呼び出されないと考えることができるので、これを考慮する必要がありません。
@MainActor class Controller1 {
let service: NonSendableService
init(service: NonSendableService) {
// On MainThread
self.service = service
}
func run() async throws -> Response {
// On MainThread
return try await service.fetch(Request())
}
}
@MainActor class Controller2 {
func run() {
Task {
// On MainThread
let service = NonSendableService()
let c1 = Controller1(service: service)
let res = try await service.fetch(Request())
}
}
}
参考文献
記事が気に入ったらチップを送ることができます!
You can give me a cup of coffee :)
Kyash ID: soranoba
Amazon: Wish List
GitHub Sponsor: github.com/sponsors/soranoba
PayPal.Me: paypal.me/soranoba
(Updated: )