はじめに
最近Swift用のasync/await本を書いていまして、その発端ともなった、クリス・ラトナーさんのプロポーザルAsync/Await for Swift
の下書きについて解説を加えているのですが、なんかもう、そのモチベーションが全然保てないわけです。一度ざっくりと理解してしまったことについて解説するのはしんどい。もっとよくわからん段階で解説文を書いておけばよかったなあと思っています。
解説するモチベーションの低下、そしてそもそも正しく理解できてるかもわからないし、解説すること必要?っていう話と、そしてその解説方法って感想が入りまくってて読みづらくない?と思っていてもやっているので、その一部分を公開しようと思ってます(なので、文中に「本書」などと出てきたりするのはこの文章が本の一部だからです)。
プロポーザルの下書きはgistで公開されています。
Async/Await for Swift
https://gist.github.com/lattner/429b9070918248274f25b714dcfc7619
この文章ではリビジョン 4 Sep 2017を元にしています。
解説する項目は前半の項目です
- Introduction
- Motivation: Completion handlers are suboptimal
- Proposed Solution: Coroutines
以降、箇条書きで翻訳したものとその解説を書いていきます。もし、俺はこう解釈したぜというご意見があれば、コメント欄でもtwitterでもその意見を教えていただければと思います。
Async/Await for Swift
Introduction
- モダンCocoa開発にはクロージャとコンプリートハンドラを使用した多くの非同期プログラミングが必要だがこれらのAPIは使いにくい
- 何が問題?
- 複数の非同期操作が使用されている場合
- エラー処理が必要な場合
- 非同期呼び出し感の制御フローが複雑になる場合
- 何が問題?
- このプロポーザルはこれをより自然で、間違いを起こりにくくする
- このプロポーザルではSwiftのコルーチンモデルを紹介する
- 関数を
async
にすることで- プログラマは非同期操作を含む複雑なロジックを作成し
- コンパイラはそのロジックを実装するために必要なクロージャとステートマシンを生成します
- 関数を
- 重要なことは
- 完全に同時実行時ランタイムに依存しないコンパイラのサポートを提案していることを理解すること
- 新しいランタイムモデル(
Actor
など)は含まれていない - pthreadや他のAPIと同じようにGCDでも機能する
- 他の言語の設計とは異なり、
Future
やchannels
などには依存しない- ライブラリとして構築はできる
- 暗黙的に生成されたクロージャを変換および操作するためのコンパイラサポートロジックのみが必要
- このプロポーザルの提案者など
- インスピレーションを受けたプロポーザルのgist
- Oleg Andreevさんの gist
解説
コルーチンとは何かということについては、このプロポーザルで徐々に書かれています。しかし、このプロポーザルではSwiftのコンパイラレベルでサポートするコルーチンとは何かであって、読者としてはまず一般的なコルーチンとは何かをざっくりとでも知りたいことでしょう。
wikipediaからコルーチンのページについて引用します
コルーチン(英: co-routine)とはプログラミングの構造の一種。サブルーチンがエントリーからリターンまでを一つの処理単位とするのに対し、コルーチンはいったん処理を中断した後、続きから処理を再開できる。接頭辞 co は協調を意味するが、複数のコルーチンが中断・継続により協調動作を行うことによる。
サブルーチンと異なり、状態管理を意識せずに行えるため、協調的処理、イテレータ、無限リスト、パイプなど、継続状況を持つプログラムが容易に記述できる。
コルーチンはサブルーチンを一般化したものと考えられる。コルーチンをサポートする言語には Modula-2、Simula、Icon、Lua、C#、Limbo などがある。マルチスレッドで原理的には同じことができるため、現在はそちらが使われるケースが多い。これはマルチスレッドであれば直接OSの支援を受けられることや、エントリー/リターンの構造を変えずにコードを多重化できるので、過去の言語との親和性が良いなどが理由である。ただし、マルチスレッドの場合プログラマが同期制御を行わなければならないので、コルーチンのような簡易さはない。
コルーチンという名称は、メルヴィン・コンウェイの1963年の論文が起源である。
以上を踏まえてプロポーザルに戻ると、「コンパイラはそのロジックを実装するために必要なクロージャとステートマシンを生成します」とあります。ステートマシンを生成するとは何か、どういうことかということについては、Kotlinでのコルーチンサポートが理解の手助けになってくれるでしょう。
Kotlinのasync/await
については id:sys1yagi さんのブログ記事 Kotlin 1.1 async/awaitの仕組みと使い方の概要 for Android - visible trueが参考になります。
Kotlin 1.1ではコルーチンの処理全体を「状態」の集まりとしてステートマシンに変換することで、JVM上での動作を実現しています。
コルーチンのコードをステートマシンとして解釈するために、コルーチンの処理を表すcoroutineキーワードと、中断点を示すためのsuspendキーワードが追加されています。これらのキーワードによって「コルーチンの実装」を言語機能としてサポートします。
Kotlin 1.1で提供されるasync/awaitやジェネレータは、コルーチンの実装としてライブラリの形式で提供されます。
つまり、このプロポーザルではKotlinと同じようにコルーチンの処理を「状態」として、その状態を処理ごとに分割し、コルーチンによる処理の停止や再開といった動作をさせるようにする、ということだと理解できます。
また、本書ではさまざまな言語でasync/await
にはFuture
やPromise
が使われているという話やその解説を行ってきましたが、Future
は、このプロポーザルでは言語機能としてサポートしないということを述べているのが興味深いところです。
しかし、Future
の必要性がないわけではなく、Future
を実装するならどうするか、やFuture
をサポートしない理由もこのプロポーザルに含まれています。
Motivation: Completion handlers are suboptimal
Problem 1: Pyramid of doom
- 連続した処理はネストしたブロックで不自然に構成されてしまう
- このPyramid of doomは実行中のコードを追跡するのを困難に
- 他の効果も引き起こしてしまう
- このPyramid of doomは実行中のコードを追跡するのを困難に
- Pyramid of doomの例は次の通り
func processImageData1(completionBlock: (result: Image) -> Void) {
loadWebResource("dataprofile.txt") { dataResource in
loadWebResource("imagedata.dat") { imageResource in
decodeImage(dataResource, imageResource) { imageTmp in
dewarpAndCleanupImage(imageTmp) { imageResult in
completionBlock(imageResult)
}
}
}
}
}
processImageData1 { image in
display(image)
}
Problem 2: Error handling
- エラーを処理することは困難で冗長になる
- Swift 2では、同期コードのエラー処理モデル(
guard
)が導入された - コールバックベースのインターフェイスではその利点がない
- Swift 2では、同期コードのエラー処理モデル(
func processImageData2(completionBlock: (result: Image?, error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
completionBlock(nil, error)
return
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
completionBlock(nil, error)
return
}
decodeImage(dataResource, imageResource) { imageTmp, error in
guard let imageTmp = imageTmp else {
completionBlock(nil, error)
return
}
dewarpAndCleanupImage(imageTmp) { imageResult in
guard let imageResult = imageResult else {
completionBlock(nil, error)
return
}
completionBlock(imageResult)
}
}
}
}
}
processImageData2 { image, error in
guard let image = image else {
error("No image today")
return
}
display(image)
}
解説
guard
を非同期処理のクロージャごとに呼び出して完了クロージャを呼び出すコード、みんなも書いたことあるよな。って感じ、それがasync/await
では必要なくなるよということですね。
Problem 3: Conditional execution is hard and error-prone
- 条件によって非同期関数を実行することはかなり苦痛
- これに対するおそらく最善の方法はヘルパーとしてクロージャを定義して書いておく
- 下記がその例
- これに対するおそらく最善の方法はヘルパーとしてクロージャを定義して書いておく
func processImageData3(recipient: Person, completionBlock: (result: Image) -> Void) {
let continuation: (contents: image) -> Void = {
// ... continue and call completionBlock eventually
}
if recipient.hasProfilePicture {
continuation(recipient.profilePicture)
} else {
decodeImage { image in
continuation(image)
}
}
}
Problem 4: Many mistakes are easy to make
- blockを呼び出したあとにreturnしないと問題をデバッグするのは難しい
- 下記がその例(しかし先回りして
guard
について書いてる)
- 下記がその例(しかし先回りして
func processImageData4(completionBlock: (result: Image?, error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
return // <- forgot to call the block
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
return // <- forgot to call the block
}
...
}
}
}
-
guard
によってreturn
忘れを保護してくれる- ただし、必ずしも適切かというとそうでもない
- 下記がその例
- ただし、必ずしも適切かというとそうでもない
func processImageData5(recipient:Person, completionBlock: (result: Image?, error: Error?) -> Void) {
if recipient.hasProfilePicture {
if let image = recipient.profilePicture {
completionBlock(image) // <- forgot to return after calling the block
}
}
...
}
解説
ここの部分、先にguard
の話だしてるので分かりづらいですね。まず、return
忘れたらつらいよねっていう話があって、そこから、guard
が使えるときはreturn
忘れようがないからそのときは話し別だよねっていう持っていきかたのほうが自然な気がします。
Problem 5: Because completion handlers are awkward, too many APIs are defined synchronously
- 完了ハンドラを利用した非同期APIを定義して使用することでAPIをブロックすることができても
- それは同期的動作で定義されていると考えている
- UIアプリケーションで問題となるパフォーマンスと応答性の問題につながる可能性がある
- 例えばサーバのように、非同期性がスケールを達成するために重要である場合には使用できないAPIの定義につながる可能性がある
- それは同期的動作で定義されていると考えている
解説
ここはわかったようなわからんような感じで、具体例がほしいところです。APIをブロックするとは?パフォーマンスと応答性の問題とは具体的に?という感じですね。
Problem 6: Other "resumable" computations are awkward to define
- 計算の再開について自乗のリストを生成するコードを書く場合を例に
for i in 1...10 {
print(i*i)
}
- これを段階的に値を生成するなら
-
AnyIterator
やsequence(state:,next:)
がある- しかしそれらは命令形のような明快さと自明性がない
-
- 対照的に、ジェネレータ(
yield
)を持つ言語では、これに近いものを書くことができる
func getSequence() -> AnySequence<Int> {
let seq = sequence {
for i in 1...10 {
yield(i*i)
}
}
return AnySequence(seq)
}
解説
ここで述べられているyield
について解説すると、Kotlinのそれに近いものだと思います。例えばAnySequence<Int>
がnext
メソッドを持っているとすると、その利用例からこのyield
の動きは次のようになるものでしょう。
let sequence = getSequence()
sequence.next() // 1 * 1 が計算される
sequence.next() // 2 * 2 が計算される
sequence.next() // 3 * 3 が計算される
sequence.next() // 4 * 4 が計算される
sequence.next() // 5 * 5 が計算される
また、おそらく戻り値としてKotlinと同じようにyieldの結果が返されると考えられます。
let sequence = getSequence()
print(sequence.next()) // => 1
print(sequence.next()) // => 4
これはプロポーザルに書いてある内容ではなく、あくまで私の想像です(プロポーザルにnext
メソッドのようなことを書いてほしい)。
Proposed Solution: Coroutines
- コルーチンの抽象化は問題解決のための標準的な方法
- コルーチンは
- 関数が値を返すか、中断することを可能にする
- 基本関数の拡張
- ジェネレータ(
yield
)や非同期モデルその他の機能を実装するのに使用できる
- プロポーザルでは
- 完了ハンドラの多くの問題を排除
- 最も一般的なユースケースに向かって命名法と用語をバイアスする
-
async
かyields
かはコアセマンティクスには関係しない- 末尾のalternate-syntax-optionsで言及する
- 提案するコルーチンモデルはインターフェースではない
- コンプリートハンドラのシンタックスシュガーと考えることができる
- コルーチンの導入によって完了ハンドラが呼び出されるキューは変更されない
Async semantics
- 関数型は
throw
できる - プロポーザルでは
async
できるように拡張する- そうなると関数型は次のようになる
(Int) -> Int // #1: Normal function
(Int) throws -> Int // #2: Throwing function
(Int) async -> Int // #3: Asynchronous function
(Int) async throws -> Int // #4: Asynchronous function, can also throw.
- 通常の関数#1が
throws
する際に#2に変換される- 同様に、#3は明示的に#4になる
- 関数宣言時の
async
キーワードは次の通り
func processImageData() async -> Image { ... }
// Semantically similar to this:
func processImageData(completionHandler: (result: Image) -> Void) { ... }
-
async
な関数は、コルーチンの中断をすることができる - 明示的にするには
await
キーワードでマークする- プロポーザルに示した例は
async
とawait
で次のようになる
- プロポーザルに示した例は
func loadWebResource(_ path: String) async -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async -> Image
func dewarpAndCleanupImage(_ i : Image) async -> Image
func processImageData1() async -> Image {
let dataResource = await loadWebResource("dataprofile.txt")
let imageResource = await loadWebResource("imagedata.dat")
let imageTmp = await decodeImage(dataResource, imageResource)
let imageResult = await dewarpAndCleanupImage(imageTmp)
return imageResult
}
-
await
関数が呼び出せる条件-
async
関数か別の(非同期?)クロージャから呼び出せる
-
-
do/catch
ブロックの内部にないとthrow
関数が呼び出せないのと同じ
解説
ここらへんの訳と解釈についてはかなり自信がありません。原文を読むと、async
関数を呼び出せる条件が書いてあって、await
について書いてないように思えるんですが、文脈やdo/catch
の話からawait
の条件を書いているように思えます。
Entering and leaving async code
- 非同期コンテキストの入力と中断を可能にするためにいくつかのプリミティブが必要
func beginAsync(_ body: () async throws -> Void) rethrows -> Void
func suspendAsync<T>(
_ body: (_ continuation: @escaping (T) -> ()) -> ()
) async -> T
func suspendAsync<T>(
_ body: (_ continuation: @escaping (T) -> (),
_ error: @escaping (Error) -> ()) -> ()
) async throws -> T
- beginAsync
- 非同期コルーチンを開始し、制御を
body
に移動する
- 非同期コルーチンを開始し、制御を
- suspendAsync
- 現在の非同期タスクを一時停止し、タスクの継続終了とともに
body
を呼び出す -
continuation
を呼び出すとコルーチンを再開する -
continuation
が複数回呼び出されるのは致命的なエラー
- 現在の非同期タスクを一時停止し、タスクの継続終了とともに
- IBActionでの利用例としてまずは完了ハンドラでの例を示す
@IBAction func buttonDidClick(sender:AnyObject) {
// 1
processImage(completionHandler: {(image) in
// 2
imageView.image = image
})
// 3
}
- 完了ハンドラでの例では実行順序は次のようになる
- #1
- #3
- #2
- #3
- #1
- 次に
beginAsync
を使った例を示す
@IBAction func buttonDidClick(sender:AnyObject) {
// 1
beginAsync {
// 2
let image = await processImage()
imageView.image = image
}
// 3
}
- コールバックベースのAPIを非同期コルーチンAPIとしてラップすることを可能にする
// Legacy callback-based API
func getStuff(completion: (Stuff) -> Void) { ... }
// Swift wrapper
func getStuff() async -> Stuff {
return await suspendAsync { continuation in
getStuff(completion: continuation)
}
}
- libdispatchやpthreadsなどの同時実行ライブラリの機能
- コルーチンに優しい方法でも提供される
extension DispatchQueue {
/// Move execution of the current coroutine synchronously onto this queue.
func syncCoroutine() async -> Void {
await suspendAsync { continuation in
sync { continuation }
}
}
/// Enqueue execution of the remainder of the current coroutine
/// asynchronously onto this queue.
func asyncCoroutine() async -> Void {
await suspendAsync { continuation in
async { continuation }
}
}
}
func queueHopping() async -> Void {
doSomeStuff()
await DispatchQueue.main.syncCoroutine()
doSomeStuffOnMainThread()
await backgroundQueue.asyncCoroutine()
doSomeStuffInBackground()
}
-
func syncCoroutine() async -> Void
で現在のコルーチンの実行を同期してこのキューに移動する- 例ではメインキューで実行しているのでメインキューになる
-
func asyncCoroutine() async -> Void
で現在のコルーチンの残りの部分をこのキューに非同期的にエンキューする- 例ではバックグラウンドキューで実行しているので残りの処理がバックグラウンドキューにエンキューされる
(この例は分かりづらいし解釈に自信がありません)
-
Future
のようなコルーチンを調整するための一般化された抽象化も構築することができる -
Future
は- まだ解決されていない未来の値を表す値
-
Future
の正確なデザインはこの提案の対象外- 対象外だけど実装例を示す
(本書ではFuture
の実装例を省略します)
- 示した
Future
の実装例はパフォーマンスとAPIの脆弱性がある- 抽象化を
async/await
の上にどのように構築するかのスケッチ
- 抽象化を
-
Future
はパラレル実行を可能にする- パラレルコールを個々の
Future
オブジェクトにラップする
- パラレルコールを個々の
func processImageData1a() async -> Image {
let dataResource = Future { await loadWebResource("dataprofile.txt") }
let imageResource = Future { await loadWebResource("imagedata.dat") }
// ... other stuff can go here to cover load latency...
let imageTmp = await decodeImage(dataResource.get(), imageResource.get())
let imageResult = await dewarpAndCleanupImage(imageTmp)
return imageResult
}
- この例では
- 最初の2つの操作が順番に開始
- 未評価の計算は
Future
の値にラップされる - (decodeImage)関数は画像をデコードする前にその完了を持つ
解説
最初の例は処理を置き換えるというかレガシーなインターフェースを同期的に書き換えることができますよという例ですね
おわりに
プロポーザルにはまだ残りがあるので、それについては日を改めたいですね...
- Conversion of imported Objective-C APIs
- あんまり興味がないなあ
- Source Compatibility
- まあそこまで興味ないなあ
- Effect on ABI stability
- ABIについては
async/await
と関係ないよってのことが書いてある
- ABIについては
- Alternate Syntax Options
- Alternatives Considered
- Potential Future Directions