【翻訳】Swift Concurrency Deep Dive [2] — Continuation
この投稿は Swift の並行処理、つまり async/await をより深く理解するために書かれています。
Apple Developer's Document、Swift-evolution リポジトリ、 Swift Language Guide のような信頼できる情報源から可能な限り情報を集めましたが、間違った情報が含まれているかもしれません。その場合は、コメントで教えてください。
私の以前の記事を読むことを強くお勧めします。
【以前の記事】
Swift Concurrency Deep Dive [1] — GCD vs async/await
キーワード
GCD, full thread context switching, continuation, structured concurrency
continuation とは何か?
continuation の概念
ウィキペディアの言葉を引用すると、 continuation はプログラムの制御状態を実装(再定義)します。 continuation は、プロセスの実行のある時点における計算プロセスを表すデータ構造です。
ここで紹介するメタファーは「 continuation のサンドイッチ」です。 continuation の概要を簡単に理解することができるでしょう。
キッチンの冷蔵庫の前でサンドイッチのことを考えているとします。あなたはその場で continuation を取り、ポケットに入れます。そして冷蔵庫から七面鳥とパンを取り出し、サンドイッチを作ります。
あなたはポケットの中の continuation を呼び出し、再び冷蔵庫の前に立ってサンドイッチのことを考えている自分に気づきます。しかし幸いなことに、カウンターの上にはサンドイッチがあり、それを作るのに使った材料は全てなくなっています。だからあなたはそれを食べます。
サンドイッチはデータの一部であり、サンドイッチを作ることはプログラムがデータに対して行うことだと考えることができます。この文脈では、 continuation はサンドイッチを作る直前を保存するセーブポイントのようなものです。
continuation に戻ると、サンドイッチを作る直前に保存した時点から作業を再開できます。冷蔵庫は continuation に含まれないので、材料に関する状態は保存されていません。
簡単に言うと、 continuation はプログラムの実行状態を共有スペースに保存し、どこからでも呼び出せるようにするインターフェースです。
詳細情報
これは continuation-passing (https://en.wikipedia.org/wiki/Continuation-passing_style) と呼ばれます。これを使えば、現在の continuation を別の関数に渡して、そのコンテキストの中で次のタスクを処理させることができます。 Scheme や Coroutine などのプログラミング言語がこの機能を持っていることが知られています。
continuation を実装する方法はいくつかあります。このサイトでもいくつか紹介しているので、よかったらチェックしてみてください。
Continuation in Swift
Swiftは状態データ、つまり各サスペンションポイントで使用される continuation を保存するために heap を使用します。 continuation は Swift では非同期フレームとも呼ばれ、キーワード awaitによって設定することができます。
非同期メソッドが行うように stack のみを使用するよりも、非常に強力な利点があります。 stack に格納されたデータとは異なり、 heap に格納された情報は、関数が停止または返された場合でも保持することができます。
手動で continuation を作成するメソッドを提供する Swift のネイティブ機能
(Un)CheckedContinuation をもう少し深く掘り下げてみましょう。
import Foundation
func doSomethingWithDelay(completion: @escaping () -> Void) {
sleep(1)
completion()
}
Task {
await withCheckedContinuation { continuation in
doSomethingWithDelay {
print("Completed")
continuation.resume()
}
}
}
(Un)CheckedContinuation の目的は元々、伝統的な非同期コード、クロージャ完了ハンドラと Swift の並行処理を組み合わせることができる API を提供することでした。
完了ハンドラは Swift の並行処理のコンテキストにないため、 Swift はその戻り値をどのように扱うか分かりませんでした。そのため、サスペンド状態から戻る瞬間を手動でキャッチして報告する必要があります。そのために、インスタンスメソッド resume を使うことができます。
thread と continuation の関係
我々は、 Swift が heap にプログラム(continuation)の実行状態を保存することを知っています。ではなぜ?
そもそもなぜ heap が必要だったのかに戻ると、全てのスレッドで共有されるスペースが必要だったからです。
今回も同じです。 heap 共有の continuation とは、常に空になる危険性のある stack 保存データから脱却し、どこからでもアクセス可能で永続的な共有データをシステムが持ち、提供できることを意味します。
これによって、プロセスの再入可能性が保証されます。必要なのは、メモリからデータをロードすることだけです。
その結果、前述の「関数を呼び出すコスト」を可能にし、「完全なスレッドコンテキストの切り替えはない」と言っているのと同じことになります。
スレッドをブロックしてはいけません。なぜか?
Swiftが私たちのために並行処理に関する多くのことを管理してくれているとはいえ、私たちが守るべきルールがいくつかあります。そのひとつが「スレッドをブロックしない」ことです。
import Foundation
let lock = NSLock()
func doSomething(_ num: Int) async throws {
Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("Job: \\(num)", Thread.current)
lock.unlock()
}
}
// main
Task {
for i in 0..<100 {
lock.lock()
try await doSomething(i)
}
}
Swiftの並行処理では、 NSLock や DispatchSemaphore のような、並行処理を行うために使ってきたスレッドブロックメソッドが使えません。上の例は WWDC のセッションで紹介されたデッドロックが発生する典型的なコードです。
上記のコードの意図は以下の通りです。
-
メイン部分では、 doSomething を100回実行します。 doSomething は非同期メソッドなので、 await でブレークポイントを設定する必要があります。
-
メソッドは同期的(逐次的)に実行させたいので、グローバルに共有されている NSLock を使ってアクセス可能かどうかをチェックします。
-
doSomething は、タスクを 1 秒間一時停止し、 Task を使用して Swift の同時実行コンテキスト内で print を実行する単純な関数です。
-
Task のブロックが終了すると、ロックは解除され、次のタスクがアクセスできるようになります。
しかし、前述のように、上記のコードはデッドロックを引き起こし、ログを出力しません。
なぜか? それは、 Task メソッドが非構造化並行処理でブロックをディスパッチするからです。これは、構造化された並行性と Swift の並行性の Task を理解する必要があります。次回の記事でそれを見ていきましょう。
【翻訳元の記事】
Swift Concurrency Deep Dive [2] — Continuation
Discussion