Swift 6で来たる並行処理の大型アップデート近況
最近、 Swift リポジトリに並行処理関係の Pull Request (PR) が続々とマージされています。 たとえば、次のような PR があります。
- Add
asyncto the Swift type system. #33147 - Add @asyncHandler attribute. #33476
- Import "did" delegate methods as @asyncHandler. #34065
- Import Objective-C methods with completion handlers as async #33674
- Basic support for actor classes and actor isolation #33906
Swift の並行処理( Concurrency )関連の機能については、 2020 年 1 月に発表された "On the road to Swift 6" という公式アナウンスの中で特に重要な分野の一つとして挙げられていました。
- Provide excellent solutions for major language features such as memory ownership and concurrency
前述の PR はそれに沿った動きだと考えられます。
さらに、 Swift Core Team の一人である John McCall さんが 2020 年 9 月 17 日に Swift Forums で並行処理のデザイン案が数週間内に提供されると述べています 。
The concurrency design (coming in a few weeks, I promise!) will likely demote the importance of using this API directly
ちなみに上記の文中の "this API" とは DispatchQueue.main のことで、これを直接利用する重要性は下がると述べています。 DispatchQueue.main は Swift プログラマにとって非常に馴染み深い API だと思いますが、これを使う機会がほとんどなくなる(?)ほどの劇的な変化がありそうだと推測できます。
Swift の並行処理については現状では正式な手順でプロポーザルが作られていないものの、上記の経緯から考えてこれから急速に議論が進み、 Swift の次期メジャーバージョンである Swift 6 (おそらく 2021 年リリース)には並行処理に関する大型のアップデートが含まれる ことになりそうです。
昨日開催されたわいわいswiftc #22 にて上記が話題になったので、その内容を本記事にまとめてみました。
Swift の並行処理
Swift の並行処理については、 2017 年 8 月に( Swift の生みの親であり Core Team のメンバーでもある) Chris Lattner によってマニフェストが示されました。
このマニフェストは五つのパートからなるのですが、 Part 1 が async/await について、 Part 2 以降が actor についてのものとなっています。
async/await についてはプロポーザルのドラフトとして↓に切り出されています。
これらは Swift コミュニティに大きな衝撃を与え、半ば規定路線として扱われていますが、 3 年経った 2020 年 9 月 26 日現在でも正式なマニフェストやプロポーザルは作られていません。その証拠に上記のドキュメントはただの Gist で、 Swift および Swift Evolution リポジトリに取り入れられていません。
しかし前述したように、このところ並行処理関連の動きが加速しており、今後正式に議論が進められていくものと思われます。
Swift の async/await
async/await と言えば JavaScript / TypeScript や C# が有名です。一見すると、それらの async/await と Swift の async/await は同じもののように見えます。
// JavaScript
async function foo() {
const a = await bar();
const b = await baz();
return a + b;
}
// Swift
func foo() async -> Int {
let a = await bar()
let b = await baz()
return a + b
}
async の位置が違う他はそっくりです。しかし、 Swift の async/await はそれらの言語の async/await と少し異なります。
たとえば、 JavaScript / TypeScript では async はその関数の中で await を使えるという意味ですし、 await は Promise を剥がす(値が得られるまで非同期的に待つ)ためのものです。そして、 async 関数の戻り値は Promise です。
しかし、 Swift の async/await には Promise に相当するものは登場しません。 async はその関数が非同期であることを示し、 await は async な関数をコールするときに必要なマークに過ぎません。 async 関数の戻り値の型も Int のような素の型です。
Swift の async と await はそれぞれ throws と try にとても良く似ています。 throws が付与された関数を呼び出すときには try を付けることが求められます。
func foo() throws -> Int { ... }
let a: Int = try foo() // try がないとコンパイルエラー
同様に、 async が付与された関数を呼び出すときには await を付けることが求められます。
func foo() async -> Int { ... }
let a: Int = await foo() // await がないとコンパイルエラー
また、 throws 関数を呼び出す場合(つまり try を書く場合)、( catch しない限りは) throws 関数の中でないといけません。
func main() throws { // throws がないとコンパイルエラー
print(try foo())
}
同様に、 async 関数を呼び出す場合( await を書く場合)、 async 関数の中でないとコンパイルエラーになります。
func main() async { // async がないとコンパイルエラー
print(await foo())
}
このように、 Swift では throws と async が、 try と await が対になるように設計されており、 Promise は介在しません。 Swift の async/await は JavaScript 等の async/await よりも Kotlin の suspend に近く、機能的にも Kotlin と同じくコルーチンをサポートするためのものです。
Swift の async/await そのものや throws/try との関係については、↓の記事および try! Swift Tokyo 2016 の発表で説明しているのでそちらを御覧下さい。
- Proposalには載っていないSwift 5のasync/awaitが素晴らしいと思う理論的背景
- Three Stories about Error Handling in Swift | try! Swift Tokyo 2016 (英語・動画)
@asyncHandler
async 関数を使おうと思っても、 async 関数は async 関数の中でしか呼べません。 async 関数を呼び出すエントリーポイントはどうすれば良いでしょうか。
前述のプロポーザルでは、非同期エントリーポイントとして標準ライブラリに beginAsync 関数を追加することが提案されていました。
beginAsync の利用例は次のようになります。これは、ボタンが押されたときに非同期でデータをダウンロードするコードです。
func onButtonPressed(_ sender: UIButton) {
beginAsync {
let data: Data = await download(from: url)
...
}
}
beginAsync は渡されたクロージャを非同期的に実行します(正確には await 以降を suspend します)。そのため、 onButtonPressed は download の完了を待たずに即座に終了します。これによって、 download を UI スレッドがブロックされることなく実行できます。
beginAsync に渡すクロージャの中で async 関数である download を呼び出せるのは、 beginAsync のシグネチャが次のようになっているからです。
func beginAsync(_ body: () async throws -> Void) rethrows -> Void
body に async が付与されているのがポイントです。そのため、 body として渡されるクロージャ式の中で async 関数を呼び出せるわけです。
しかし、↓の PR を見る限り、これとは異なったアプローチが採用されることになりそうです。
この PR は、 Swift に @asyncHandler という Attribute を追加するものです。
@asyncHandler は beginAsync と同じ役割を果たします。 @asyncHandler を使って onButtonPressed を実装すると次のようになります。
@asyncHandler
func onButtonPressed(_ sender: UIButton) {
let data: Data = await download(from: url)
...
}
これは、関数全体を beginAsync で包んだ場合と等価です。
わいわいswiftc #22 では、 beginAsync をやめて @asyncHandler にした理由として、次のようなものが推測されていました。
- ネストを減らせる
- 専用の Attribute を用意することでより細かくコンパイルエラーの原因を診断することが可能となり、適切なエラーメッセージを提示できる
僕が個人的に気に入っているのは、 @asyncHandler を付与した関数は throws が禁止されている点です。これは、 beginAsync から throws と rethrows を取り除き、↓のように変更したことに相当します。
func beginAsync(_ body: () async -> Void) -> Void
僕は以前からそうするべきだと主張していた(↓)のですが、その通りの形にまとまりそうでよかったです。
@asyncHandler で throws が禁止されていることは PR に含まれるテストコードからわかります。
@asyncHandler
func asyncHandlerBad3() throws { }
// expected-error@-1{{'@asyncHandler' function cannot throw}}{{25-32=}}
その他にも、テストコードからは @asyncHandler の戻り値は Void でないといけない、 mutating func には @asyncHandler を付けられないなどの( beginAsync と比較してみると自明な)ルールを読み取ることができておもしろいです。
デリゲートと @asyncHandler
この PR はちょっと変わっていて、 did を単語として含む Obj-C プロトコルのメソッドのうち、 @asyncHandler の条件(戻り値が Void など)を満たすものについて、自動的に @asyncHandler を付与しようというものです。
テストコードでは次のようなプロトコルが挙げられています。
// Objective-C
@protocol RefrigeratorDelegate<NSObject>
- (void)someoneDidOpenRefrigerator:(id)fridge;
- (void)refrigerator:(id)fridge didGetFilledWithItems:(NSArray *)items;
- (void)refrigerator:(id)fridge didGetFilledWithIntegers:(NSInteger *)items count:(NSInteger)count;
- (void)refrigerator:(id)fridge willAddItem:(id)item;
- (BOOL)refrigerator:(id)fridge didRemoveItem:(id)item;
@end
テストコードによると、これらのデリゲートメソッドの最初の二つには @asyncHandler が付与され、後の三つには付与されないようです。
// CHECK-NEXT: @asyncHandler func someoneDidOpenRefrigerator(_ fridge: Any)
// CHECK-NEXT: @asyncHandler func refrigerator(_ fridge: Any, didGetFilledWithItems items: [Any])
// CHECK-NEXT: {{^}} func refrigerator(_ fridge: Any, didGetFilledWithIntegers items: UnsafeMutablePointer<Int>, count: Int)
// CHECK-NEXT: {{^}} func refrigerator(_ fridge: Any, willAddItem item: Any)
// CHECK-NEXT: {{^}} func refrigerator(_ fridge: Any, didRemoveItem item: Any) -> Bool
refrigerator(_:didGetFilledWithIntegers:count:) については(おそらく) items が inout 引数相当なこと、 refrigerator(_:willAddItem:) は did を含まないこと、 refrigerator(_:didRemoveItem:) は戻り値の型が Void でないことが理由だと思われます。
この PR の意図するところは、 did のデリゲートメソッドの中で非同期処理を発火することが多いので、 @asyncHandler が付与されていると async 関数が使えて便利でしょ、ということだと思われます。
しかし、命名を元にこのような処理を施して本当に良いのか、 did だけで will には必要ないのかなど、わいわいswiftc #22 では様々な議論が交わされました。個人的に興味深かった意見をまとめたものが↓です。
-
willについては非同期処理を待たずに後続(のdidなどの)処理が行われるので適さないのではないか。 - でも
didでも繰り返し呼ばれるデリゲートメソッドもあるけど良いのか。 -
@asyncHandlerが付与された関数は通常の関数のスーパータイプになるので、async関数を呼ばないといけないわけではない。なので、雑に付与されても良いのではないか。
completion ハンドラーを持つ Obj-C メソッドを async に
この PR は、 completion ハンドラーを持つ Obj-C メソッドを async メソッドに変換するというものです。たとえば、 UIView の animate メソッドは現在 completion ハンドラーを使って次のように使います。
// BEFORE
func onButtonPressed(_ sender: UIButton) {
UIView.animate(withDuration: 0.5) {
image.alpha = 1.0
} completion: { isFinished in
... // 完了時の処理
}
}
この animate メソッドが async に変換されると、それを利用するコードは次のようになります。
// AFTER
@asyncHandler
func onButtonPressed(_ sender: UIButton) {
let isFinsihed = await UIView.animate(withDuration: 0.5) {
image.alpha = 1.0
}
... // 完了時の処理
}
シグネチャで言うと次のような変化になります。
// BEFORE
class func animate(withDuration duration: TimeInterval,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil)
// AFTER
class func animate(withDuration duration: TimeInterval,
animations: @escaping () -> Void) async -> Bool
テストコードには色々なパターンが挙げられていておもしろいです。
UIView.animate はエラーを発生させない非同期処理でしたが、大抵の非同期処理はエラーを発生させる可能性があります。その場合、たとえば↓のように結果( String )とエラーをそれぞれ Optional で受け取る API が一般的です。
// BEFORE
func doSomethingDangerous(_ operation: String,
completionHandler handler: ((String?, Error?) -> Void)? = nil)
このような場合は、 async throws が付与された関数に変換してくれるようです。
// AFTER
func doSomethingDangerous(_ operation: String) async throws -> String?
この変換前後で、この関数の利用側のコードは次のように変化します。
// BEFORE
doSomethingDangerous("ABC") { value, error in
if let error = error {
// エラーハンドリング
}
use(value!)
}
// AFTER
do {
let value = try await doSomethingDangerous("ABC")
use(value)
} catch {
// エラーハンドリング
}
if や for などの制御構文との組み合わせも簡単になり、便利になりそうです!
わいわいswiftc #22 では他にも次のようなことが話されていました。
-
Resultが導入されたときに、このようなエラーを伴う非同期 API のハンドラーは(Value?, Error?)ではなくResult<Value, Error>を受け取る形に変換されなかったのか?(そうすれば Forced Unwrapping が必要ない。)-
Resultの導入時にはすでにasync/awaitが論じられていたので、それを見越して(Resultが不要な)async/awaitを待ったのではないか。
-
- キャンセラを返すような非同期 API では
asyncにできない。-
Resultのプロポーザルで論じられたURLSession.dataTask( API リファレンス)は結局(Data?, URLResponse?, Error?)を受け取るままだが、この API は(キャンセラに相当する)URLSessionDataTaskを返すので結局asyncにできない。それならResult<(URLResponse, Data), Error>を受け取る形に修正されても良いのでは?
-
actor
マニフェストの Part 1 に当たる async/await だけでなく、 Part 2 以降の actor に関する PR もマージされています。
このことから、 Swift 6 での並行処理は async/await に留まらず、 actor まで含めた広範囲のものになると予想されます。
この actor はアクターモデルの actor で、マニフェストの中でも次のように述べられています。
We propose the introduction of a first-class actor model
アクターモデルについては僕も勉強中なので詳しく説明できませんが、アクターモデルとは多数のアクター同士が非同期のメッセージパッシングによって通信し処理を実行するモデルのことです。
アクターモデルを採用した最も有名なプログラミング言語は Erlang ではないかと思います。ここでは、実際に Erlang を使っているエンジニアから聞いた、 Erlang を学ぶのにオススメの書籍を二冊挙げておきます。前者は Erlang の設計者自身が書いたもので、後者は数年前に発売されて人気を博したものです。
間違いがあるかもしれませんが、僕は Erlang のアクターは次のような特徴を持っていると理解しています。
- Erlang プロセスという軽量なプロセス( OS のプロセスとは別物)がアクターとして振る舞う
- Erlang プロセスはメッセージボックスを持っており、 Erlang プロセス同士がメッセージを送り合うことで通信する
- Erlang プロセスは自身のメッセージボックスから順番にメッセージを取り出し処理を実行する(キューに似ている?)
- Erlang プロセスは共有メモリを持たない(ので共有メモリに由来する問題を引き起こさない)
- Erlang プロセスがクラッシュしても OS のプロセスがクラッシュするわけではない
僕は特に最後の点が興味深く、 Erlang ではエラーハンドリングとしてプロセスをクラッシュさせることが普通に行われると聞いたことがあります。
これに関連することがマニフェストの "Part 3: Reliability through fault isolation" で述べられています。
これまで Swift は Forced Unwrapping の失敗や Array の index out of bounds のエラー、 preconditoon の失敗などの Logic Failure をハンドリングすることができませんでした。 actor の導入によって、これらのクラッシュをプロセスのクラッシュではなく actor のクラッシュとして隔離し、 Logic Failure をハンドリングすることが可能となります。
僕もまだきちんとマニフェストを読めていないですし、 "a few weeks" で正式なドキュメントが出てきそうなのでそれを待ちたいと思いますが、 Swift への actor 導入が本格的に動きそうなので、そろそろちゃんと勉強したいと考えています。前述の Erlang 本も途中まで読んで積読になってるので、それを再開してみるのも良さそうです。
その他の PR
軽く調べて見たところ、昨日わいわい swiftc #22 で話題になった以外にも次のような PR が見つかりました。広範囲の構文が実装されつつあることがわかり、期待が膨らみます。
async/await 関連
- Implement parsing and semantic analysis of await operator #33199
- Implement restrictions on calls to 'async' functions. #33399
- Add support for 'async' closures. #33408
- async autoclosures are only legal on async functions. #33446
- Treat 'await' as a contextual keyword. #33457
- Infer @asyncHandler from protocol requirements. #33488
- Fix nested await/try parsing and effects checking. #33807
- Enable support for @objc async methods #33837
- Allow overload 'async' with non-async and disambiguate uses. #33862
actor 関連
- Actor-isolated members cannot satisfy protocol requirements #33982
- Introduce
@actorIndependentattribute. #33998 - Handle partial application of actor-isolated methods. #34051
まとめ
最近、 Swift リポジトリに async/await や actor といった並行処理関連の PR が大量にマージされています。 Swift Core Team メンバーの発言からも、今後数週間以内に Swift の並行処理に関する正式なドキュメントが出てきそうで、今後急速に話が進むと思われます。 Concurrency Menifesto が出てから 3 年、待ち侘びていた Swift の並行処理関連機能が Swift 6 でついに導入されそうです。
async/await はともかく、 actor は多くの Swift プログラマにとって馴染みの薄い概念だと思います。来年に向けて勉強を進めることが求められるでしょう。しかし、このアップデートによって、 Swift で非同期処理や並行処理を扱うのが劇的に楽になると予想されます。それらを使える未来を考えるとわくわくしますね。 Swift 6 のリリースが待ち遠しいです!
Discussion