LoginSignup
55
44

More than 5 years have passed since last update.

プロポーザルAsync/Await for Swiftの解説と感想

Last updated at Posted at 2018-06-03

はじめに

最近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でも機能する
  • 他の言語の設計とは異なり、Futurechannelsなどには依存しない
    • ライブラリとして構築はできる
  • 暗黙的に生成されたクロージャを変換および操作するためのコンパイラサポートロジックのみが必要
  • このプロポーザルの提案者など
  • インスピレーションを受けたプロポーザルの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にはFuturePromiseが使われているという話やその解説を行ってきましたが、Futureは、このプロポーザルでは言語機能としてサポートしないということを述べているのが興味深いところです。

しかし、Futureの必要性がないわけではなく、Futureを実装するならどうするか、やFutureをサポートしない理由もこのプロポーザルに含まれています。

Motivation: Completion handlers are suboptimal

Problem 1: 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)が導入された
    • コールバックベースのインターフェイスではその利点がない
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)
}
  • これを段階的に値を生成するなら
    • AnyIteratorsequence(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)や非同期モデルその他の機能を実装するのに使用できる
  • プロポーザルでは
    • 完了ハンドラの多くの問題を排除
    • 最も一般的なユースケースに向かって命名法と用語をバイアスする
  • asyncyieldsかはコアセマンティクスには関係しない
    • 末尾の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キーワードでマークする
    • プロポーザルに示した例はasyncawaitで次のようになる
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
  • 次に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と関係ないよってのことが書いてある
  • Alternate Syntax Options
  • Alternatives Considered
  • Potential Future Directions
55
44
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
55
44