本日は、調べものをしているときに見つけて気になり読み進めている Concurrency
による並行処理と セマフォ
についての技術ブログの最後のところ。ひと通り実装を終えてみての著者の所感のところを見てみたり、全体を踏まえて所感をみんなと交換してみたり、そんな今回注目した記事についての余韻みたいな感じの回にしてみますね。どうぞよろしくお願いします。
—————————————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #204
00:00 開始 00:19 これまでにした実装のあらすじ 03:39 良い技術ブログだった印象 04:41 これまでの実装には足りないところ 05:11 開放時に待ち状態のセマフォがあるとき 06:33 CancellationError の定義場所 08:52 エラーを扱うには withCheckedThrowingContinuation 10:35 キャンセル時の対応を大切 11:37 Task.value をキャンセルしてみる 14:50 ビジーウェイトは論外? 15:34 Task を用いた試行錯誤 17:52 スリープを含んでビジーウェイト? 18:17 カーネルとスピンロック 19:15 ループしながら制御を戻す 20:58 ここでのキャンセルのチェックは必要 21:59 ブログとしてのまとめ 23:29 クロージングと次回の展望 ——————————————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #204
はい、始めていきますね。ずっと見てきていたSwift Concurrencyでセマフォを作るブログの最後のほうを見ていく感じですかね。前回まででどんなふうに実装していくか、実装が完成したところまで見ました。今回は、要因的な事態を通してどんな雰囲気かみたいな話ができたらいいのかなという気持ちで進めていきましょう。
まず、ブログの著者の方の感想などがあるので、それに注意しつつ話を進めていきます。前回のコードを軽くおさらいしておきます。今回はコンカレンシーを使ったセマフォを作るというテーマで、その中でコンティニュエーションを活用したセマフォを作るという内容です。アクターを使って、ウェイト中の待ち行列とリソースのキャパシティー、つまり同時アクセスできる数を管理します。これによりスレッドセーフ、つまりコンカレンシー的な安全性をアクターで保ちながら実装していきます。
基本的な流れは、まず初期化(イニシャライズ)して、カウントを設定します。そして、ウェイトとシグナルの2つだけを実装したシンプルなセマフォを作ります。具体的には、ウェイトの中ではカウントを減らし、その後待つ必要がある場合にコンティニュエーションを待ち行列に確保します。コンティニュエーションはセンダブルなので、問題なくパスを覚えて後回しにできます。アクターを使うことで安全性が保たれます。
ウェイトの実装が完成したら、次はシグナルを実装します。カウントを増やし、待ち行列がある場合にはその待ち行列をリジューム(再開)させます。この仕組みでセマフォが作動するわけです。
注意事項などもいくつかありますので、その点も確認していきます。ブログの筆者によると、このコードはまだ未解決の問題がいくつかあります。実用的ではない部分やテストされていない部分があるので、万全を期すための調整が必要です。
具体的な未解決問題としては、セマフォが解放されるときにウェイターが残っている場合にどうするか、つまりインスタンスが解放されるときに待っているタスクをどう処理するかということです。これはエラーハンドリングや、タスクのキャンセルを行うことで対応できるかもしれません。
例えば、Xcode
で以下のように書いてみます。
if !waitingQueue.isEmpty {
for waiter in waitingQueue {
waiter.resume(throwing: CancellationError())
}
}
キャンセルさせるタスクのためのエラー処理や、スリープのような感覚で扱うと、シンプルかつ効果的に対応できるでしょう。これにより、セマフォの解放時にも安全に対応できるようになります。
他にも、標準ライブラリに存在するエラーを活用することで、タスクのキャンセルが発生した場合の処理をきちんと行うことができます。それでは、続きを進めていきましょう。 とりあえずこのように用意しておいたとします。これでキャンセレーションエラーをユニットに使わせることができるでしょうか。例えば以下のように渡してあげると、あとはawait
でシンクさせて、それでtry await
にしてみます。
try await someFunction()
try await
にしたら、Never
を普通のエラーに変換することができます。このようにcatch
で例外を捕えます。try await
でシンクして、スローイング関数にすると良いかもしれません。
do {
try await someFunction()
} catch {
// エラーハンドリング
}
これでコンパイルがパスするか見てみます。まだNever
と言われているので何か間違っているかもしれません。checkCancellation
という関数はNever
を返すのか確認します。そうですね、スロー用に例外を投げる場合はcheck
やthrowingContinuation
を使う場合があります。これで上手くいくかもしれません。適切にエラーを処理して、キャンセルされるとエラーハンドリングで対応する形が作れそうですね。
キャンセルはawait
を中断させたい場合などに使います。一般的にはタイムアウトやキャンセル機能が提供されていることがありますが、手元ではあまり使ったことがないかもしれません。例えばタイムアウトして処理を行うことでリソースを解放する場合などです。実際に使ってみて初めて実用的な課題を確認することが多いでしょう。
キャンセルする時の注意点として、コードがキャンセル状態を意識している必要があります。そのため、スロー用の例外処理が効果的かもしれません。そういう形でアイデアを取り込む余地がありますね。
次にタスクの話です。特定のタスクが終わるのを待つためには、タスクのvalue
プロパティを監視できます。ただし、タスクを途中で止める方法はないようです。このため、タスクがキャンセル可能かどうかを確認する必要があります。例えば以下のようなコードでタスクをキャンセルすることができます。
let task = Task {
try await Task.sleep(nanoseconds: 5_000_000_000)
}
task.cancel()
do {
try await task.value
} catch {
print("Task was cancelled")
}
このようにしてタスクをキャンセルするとどうなるか確認できます。出力を見ると2秒ぐらいでキャンセルされていることがわかります。タスクのvalue
を取得しようとするとキャンセルされるので、適切な方法でタスクをキャンセルできることがわかります。
ビジーウェイトはもちろん論外です。CPUを無駄に消費するので、適切な方法でタスクを待つ必要があります。セマフォや他の同期手段を使うべきです。タスクを止める場合でもスムーズに動作するように設計することが重要です。 とりあえず、ビジーウェイトについて読み進めましょう。元のタスクについては数行だけのつもりでしたが、その数行がどこか理解しづらかったです。タスクのドキュメントを読むとビジーウェイトに少し似ている方法が書かれているようです。
タスクには実行されたタスクとキャンセルできる機能、そしてそれらを制御する機能が組み合わされています。これはコンティニュエーションの使い方が想定されているものだと思います。ちょっと文章が理解しにくくなってきましたが、読み進めていきましょう。
以下のタスクは、セマフォが絡んできていますが、少し理解しづらい部分があります。ただ、次のコードが勉強になるので紹介しておきます。
let task = Task {
var value = await someFunction()
// タスクの処理
}
task.cancel()
コンティニュエーションの代わりにタスクを持ち、行列に使うという話です。タスクがキャンセルされるまでスリープさせることで、ブロックをかけるという方法です。その間、タスクをスリープしながら回していき、最終的にキャンセルを呼び出すことでブロックを解除しますということです。
ビジーウェイトに少し似ていると書かれていますが、スリープが入ればビジーウェイトとは違うのかどうか?という問いが生じます。デーモンプログラムを作る場合、無限ループプラススリープで待つのは一般的ですよね。でも、ビジーウェイトとは、完全にスリープなしのものを指すことが多いようです。
スピンロックは完全に無限ループで回るものです。スリープを挟むという点でビジーウェイトとは違います。スリープを入れることでOSに制御を一旦渡す感じになります。
私の感覚では、ビジーウェイトをやる際にはスリープを挟むことが普通です。何も入れないとメッセージを受け取れなくなります。WindowsでMFCを使っていた時代、定期的にチェックする必要があると書かれていたからです。
ビジーウェイトもスリープを入れることで、論外ではなく、有効に使える場面があります。しかし、スリープを使わない方法を押している人もいるでしょう。
この辺りで実装の構成が変わってきました。既存のコードをテキストコンティニュエーションと非同期ストリームで書き直すことを意識しましょう。コンカレンシーが活用できる場合も多いです。
すべてのAPIを詳しく理解し、場合によっては使えないものもあるでしょう。いくつかの要因を考慮しつつ、目的に沿った実装ができると良いと思います。
今回はここまでとします。お疲れ様でした。ありがとうございました。