4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Swift] Objective-Cのcompletionをasyncで呼ぶ機能はexecutorを引き継ぐ

Last updated at Posted at 2023-12-01

事前知識

Objective-Cのcompletionをasyncで呼ぶ機能

- (void)signData:(NSData *)signData 
withSecureElementPass:(PKSecureElementPass *)secureElementPass 
      completion:(void (^)(NSData *signedData, NSData *signature, NSError *error))completion;

のようにObjective-Cで定義されたcompletion handler付きのメソッドを

@objc func sign(
    _ signData: Data, 
    using secureElementPass: PKSecureElementPass
) async throws -> (Data, Data)

のasync functionとしてSwiftから呼び出せる機能です。
変換される条件は上のサイト参照。

「使ってるフレームワークにasync function版追加されてるじゃん!」と思っても、実際はこの機能によってObjective-Cのcompletion handlerがasyncに見えているだけかもしれません。

SE-0297ではPassKitのこちらのメソッドが例として紹介されていました。

withUnsafeContinuation

completion handler付きのメソッドを自分でasync functionに変換するときは withUnsafeContinuation, withCheckedContinuationを使います。

基本的に誤使用を検知してくれるCheckedを利用しますが、SE-0297はwithUnsafeContinuationを用いて実装されていると記載されているので、今回はこちらに注目します。使い方はどちらも同じです。

本題

実験します。

MyClass.h
#ifndef callbackFunction_h
#define callbackFunction_h

#import <Foundation/Foundation.h>

@interface MyClass : NSObject

- (void)handleVoidWithCompletion:(void (^)(void))completion;

@end

#endif /* callbackFunction_h */
MyClass.m
#import "MyClass.h"

@implementation MyClass

- (void)handleVoidWithCompletion:(void (^)(void))completion {
    NSLog(@"isMainThread: %d", [NSThread isMainThread]); // メインスレッドかどうか確かめる

    if (completion) {
        completion();
    }
}

@end
main.swift
// 手動でasync functionに変換
extension MyClass {
    func handleVoidManual() async {
        await withUnsafeContinuation { continuation in
            self.handleVoid {
                continuation.resume(returning: ())
            }
        }
    }
}

let myClass = MyClass()

// ここはMainActor = メインスレッド
print("手動変換")
await myClass.handleVoidManual()

// ログ出力が乱れるので少し待つ
try? await Task.sleep(nanoseconds: 10000000)

print("自動変換")
await myClass.handleVoid()
手動変換
isMainThread: 0
自動変換
isMainThread: 1

はい。出力が変わってしまいました。手動変換ではhandleVoid()がメインスレッドではなく、自動変換ではメインスレッドになっています。

なぜ?

これにはasync functionのexecutorの引き継ぎが関わっています。手動変換ではMainActor=メインスレッドがhandleVoid()まで引き継がれていませんが、自動変換では引き継がれています。

つまり、Objective-Cのcompletionをasyncで呼ぶ機能は通常のasync functionとは異なり、executorを引き継ぐ ことがわかります。

実は下のコードの実態は

let myClass = MyClass()
await myClass.handleVoid() // Objective-Cのcompletionをasyncで呼ぶ機能によって生成
extension MyClass {
    func handleVoidManual() async {
        await withUnsafeContinuation { continuation in
            self.handleVoid {
                continuation.resume(returning: ())
            }
        }
    }
}

let myClass = MyClass()
await myClass.handleVoidManual()

ではなく

let myClass = MyClass()
await withUnsafeContinuation { continuation in
    myClass.handleVoid {
        continuation.resume(returning: ())
    }
}

に近い実装がされていると想定されるからです。

withUnsafeContinuationには@_unsafeInheritExecutorというattributeがついており、これは通常async呼び出しをするとexecutorが切り替わる仕様を、呼び出し元のexecutorのまま引き継ぐようにするattributeです。

func handleVoidManual() async {の関数定義を間に入れてしまうとMainActorの executorではなくなってしまいますが、withUnsafeContinuationを直接呼ぶとhandleVoid()までMainActor=メインスレッドになっていたわけです。


SE-0297では以下の記述がありました。

let (signedValue, signature) = try await passLibrary.sign(signData, using: pass)

becomes pseudo-code similar to

try withUnsafeContinuation { continuation in 
    passLibrary.sign(
        signData, using: pass, 
        completionHandler: { (signedValue, signature, error) in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: (signedValue!, signature!))
            }
        }
    )
}

これを見たとき

@objc func sign(
    _ signData: Data, 
    using secureElementPass: PKSecureElementPass
) async throws -> (Data, Data) {
    try await withUnsafeContinuation { continuation in 
        passLibrary.sign(
             signData, using: pass, 
             completionHandler: { (signedValue, signature, error) in
                 if let error = error {
                     continuation.resume(throwing: error)
                 } else {
                     continuation.resume(returning: (signedValue!, signature!))
                 }
             }
         )
     }
}

を自動定義してくれる物だと勘違いしていましたが、違いました。

When the compiler sees a call to such a method, it effectively uses withUnsafeContinuation to form a continuation for the rest of the function, then wraps the given continuation in a closure.
コンパイラは、このようなメソッドの呼び出しを見つけると、withUnsafeContinuation を使って関数の続きを作り、与えられた続きをクロージャで包みます。例えば

「呼び出し」を見つけると「withUnsafeContinuation」を使って...

であって

「定義」を「withUnsafeContinuation」を使って...

とは書いてありません。つまり 「定義を自動生成する」 のではなく、 「呼び出しを自動翻訳する」 のです。

(「自動変換」という言葉もミスリーディングでしたね、「自動翻訳」の方が正確そうです。)

他に根拠は?

あります。SILを覗いて見ましょう。

swiftc -emit-sil main.swift -import-objc-header Objec-Concurrency-Bridging-Header.h

手動

// async_Main
sil hidden @async_Main : $@convention(thin) @async () -> () {
bb0:
...
(省略)
  %9 = load %4 : $*MyClass                        // user: %11
  // function_ref MyClass.handleVoidManual()
  %10 = function_ref @$sSo7MyClassC4mainE16handleVoidManualyyYaF : $@convention(method) @async (@guaranteed MyClass) -> () // user: %11
  %11 = apply %10(%9) : $@convention(method) @async (@guaranteed MyClass) -> ()
  // ↑MyClass.handleVoidManual()を呼び出し
...
(省略)
...
// MyClass.handleVoidManual()
sil hidden @$sSo7MyClassC4mainE16handleVoidManualyyYaF : $@convention(method) @async (@guaranteed MyClass) -> () {
// %0 "self"                                      // users: %13, %8, %7, %6, %1
bb0(%0 : $MyClass):
...
(省略)
  // function_ref withUnsafeContinuation<A>(_:)
  %9 = function_ref @$ss22withUnsafeContinuationyxySccyxs5NeverOGXEYalF : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0 // user: %10
  %10 = apply %9<()>(%4, %8) : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0
  // ↑withUnsafeContinuationを呼び出し
...
(省略)
...
// withUnsafeContinuation<A>(_:)
sil shared [available 12.0.0] @$ss22withUnsafeContinuationyxySccyxs5NeverOGXEYalF : $@convention(thin) @async <T> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <T>) -> @out T {
// %0                                             // user: %3
// %1                                             // user: %4
bb0(%0 : $*T, %1 : $@noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <T>):
  // function_ref closure #1 in withUnsafeContinuation<A>(_:)
  %2 = function_ref @$ss22withUnsafeContinuationyxySccyxs5NeverOGXEYalFyBcXEfU_ : $@convention(thin) <τ_0_0> (Builtin.RawUnsafeContinuation, @guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> () // user: %4
  %3 = get_async_continuation_addr T, %0 : $*T    // users: %5, %4
  %4 = apply %2<T>(%3, %1) : $@convention(thin) <τ_0_0> (Builtin.RawUnsafeContinuation, @guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> ()
  // ↑withContinuationのコールバックを呼び出し
  await_async_continuation %3 : $Builtin.RawUnsafeContinuation, resume bb1 // id: %5
  // ↑withContinuationのコールバックをawaitする

色々端折ってSwift風に書き下すと以下のようになります。概ね元のソースコードと同じ構造ですね。

func main() async {
    await handleVoidManual()
}

func handleVoidManual() async {
    await withUnsafeContinuation(...) // 触れてないが{ handleVoid { continuation.resume(()) } }になってる
}

func withUnsafeContinuation(callBack: (RawUnsafeContinuation) -> Void) async {
    let continuation: RawUnsafeContinuation = ...

    callBack(continuation)

    await Builtin.wait(continuation) // 想像
}

自動

// async_Main
sil hidden @async_Main : $@convention(thin) @async () -> () {
bb0:
省略)
  %10 = load %4 : $*MyClass                       // users: %11, %20
  %11 = objc_method %10 : $MyClass, #MyClass.handleVoid!foreign : (MyClass) -> () async -> (), $@convention(objc_method) (Optional<@convention(block) () -> ()>, MyClass) -> () // user: %20
  // ↑Objective-Cの元々の`handleVoid()`を%11に
  
  %12 = get_async_continuation_addr (), %9 : $*() // users: %22, %13
  %13 = struct $UnsafeContinuation<(), Never> (%12 : $Builtin.RawUnsafeContinuation) // user: %16
  // ↑UnsafeContinuationを普通にinit
  
  %14 = alloc_stack $@block_storage UnsafeContinuation<(), Never> // users: %21, %18, %15
  %15 = project_block_storage %14 : $*@block_storage UnsafeContinuation<(), Never> // user: %16
  store %13 to %15 : $*UnsafeContinuation<(), Never> // id: %16
  
  // function_ref @objc completion handler block implementation for @escaping @callee_unowned @convention(block) () -> () with result type ()
  %17 = function_ref @$sIeyB_ytTz_ : $@convention(c) (@inout_aliasable @block_storage UnsafeContinuation<(), Never>) -> () // user: %18
  // ↑handleVoidのクロージャー

  
  %18 = init_block_storage_header %14 : $*@block_storage UnsafeContinuation<(), Never>, invoke %17 : $@convention(c) (@inout_aliasable @block_storage UnsafeContinuation<(), Never>) -> (), type $@convention(block) () -> () // user: %19
  %19 = enum $Optional<@convention(block) () -> ()>, #Optional.some!enumelt, %18 : $@convention(block) () -> () // user: %20
  // handleVoidの引数
  
  %20 = apply %11(%19, %10) : $@convention(objc_method) (Optional<@convention(block) () -> ()>, MyClass) -> ()
  // ↑%11=Objective-Cの元々の`handleVoid()`を呼ぶ
  dealloc_stack %14 : $*@block_storage UnsafeContinuation<(), Never> // id: %21
  
  await_async_continuation %12 : $Builtin.RawUnsafeContinuation, resume bb1 // id: %22
  // ↑handleVoidをawaitする

色々端折ってSwift風に書き下すと以下のようになります。

func main() async {
    let continuation = UnsafeContinuation()

    // Objective-Cの普通のhandleVoidのcompletion版を呼び出し
    handleVoid {
      continuation.resume()
    }

    // 想像
    await Builtin.await(continuation)
}

手動の時とは全く違いますね。mainhandleVoidの呼び出しやawaitが簡潔にまとまっている上、withUnsafeContinuation すら出てきていません。UnsafeContinuationを普通にinitしているので、withUnsafeContinuationの内部実装を展開している、といったところでしょうか。(推測)

補足

SILを踏まえて再度自動翻訳について言及すると

let myClass = MyClass()
await withUnsafeContinuation { continuation in
    myClass.handleVoid {
        continuation.resume(returning: ())
    }
}

に翻訳されると言うのも少し違うのかなと思います。withUnsafeContinuationすら出てきていないので、こちらも実際には以下のようなwithUnsafeContinuationの内部実装で展開されていると考えるのが良いんじゃないでしょうか。
@_unsafeInheritExecutorの話をしましたが、これも実際には無関係かもしれません。ただ、SE-0297では pseudo-code similar to ...withUnsafeContinuationが取り上げられているので、「@_unsafeInheritExecutorを利用した場合と同等の挙動を取る」と言う意味で覚えておくのが良いかなと思います。)

これならexecutorを引き継ぐのは当たり前ですよね、なぜなら普通の関数を呼び出しているだけですから

let myClass = MyClass()
let continuation = UnsafeContinuation()
handleVoid {
  continuation.resume()
}
await Builtin.await(continuation)
SILフル出力

https://gist.github.com/kntkymt/d24f5003691e8c50be5f4d572bc8a7f3

それで、結局どうすれば良いの?

基本は引き継がれないはずのexecutorが引き継がれることで意図しないパフォーマンス低下やエラーを引き起こす可能性があります。

VStack {
    ...
}
.task {
    // 普通のasync functionの場合、awaitでMainActor executorから変わるためアクセスはメインスレッドで起きないが
    // 仮にgetDataが内部でスレッド等を利用しておらず、Objective-Cから自動翻訳されていた場合メインスレッドで処理が走る
    await Storage.shared.getData()
}
Task { @MainActor in
    // Objective-Cで書かれている3rd partyライブラリのクラス
    let client = XXAuthClient()
    
    // signInはメインスレッドから呼ばなくてはいけない仕様
    // これでメインスレッドから呼べるが、通常のasync functionとは違う仕様が存在することが初見で分かりずらい
    await client.signIn(viewController: ...)
}

Objective-Cで書かれているはずのライブラリでasync functionを見かけたら、一瞬立ち止まって考えてみると良いかもしれません。必要に応じて手動変換するなどの対処も必要かなと思います。

併せて読みたい

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?