soranoba
soranoba Author of soranoba.net
programming

[Swift6] アクター分離境界 (Actor isolation boundary) を考慮したcallback

この記事では、同期関数 (no-async) のcallbackにおけるアクター分離境界(以降、アクター境界)の考え方について解説します。

関連記事

  1. アクター分離境界 (Actor isolation boundary) を理解する
  2. アクター分離境界 (Actor isolation boundary) を考慮したasync関数の定義パターン

この記事は上記に続く3つ目の記事です。以前の記事の内容を前提にしている場合があります。

同期関数のcallbackとは

async関数以前は、非同期に実行する場合callbackで結果を受け取る必要がありました。
例えば、以下のようなコードです。このような関数はSwift6によってどのように変わるでしょうか。

func fetch(request: Request, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
    URLSession.shared.dataTask(with: request.makeRequest()) { data, urlResponse, error in
        // 省略
        completion(result)
    }.resume()
}

異なるアクター上でcallbackが呼び出される場合

まずは、callbackが異なるアクター上で呼び出されるケースを考えます。

callbackが別のアクター上で呼び出される様子

この場合、callbackはアクター境界を越えるので、Sendableにする必要があります。
しかし、それだけでは足りません。多くの場合はcallbackで結果を受け取り、元のアクター上に持っていく必要があります。

resultを同じアクター上に戻す様子

これを考慮すると以下のコードになります。

// ResultがSendableの場合 (= ResponseがSendableの場合) はsendingは不要です
func fetch(request: Request, completion: @escaping @Sendable (sending Result<Response, Swift.Error>) -> Void) {
    URLSession.shared.dataTask(with: request.makeRequest()) { data, urlResponse, error in
        // 省略
        completion(result)
    }.resume()
}

// On MainActor
Service().fetch(request: Request()) { [weak self] result in
    Task { @MainActor in
        if case let .success(response) = result {
            self?.res = response
        }
    }
}

呼び出し側が煩雑になりますが、このやり方は呼び出し側で、スレッド呼び出しを必要最小限に制御することができるというメリットがあります。
その為、繰り返し高い頻度で呼び出され、オーバヘッドを極力減らしたい場合に検討するのが良いでしょう。

同じアクター上でcallbackを呼び出す場合

では、どうやって呼び出し側の煩雑さを軽減するかと言うと、callbackの呼び出しを同じアクター上にすることで、これが実現できます。

callbackが同じアクター上で呼び出される様子

1. 呼び出し側でActorを指定する

@isolated(any) Function TypesによってClosureをどのアクター上で実行するべきかを引き継げるようになりました。
これを用いると、以下のコードになります。

func fetch(request: Request, completion: @escaping @isolated(any) (Result<Response, Swift.Error>) -> Void) {
    nonisolated(unsafe) let completion = completion

    URLSession.shared.dataTask(with: request.makeRequest()) { data, urlResponse, error in
        Task {
            // 省略
            await completion(result)
        }
    }.resume()
}

// On MainActor
Service().fetch(request: Request()) { [weak self] result in
    // On MainActor
    if case let .success(response) = result {
        self?.res = response
    }
}

この方法だと、@isolated(any)@MainActorと同様に暗黙のSendableとして扱うべきにも関わらず、Swift6.0時点では扱われていない為、nonisolated(unsafe)を使う必要があります。
Closure isolation controlというプロポーザルは出ているので、これが実装されればnonisolated(unsafe)を使わない書き方にできる可能性があります。この方法はやや時期尚早と言えます。

2. GlobalActorを用いる

GlobalActorを使用している場合は、これを使うことでより簡略化することもできます。

func fetch(request: Request, completion: @escaping @MainActor (Result<Response, Swift.Error>) -> Void) {
    URLSession.shared.dataTask(with: request.makeRequest()) { data, urlResponse, error in
        Task { @MainActor in
            // 省略
            await completion(result)
        }
    }.resume()
}

// On MainActor
Service().fetch(request: Request()) { [weak self] result in
    // On MainActor
    if case let .success(response) = result {
        self?.res = response
    }
}

呼び出し側・実装側が共に同じGlobalActorの場合は、適切な選択肢と言えます。

3. asyncにする

お気づきのことと思いますが、「同じアクター上でcallbackを呼び出す」というのはasync関数でやっていることと同じです。その為、async関数に変更するという選択肢もあります。

従来実装(non-Sendable)の挙動

関連して、従来実装の場合はどのような挙動になるか解説します。

例えば、UIViewController.present(_:animated:completion:)などのcallbackは@Sendableもアクターの指定もありません。
これは、何故動作しているのでしょうか?

動的アクター分離チェック

挙動が分かりやすい、UNUserNotificationCenter.requestAuthorization(options:completionHandler:)を例に取ります。

override func viewDidLoad() {
    super.viewDidLoad()

    // On MainActor
    UNUserNotificationCenter.current().requestAuthorization { isAuthorized, _ in
        // On OtherActor
        self.isAuthorized = isAuthorized
    }
}

これはコンパイルは通りますが、アクター境界を越えてcallbackが実行される為、実行時にクラッシュします。
これは、Unmarked Sendable Closuresにあるように、動的アクター分離チェックが動作している為であり、以下のように変更することで正しく動作するようになります。

UNUserNotificationCenter.current().requestAuthorization { @Sendable isAuthorized, _ in
    Task { @MainActor in
        self.isAuthorized = isAuthorized
    }
}

non-Sendableで良い場合

ではUIViewController.present(_:animated:completion:)が何故問題ないのかと言うと、関数呼び出しとcompletionは必ずMainActorで実行される為、アクター境界を越えることがない為です。
言い換えると、アクター境界を跨がない場合は従来通りの定義で良いということでもあります。
しかし、多くの場合Swift6下ではコンパイルエラーになる為、何らかの回避方法が必要になります。

@MainActor class MainActorService {
    func fetch(request: Request, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
        URLSession.shared.dataTask(with: request.makeRequest()) { data, urlResponse, error in
            Task { @MainActor in
                // 省略

                // CompileError: Capture of 'completion' with non-sendable type '(Result<Response, any Error>) -> Void' in a `@Sendable` closure
                completion(result)
            }
        }.resume()
    }
}

この記事のコードはこちらから確認することができます

(Updated: )

comments powered by Disqus