プログラミング
ZigのIo.Threadedは巧妙だ
Zig's Io.Threaded Is Neat (matklad.github.io)
要約
Zigの新しいIoインターフェースの実装であるstd.Io.Threadedは、ブロッキングシステムコールを使いながらもキャンセルを完全にサポートするという、ユニークなアプローチで並行処理を実現します。従来の「スレッドを使うだけ」という実装では困難だった、システムコール中の処理を安全に中断させる仕組みを、信号と共有メモリフラグを組み合わせることで実現しており、言語レベルでのキャンセル処理との統合も図られています。
全文翻訳
std.Io.Threadedは、Zigの新しいIoインターフェースの実装の一つで、並行処理を可能にします。これは、単に「スレッドを使う」という、ありふれた実装です。しかし、私は個人的にはこれが巧妙だと感じています。それは、私が長年やりたかった、そして私の知る限り他の誰も適切に行っていない、奇妙なことをやってのけ、予想以上にうまく実装しているからです。Io.Threadedはブロッキングシステムコールを使用し、キャンセルを完全にサポートします。
並行処理 vs 並列処理
@tedinski氏の引用によれば、「並行処理は(非同期、非決定論的な)イベントを扱うこと。並列処理は、ハードウェアリソースを使って同時に多くのことを実行すること」です。この定義は正しいと思いますが、直接的な直感を提供してくれるわけではありません。並行処理は状態遷移関数と同じことでしょうか? はい、明らかにそうですが、それをどうプログラムするかという点ではあまり啓発的ではありません。直感を得るために、私はこれらの2つのリトマス試験紙を好みます。
第一に、並列処理は決定論的、つまり「宣言的」です。
```rust
use rayon::prelude::*
fn sum_of_squares(input: &[i32]) -> i32 {
input.par_iter()
.map(|i| i * i)
.sum()
}
```
問題の独立したパーティションへの分割方法を記述し、一度に1つのパーティションを処理する関数を実装します。プラットフォームの仕事は、その分割が正しい(競合がない)ことを検証し、すべてのパーティションを処理し、それが完了したら制御を戻すことです。
第二に、並行処理には常にキャンセルが伴います。同時に2つの非同期計算が行われている場合、一方の計算が他方の計算がもはや不要であり、積極的にキャンセルされなければならないことを認識する瞬間が来ます。一般的に、もう一方の計算が完了するまで待つことはできません。多くの場合、キャンセルしたい主な理由は、まさにそれが完了できない(例えば、決して受信しないメッセージを待っている)ことを知ったからです。そして、それが「スレッドを使うだけ」の問題なのです。
まあ、他にも問題はありますが、主なものは、多くのスレッドを生成することは完全に可能ですが、そのためにはシステム全体の構成変更が必要になることが多く、ほとんどのアプリケーションではそれは現実的ではありません。しかし、キャンセルの欠如は、遅かれ早かれ壁にぶつからせます。問題はシステムコールです。ループするコードで、次のようなことをするのは十分に簡単です。
```c
while (true) {
if (is_canceled()) return error.Canceled;
/// 簡単!
...
}
```
しかし、スレッドはカーネル内のシステムコールでブロックされており、プログラミング言語のAPIは一般的にそれを解除する方法を提供していません。
```c
const read_size = try read(fd, buffer);
// ???
```
標準的なOSスレッド、ブロッキングAPIを使用し、io_uringのような新しいものを避けつつ、それでもあらゆる作業を確実にキャンセルできるとしたら、それは素晴らしいと思いませんか? それがまさにZigのstd.Io.Threadedが提供するものです。
SIGIO
POSIXでのこの仕組みは、少し呪われています。カーネルは、実際にはブロッキングシステムコールをキャンセルするための間接的な方法、つまりシグナルを提供していることが判明しました。スレッドがカーネルでブロックされているときに、そのスレッドにシグナルが配信されると、スレッドは起動され、システムコールはEINTRを返します。このような場合、システムコールをループして再試行するのが慣例ですが、そうする必要はありません。シグナル自体はキャンセルメカニズムではありません。スレッドへのシグナリングは本質的に競合状態であり、シグナルは関連するシステムコールが開始される前に、または完了した後に配信される可能性があります。逆に、キャンセルとは無関係のシグナルによってシステムコールが中断される可能性があります。実際のプロトコルは、キャンセルするスレッドが共有メモリにフラグを設定してキャンセルを要求し、キャンセラーをループでシグナルし、キャンセルが確認される(共有メモリ内のフラグの異なる値)まで行うことです。システムコールからEINTRを受け取ると、キャンセルされる可能性のあるスレッドはフラグの値を確認し、システムコールを再試行するか、キャンセルを確認してアンワインドを開始します。プロトコルの2つの部分については、signalCanceledSyscallと、例えばfileReadPositionalPosixを参照してください。ユーザー側では、キャンセル要求はerror.Canceledとして具体化されます。エラー管理は、キャンセル、分岐、報告の組み合わせという機能であり、Zigは最初の2つを実装しています。キャンセルは、それが偶然の成功だからエラーではないのではなく、逆に、エラーはペイロードを伴うキャンセルだからです。
Windowsには、はるかに直接的なNtCancelSynchronousIoFile(名前が素晴らしい!)があります。一般的に、ファイバー、IO完了ポート、ジョブオブジェクト、そしてこれらを考えると、NTはUnixよりも優れた並行処理のストーリーを考えているようです。
先行事例
Javaには、同様の見えるスレッド割り込みメカニズムがあります。重要なのは、システムコールの割り込みをサポートしていないことです。IOExceptionとInterruptedExceptionはどちらもチェックされており、無関係であるため、IO処理関数は割り込まれません。Zigでは、リーダーおよびライターインターフェースはエラーを完全に型消去するため、キャンセルをサポートしますが、これには通常のフラッシュを忘れないことに加えて、いくつかの追加の注意が必要です。
pthread_cancelは、同様のシグナル+フラグ機構を実装しています。しかし、言語レベルのキャンセル(try、defer)と統合されていないため、キャンセル後のクリーンアップが煩雑で遅くなります。
より一般的には、並行処理に関する多くの苦悩は、カーネル、ランタイム、言語の狭間にあるという事実に起因しています。CPU上には(割り込みを除いて)ほとんど並行処理はなく、それは混合された著者による幻想です。言語は通常、この問題に取り組むのに最も適していますが、伝統的にはカーネルとlibcによって処理されており、言語設計に悪影響を与えています。pthread_cancelのもう一つの問題は、スレッド全体を破棄することですが、スレッドが安価であれば問題ないでしょう。しかし、スレッドの作成は依然として遅く、スレッドの構成済みシステム制限は通常低いため、OSスレッドをプールするのが良い考えであることがよくあります。
ZigのIoは、インターフェースレベルで「並行して実行できる」と「並行して実行しなければならない」を分離することで、この問題を巧妙に解決しています。
https://kristoff.it/blog/asynchrony-is-not-concurrency/
これは、std::launchポリシー(Effective Modern C++の項目36、もし手元にあれば)に似た効果を達成します。何が起こっているのか(io.async vs io.concurrent)を命名することで、Zigは実際に何が起こっているのかを理解しやすくし、より正確なシグネチャ(concurrentは常にフォールブルであり、asyncは決してそうではない)を得ることができます。もちろん、concurrentはスレッドプールによってバックアップされており、プールが枯渇した場合にのみ新しいスレッドの生成にフォールバックします。