soranoba
soranoba Author of soranoba.net
programming

[Swift6] アクター分離境界 (Actor isolation boundary) を考慮したasync関数の定義パターン

アクター分離境界(以降、アクター境界とする)を意識しなければならない箇所はいくつかありますが、その際たる例はasync関数です。
この記事ではasync関数におけるアクター境界の考え方について解説します。

前回の記事でアクター境界について解説をしている為、必要に応じて参照してください。

前提

前提として、Sendableであれば常にアクター境界を越えることができる為、アクター境界を意識する必要はありません。その為、この記事ではRequestResponseという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())
    }
  }
}

参考文献

(Updated: )

comments powered by Disqus