Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

『詳解Rustアトミック操作とロック』(Rust Atomics and Locks)

昨年買っていたんですが、年末年始の時間を使って少し読めました。

著者はRustコンパイラにコントリビューションをしたことがあれば誰でも知っているかもしれない、Mara Bos氏です。

ちなみにですが、原著は下記サイトで無料でも読むことができます。

marabos.nl

書籍は下記です。

なおこの記事内で「本書」と明記する場合、それは『詳解Rustアトミック操作とロック』を指します。また、「筆者」は私自身のことであり、「著者」はMara Bos氏のことです。

内容のメモ

読んだ内容のうち、印象に残ったり初見だったものをメモしておきます。

1章

1章は主にはRustの並行性を理解するために必要なツールと概念が説明されます。具体的には、スレッド、Mutex、スレッドの安全性、内部可変性などです。TRPLよりさらに深く説明されるため、なるほどそういう意味があったのかと思う箇所がいくつかありました。

Mutexのpoisoning周りの説明と、ならびにMutexGuardの説明はとくに勉強になりました。MutexGuardの説明の中で、次のコードは一見すると結果が同じに見えるものの、実はボローチェッカーの挙動が異なるせいで挙動が変わるらしいというのを知りました。

if let Some(item) = list.lock().unwrap().pop() {
    process_item(item);
}

if list.lock().unwrap().pop() == Some(1) {
    do_something();
}

後ろのコードから説明しますが、後者のコードは後続の処理内で何かリストから取り出した要素を使用したりしません。返す値は常にboolであり、何もそこから借用しません(コピーセマンティクスです)。したがって所有権を気にする必要がなく、また一時変数の生存期間を気にする必要がありません。これにより、一時変数のガードはif文が実行される前にドロップされます。つまり、ロックは条件式を抜けると解除されます。

一方前者のコードでは、一時変数itemは(後続のブロック内で)借用の可能性があるため、生存期間をif文の最後まで延長する必要があります。仮に借用が一度も起こらなかったとしても、ボローチェッカーはドロップするタイミングや順番には影響を与えず、生存期間のチェックだけは行なってしまいます。これにより借用した場合と同じように見かけ上は動作してしまうらしいです。ロックは解除されません。

上記のコードは、次のように一時変数をifの外で作っておいて、それを利用するようにすると良いです。生存期間を意図通り細かく区切れるため、意図したスコープを抜けるとドロップが発生します。結果Mutexのロックを解除することができます。

let item = list.lock().unwrap().pop();
if let Some(item) = item {
    process_item(item);
}

以前X上の議論で、似たようなものがありました。そのときの説明では、これはパターンマッチに脱糖されるのでそこからスコープを考えてみると、生存期間がパターンマッチの腕の中までと断定できるのではという話がありました。が、これは微妙に説明不足だったと思っていて、理由としてはとくに私が手元でHIRで脱糖の状況を確認したところ、if letは必ずしもmatchに変換されているわけではなさそうだったためでした。そういうわけで、脱糖の観点からの説明は難しいのではないかと当時は考えていました。アナロジーとしては正しいんですけどね。そしてこの節を読んで、実はこのように借用とそれにまつわる生存期間からの説明が妥当だったということがわかりました。

2章

RustのAtomicではじまる機能についての解説です。Javaでは結構使ったことがあって馴染みがあるんですが、Rustの方はあまり使ったことがなかったなと読みながら思いました。いい勉強になりました。アトミック操作を使うとこういう機能を実装できるよ、という例がいくつか示されており、実アプリケーションでの利用を考えながら学べてよい構成になっていると感じました。

3章

ところで、この章を読んでいる最中にtokioのIssueを眺めていたところ、自作ツールを使ってtokioの特定の箇所に対して検査をかけてみた結果、SeqCstではなくAcquire/Releaseで十分な箇所を見つけている人を見つけました。ちょうど学んでいる内容がリアルタイムでやってくるとテンションが上がりますね。

github.com

Mara先生の教訓、

SeqCstは警告だと思った方がいい。SeqCstをどこかで見かけることがあったら、何か複雑なことを行なっているか、それを書いたプログラマがメモリオーダリングに関する想定を十分な時間をかけて解析していない、ということだ。いずれも、特に注意しなければならないサインだ。

を見た気がしました。もちろん、tokioではSeqCstが必要な箇所にはこのようにコメントがされていて、よく検討されているかどうかはすぐにわかるようになっていそうに思いました。

4章、5章

スピンロックやチャネルをフルスクラッチします。手を動かしつつ先ほど3章で出てきたメモリオーダリングを復習する章になっていると思います。

4章ではガードと呼ばれる実装パターンが紹介されています。これはRustを触っているとたびたび出てくる実装パターンなように思います。ガードは何か内部的に状態をもっておき、ドロップされるまでそれを維持する役割を果たします。ロックの実装と相性がよく、主にはロックで用いられているように見受けられますが、それ以外の用途でもたびたび見かけます

6章

この章ではArcを実装しつつ、メモリオーダリングについての理解をさらに深めていきます。ArcはRustの標準ライブラリに存在しており比較的使う回数は多いものの、内部で何をしているかはなんとなくしか把握していないことが多いと思います。この章をWeak対応版まで進めると、実質Rustの標準ライブラリのそれとほとんど同等になります。

Miri

ところでこの章を読んでいて作ったArcに対するテストが実装されるのですが、そのあとに「miriを実行してみるといいだろう」という話があります。これをやってみたので補足しておきます。

MiriはRustの中間表現であるMIRを実行するインタープリタです。unsafeコードをチェックする際に利用できるツールで、未定義動作の発生をチェックできます。MiriはコンパイラのMIRを用いて、ネイティブなプロセッサ命令にコンパイルせず、型やライフタイムなどの情報がまだ残っている状態でコードを実行・解釈させるツールです。インタプリタ方式なのでコンパイル後の実行と比較するとかなり実行時間はかかりますが、その文未定義動作になりうるようなさまざまなミスを検出できます。データ競合の検出も実験的ながらもサポートされており、これを使うとメモリオーダリングの問題も検出することができるそうです。

Miriは意外に簡単に実行できます。次のようにしてMiriを手元にインストールして、既存のテストに対してMiriを実行するだけです。

$ rustup +nightly component add miri
$ cargo +nightly miri test

ちなみにですが、今回実装したArcのテストではとくにMiriによる不具合の検出はありませんでした。unsafeなコードを書く際にはMiriもきっちり実行しておきたいものです。はじめて使いましたが便利でした。余談ですが、RustでLinked Listを実装してみる有名なチュートリアルにもMiriで検証する節があり、こちらは実際に不具合を検出させて修正させる形をとっているようです。一度このチュートリアルもこなしておきたいなと思いました。

Loom

こうした並行処理にまつわるテストにもうひとつ使えるツールとして、tokioが提供するLoomというプロジェクトがあります。[*1]Loomは、C11のメモリモデルで有効な実行の構成に応じて実現可能な組み合わせを変えながら、テストを何度も実行してくれるツールです。同時に状態数を削減しつつ組合せ爆発を回避しているようです。並行処理のテストは往々にしてこうした組み合わせを網羅するのが難しいわけですが、Loomを使うとこの点をある程度緩和できるというわけです。

私も今回はじめて使ってみたので使い方が合っているか謎ですが、tokioのテストケースなどを見る限り、次のように使いこなすことができそうでした。下記は本書で一度目に書くWeak実装前のテストコードをLoom化してみたものです。

#[cfg(test)]
mod tests {
 // 他のテスト用の余計なimportsも含まれるかも
    use loom::lazy_static::Lazy;
    use loom::sync::atomic::AtomicUsize;
    use loom::sync::atomic::Ordering::{Acquire, Relaxed, Release, SeqCst};
    use loom::thread;
    use std::marker::PhantomData;

    use crate::Arc;

    #[test]
    fn test() {
        // static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);
        static NUM_DROPS: Lazy<AtomicUsize> = Lazy {
            init: || AtomicUsize::new(0),
            _p: PhantomData,
        };

        struct DetectDrop;

        impl Drop for DetectDrop {
            fn drop(&mut self) {
                NUM_DROPS.get().fetch_add(1, Relaxed);
            }
        }

        loom::model(|| {
            // Create two Arcs sharing an object containing a string
            // and a DetectDrop, to detect when it's dropped.
            let x = Arc::new(("hello", DetectDrop));
            let y = x.clone();

            // Send x to another thread, and use it there.
            let t = thread::spawn(move || {
                assert_eq!(x.0, "hello");
            });

            // In parallel, y should still be usable here.
            assert_eq!(y.0, "hello");

            // Wait for the thread to finish.
            t.join().unwrap();

            // One Arc, x, should be dropped by now.
            // We still have y, so the object shouldn't have been dropped yet.
            assert_eq!(NUM_DROPS.get().load(Relaxed), 0);

            // Drop the remaining `Arc`.
            drop(y);

            // Now that `y` is dropped too,
            // the object should've been dropped.
            assert_eq!(NUM_DROPS.get().load(Relaxed), 1);
        });
    }

// 他のテストが続く

Arcは今回自分たちで作ったテスト対象なので、これはそのまま利用します。それ以外のたとえばメモリオーダリングを指定するOrderingや、スレッドを生成するthread::spawnの類はすべて、loomが提供するものに置き換えられています。あんまり中身がよくわかっていませんが、このloomで始まるものは内部的にトラッキングされていて、loomの並行処理実行時(loom::model)にエラー等が検出された場合、このトラッキング情報を追ってデバッグできるように作られているようです。

7章

キャッシュ周りを一旦読みましたが、この章はあとで戻ってきてじっくり読みたいと思っています。

8章

futexに関する解説が載っていました。著者はMutexの性能向上に貢献した張本人ですが、この件について調べていた際にfutexについていつか深く学んでおかないとと思っていました。ちょうどよかったです。

9章

8章までで出てきた概念やライブラリを用いて、Mutex、条件変数、リーダ・ライタ・ロックを実装します。どれもステップバイステップで少しずつ想定される問題を解決しながら実装していくので、非常にためになります。

たとえばMutexの場合、まず最小の実装で一旦実装し切ります。その後、wait/wakeに関連するシステムコールの呼び出し回数が問題になりうることが説明され、呼び出し回数を削減するためには他の待機スレッドがあるかどうかの判定を入れれば良いことが説明されます。最後にベンチマーカーを実装して実際に動かしてみつつ、Mutexのベンチマーカーを作る難しさと簡単に実装したベンチマーキングの結果の解説が挟まれるといった方式です。私はこれが非常によかったと思っています。

作られるMutexはRust標準ライブラリのそれとよく似た構成になっています。Rust標準のMutexのロック部分を作るのにはLinuxならfutex、それ以外のOSであれば相当するシステムコールが必要になりますが、それはatomic-waitというクレートを使うことで結構簡単に実装できました。

リーダ・ライタ・ロックを実装する際にはwriter starvation問題まできちんとステップバイステップで解消していきます。

10章

これまでの章で拾いきれなかった話題について解説されます。セマフォやロックフリー連結リストなどです。解説を読みながら実装してみるとおもしろそうですが、まだ試せてはいません。

感想

私は普段RustでWebバックエンドのアプリケーションを実装しています。RustでWebバックエンドを実装していると、本書で紹介があったようなMutexArcなどを利用しはするものの、中で利用されているツールや概念について使用することはほとんどありません。

また、こうした機能を深く理解しているかというとそうでもありません(Arcは後述する通り一度ある記事を読み込んでいてちょっと知ってたものの)。表面的には何をやっているかはもちろん把握していますが、メモリオーダリングの話などは詳しく把握しているかというとそうでもないのが実情です。こういう場合、最適化を本気でやろうとすると困るわけなのですが、もっともたとえばAWS上でインスタンスサイズをスッとあげれば問題が解決してしまうことが多く、突っ込んで最適化するよりも札束で殴る会社の方が多いのではないでしょうか。それがあるべき姿なのかというと、もちろんケースバイケースだと思いますが。

そして、細かい生存期間や所有権、借用について気にすることはほとんどありません。せいぜい長い生存期間を持つもので、アプリケーションのローカルに用意するインメモリキャッシュくらいでしょうか。基本的にはほとんどの生存期間はHTTPリクエストが送られてから、HTTPレスポンスが返されるまでとなります。したがってその程度の生存期間では、ドロップの順番等はほとんど気にすることはありません。また内部可変性についても、データベースをはじめとしたミドルウェアにほとんど丸投げしてるため意識することもないでしょう(ただたとえば、書き込み頻度の高いインメモリキャッシュを持っている場合は別ですが)。

そうした意味では、ほとんど見ることのなかった世界を覗けたという意味でよい読書体験だったと思います。技術を使うことに精一杯で、裏側に控える議論をあまり深ぼっていなかったなと改めて気付かされました。読後、Rustと並行処理への理解度がまた一段あがったと感じました。

私が行なったようにtokioを探索しながら読み進めるとおもしろいかもしれません。本書で登場した内容はtokioでもそのままよく議論されている内容であることが多いようです。tokioやRust本体にコントリビューションする際によい糸口になってくれるはずです。そうしたOSSへのコントリビューションを考えている方は手元に置いておいてもよいのではないでしょうか。

著者も書籍の最後の方で説明しているように、Rustの並行処理に関する文献や資料は思ったよりありません。私の知る限りでは、後ほど紹介する『並行処理プログラミング入門』と未邦訳の『Rust for Rustaceans』10章くらいでしょうか。文法入門レベルのものはいくらでも存在するのですが、それを使ってどう実装を組み上げていけばよいかに関する教材や資料が不足しているのは著者の指摘の通りだと思います。

ところで、今回紹介されたような話題について、並行処理そのものにもう少し絞ってキャッチアップしたいと思いました。何か定番の教科書や資料などあったら教えてください🙌🏻

日本語での別の資料

この話題は実は日本語圏でも結構いい資料がいくつかあり、あわせて読むと理解が深まるかもしれません。

ひとつはqnighyさんの連載で、Arcを切り口に本書で扱う話題に入っていく流れになっています。私は実は本書の前半の内容はだいたい既知だったわけですが、それはこの記事を読み込んでいたからかなと思いました。

qiita.com

もうひとつは『並行処理プログラミング入門』という書籍です。この書籍でもやはり同じくらいのレイヤーの低いところから一気に解説がなされます。それだけでなく、Rustの難しいポイントのひとつであるasync/awaitや、Futureに関連する章もあり非常に網羅的だと思います。あわせて読むと理解が深まるかもしれません。

*1:余談ですが「loom」は織機のことです。無数の糸(thread)を束ねるもの、というイメージでしょうか。