事前知識
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
を用いて実装されていると記載されているので、今回はこちらに注目します。使い方はどちらも同じです。
本題
実験します。
#ifndef callbackFunction_h
#define callbackFunction_h
#import <Foundation/Foundation.h>
@interface MyClass : NSObject
- (void)handleVoidWithCompletion:(void (^)(void))completion;
@end
#endif /* callbackFunction_h */
#import "MyClass.h"
@implementation MyClass
- (void)handleVoidWithCompletion:(void (^)(void))completion {
NSLog(@"isMainThread: %d", [NSThread isMainThread]); // メインスレッドかどうか確かめる
if (completion) {
completion();
}
}
@end
// 手動で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)
}
手動の時とは全く違いますね。main
にhandleVoid
の呼び出しや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)
それで、結局どうすれば良いの?
基本は引き継がれないはずの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を見かけたら、一瞬立ち止まって考えてみると良いかもしれません。必要に応じて手動変換するなどの対処も必要かなと思います。
併せて読みたい