Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stacked Borrows を理解しようとする

前のセクションでは、unsafe な単方向連結キューを miri で実行してみました。すると、stacked borrows のルールを破っていると言われ、いくつかのドキュメントへのリンクが示されました。

通常ならドキュメントを案内しながら説明するところですが、私たちはそのドキュメントの主な対象読者ではありません。あれは Rust のセマンティクスに取り組んでいるコンパイラ開発者や研究者向けに設計されています。

なのでここでは、「stacked borrows」の高レベルな考え方だけを説明し、そのルールに従うためのシンプルな戦略を示します。

ナレーター: Stacked Borrows は Rust のセマンティックモデルとしてはまだ「実験的」なものなので、これらのルールを破っているからといって、あなたのプログラムが実際に「間違っている」とは限りません。しかし、あなたが文字どおりコンパイラに取り組んでいるのでない限り、miri が文句を言ったらプログラムを修正すべきです。未定義動作に関しては、用心するに越したことはありません。

動機: ポインターエイリアシング

破ったルールが何かに入る前に、そもそもなぜそのルールが存在するのかを理解しておくと役立ちます。動機となる問題はいくつかありますが、最も重要なのはポインターエイリアシングだと思います。

2つのポインターが指すメモリ領域が重なっているとき、それらのポインターはエイリアスすると言います。「別名を名乗る」人を2つの異なる名前で参照できるのと同じように、その重なったメモリ領域は2つの異なるポインターで参照できます。これは問題を引き起こす可能性があります。

コンパイラは、ポインターエイリアシングに関する情報を使ってメモリへのアクセスを最適化します。そのため、その情報が間違っていると、プログラムは誤ってコンパイルされ、でたらめな動作をします。

ナレーター: 実際のところ、エイリアシングでより問題になるのはポインターそのものよりもメモリアクセスであり、本当に重要になるのはアクセスの一方が変更を伴う場合だけです。ポインターが強調されるのは、ルールを結びつける対象として都合がよいからです。

ポインターエイリアシング情報がなぜ重要なのかを理解するために、小さな怒れる男のたとえ話を考えてみましょう。


ある日、Michiel が本棚を眺めていると、見覚えのない本が目に入りました。彼らはそれを本棚から取り出して、表紙を見ました。

「ああそうだ、昔の War and Peace だ。もちろん読んだことがある本だ。Peace がいっぱい出てくるところが大好きだったな。」

突然、ドアをノックする音がしました。Michiel は本を棚に戻してドアを開けました。そこにいたのは、彼らの宿敵 Hamslaw でした。Hamslaw が Michiel の明らかに劣ったコードゴルフ能力について壊滅的な一言を放とうとしたそのとき、彼らは好機を感じ取りました。

「ねえ Hamslaw、War and Peace って読んだことある?」

「ふん、War and Peace なんて実際には誰も読んでないでしょ。」

「まあ私は読んだけどね。ほら、本棚のあそこにあるだろ。つまり当然読んだってことだよ。」

Hamslaw は信じられませんでした。彼女の顔は、いつもの得意げな表情から、怒りと決意に満ちた鉄仮面のような顔つきへと変わりました。Hamslaw は Michiel を押しのけて本棚まで早足で進み、千人のヴァルキリーの怒りを込めて、その大冊を安置場所から引き抜きました。彼女はその古い本を手の中でひっくり返し、表紙を見た瞬間、震え始めました。

Michiel は自分の明らかに比類なき才気を自慢しようと身構えましたが、Hamslaw の突然の笑い声に遮られました。

「これ War and Peace じゃないわ。War and Feet よ!」

Hamslaw の顔には涙が流れていました。これは明らかに彼女の人生最高の瞬間でした。

「そ、そんな! さっき見たばかりなのに!」

彼らは Hamslaw から本を奪い取り、表紙を確認しました。確かに、「Peace」という単語は引っかいて消され、「Feet」に置き換えられていました。Michiel は愕然としました。これは明らかに彼らの人生最悪の瞬間でした。

彼らは膝から崩れ落ち、本棚をぼんやりと見つめました。どうしてこんなことが起きたのでしょうか? 表紙を確認したのはほんの一瞬前だったのに!

すると、本棚の中で何かが少し動くのが見えました。小さな男でした。Michiel がこれまで見た中で最も怒りに満ちたしかめっ面をした、小さな男でした。その小さな男は Michiel に向かって中指を立て、「誰もお前を信じない」と口だけで言い、本の間へと消えていきました。

Michiel の計画は完璧だったはずですが、シャーピーを持ち、破壊を望む小さな怒れる男の可能性を考慮できていませんでした。彼らは本の表紙に何と書いてあるかを知っていると思っていましたし、誰もそれを変えられるはずがないと思っていました。しかし悲しいかな、彼らは間違っていたのです。

Hamslaw はすでに、自分の信じがたい勝利を記念する zine の制作に取りかかっていました — 地元のインターネットカフェでの Michiel の評判が回復することは、もう二度とないでしょう。


Michiel のようになりたい人はいませんが、小さな怒れる男を常に恐れて生きたい人もいません。私たちは、小さな怒れる男がいつこちらにいたずらを仕掛けうるのかを知りたいのです。彼がいるときは、すべてを使う前に確認することについて、とても慎重で疑い深くなります。しかし小さな怒れる男がいなくなったら、物事を覚えておけるようになりたいのです。

これがポインターエイリアシングの(かなり単純化した)核心です。つまり、コンパイラは値を何度も読み込み直す代わりに「覚えておく」(キャッシュする)ことがいつ安全だと仮定できるのか、ということです。それを知るには、あなたの背後でメモリを変更する小さな怒れる男たちが存在しうるときを、コンパイラが知る必要があります。

ナレーター: コンパイラはこの情報をストアのキャッシュにも使います。これは、誰にも気づかれないと思うなら、物事をメモリにコミットするのを避けられるという意味です。この場合も問題は小さな怒れる男たちですが、問題を起こすには彼らはメモリを読むだけで十分です。

安全な Stacked Borrows

では、コンパイラに良いポインターエイリアシング情報を持たせたいとして、それは可能でしょうか? どうやら Rust はそのために設計されているように見えます。ミュータブル参照は定義上エイリアスされず、共有参照は互いにエイリアスしうるものの、変更はできません。完璧です! 出荷しましょう!

ただし、実際にはもっと複雑です。次のようにミュータブルポインターを「再借用」できます。

#![allow(unused)]
fn main() {
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

*ref2 += 2;
*ref1 += 1;

println!("{}", data);
}

これはコンパイルも実行も問題なくできます。どういうことでしょうか?

何が起きているのかは、2つの使用箇所を入れ替えると分かります。

let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// 順序を入れ替えた!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);
error[E0503]: cannot use `*ref1` because it was mutably borrowed
 --> src/main.rs:6:5
  |
4 |     let ref2 = &mut *ref1;
  |                ---------- borrow of `*ref1` occurs here
5 |     
6 |     *ref1 += 1;
  |     ^^^^^^^^^^ use of borrowed `*ref1`
7 |     *ref2 += 2;
  |     ---------- borrow later used here

For more information about this error, try `rustc --explain E0503`.
error: could not compile `playground` due to previous error

突然コンパイラエラーになりました!

ミュータブルポインターを再借用すると、元のポインターは、その借用者が使い終わる(それ以上使われなくなる)まで、もう使用できません。

動作するコードでは、使用箇所がきれいに入れ子になっています。ポインターを再借用し、新しいポインターをしばらく使い、それから古いポインターを再び使う前に新しいポインターの使用をやめています。動作しないコードでは、そうなっていません。使用箇所を任意に入り混じらせているだけです。 これが、再借用がありながらエイリアス情報も保持できる仕組みです。私たちの再借用はすべて明確にネストしているため、任意の時点でそのうちの1つだけを「生きている」と見なせます。

ところで、きれいにネストしたものを表現するのに最適な方法って何でしょう? スタックです。借用のスタックです。

ほら、これがStacked Borrowsです!

借用スタックの一番上にあるものが「生きている」ものであり、自分が実質的にエイリアスされていないことを知っています。ポインターを再借用すると、新しいポインターがスタックにプッシュされ、その生きているポインターになります。古いポインターを使うと、その上にある借用スタック上のすべてをポップすることで、そのポインターが再び生き返ります。この時点で、そのポインターは自分が再借用されていたこと、そしてメモリが変更されているかもしれないことを「知っています」が、同時に再び排他的アクセスを持っていることも知っています――小さな怒れる男たちを心配する必要はありません。

つまり、実際には再借用されたポインターにアクセスすることは常に問題ありません。なぜなら、その上にあるものをいつでもすべてポップできるからです。本当に問題なのは、すでに借用スタックからポップされてしまったポインターにアクセスすることです――その場合はやらかしています。

ありがたいことに、上の例で見たように、借用チェッカーの設計により安全なRustプログラムはこれらのルールに従います。ただし、コンパイラーは一般に、この問題をStacked Borrowsの観点とは「逆向き」に捉えます。ref1を使うとref2が無効化される、と言う代わりに、ref2はそのすべての使用箇所で必ず有効でなければならず、順番を破って問題を起こしているのはref1のほうだ、と主張します。

したがって、「*ref1は可変借用されているため使用できません」となります。結果は同じですが(特に非字句ライフタイムでは)、おそらくより直感的な形で表現されています。

しかし、unsafeポインターを使い始めると、借用チェッカーは助けてくれません!

UnsafeなStacked Borrows

そこで、コンパイラーがunsafeポインターを適切に追跡できないとしても、なんとかしてそれらをこのStacked Borrowsシステムに参加させる方法が欲しいわけです。また、失敗してUBを引き起こすことがあまりにも簡単にならないように、システムはかなり寛容であってほしいとも思っています。

これは難しい問題で、私は解決方法を知りません。しかしStacked Borrowsに取り組んだ人たちは、もっともらしいものを考案し、miriはそれを実装しようとしています。

非常に大まかな考え方としては、参照(またはその他の安全なポインター)を生ポインターに変換するとき、それは基本的に再借用を行うのと同じだ、というものです。したがって、その生ポインターはそのメモリに対して好きなことをしてよくなり、その再借用が失効すると、通常の再借用でそれが起きる場合と同じようになります。

しかし問題は、その再借用がいつ失効するのかです。おそらく、元の参照を再び使い始めたときに失効させるのがよいでしょう。そうでなければ、きれいにネストしたスタックにはなりません。

でも待ってください。生ポインターを参照変換できます! そして生ポインターはコピーできます! もし&mut -> *mut -> &mut -> *mutと進んでから、最初の*mutにアクセスしたらどうなるのでしょう? その場合、Stacked Borrowsはいったいどう動作するのでしょうか?

正直なところ、私にはわかりません! だから複雑なのです。実際、それらはさらに複雑です。なぜなら、Stacked Borrowsはより寛容になろうとしていて、より多くのunsafeコードが期待どおりに動作することを許そうとしているからです。だから私は、間違いを見つける助けとして、miri上で実行するのです。

実際、この厄介さがあるため、miriには追加で実験的な、さらに厳格なモードがあります: -Zmiri-tag-raw-pointers

これを有効にするには、次のようにMIRIFLAGS環境変数経由で渡す必要があります。

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

またはWindowsでは、変数をグローバルに設定する必要があるため、次のようにします。

$env:MIRIFLAGS="-Zmiri-tag-raw-pointers"
cargo +nightly-2022-01-21 miri test

私たちは通常、自分たちの作業にさらに自信を持つために、この追加の厳格モードに従うようにします。また、これはある意味で「より単純」でもあるため、Stacked Borrowsをいじって直感を得るには実際により適しています。

Stacked Borrowsを管理する

したがって、生ポインターを使うときは、単純で荒っぽく、できれば大きな誤差の余地を持つヒューリスティックに従うようにします。

生ポインターを使い始めたら、生ポインターだけを使うようにする。

これにより、生ポインターがそのメモリにアクセスする「許可」を偶然失ってしまう可能性を、できるだけ低くできます。

ナレーター: これは2つの点で単純化しすぎています。

  1. 安全なポインターは、エイリアスだけでなく、しばしばより多くの性質を主張します。メモリが割り当てられていること、アラインされていること、指し先の型を格納するのに十分な大きさがあること、指し先が適切に初期化されていること、などです。そのため、物事が疑わしい状態にあるときに、それらを無造作に振り回すのはさらに危険です。

  2. 生ポインターの世界に留まっていたとしても、任意のメモリを好き勝手にエイリアスできるわけではありません。ポインターは概念的には特定の「割り当て」に結び付いており(これはスタック上のローカル変数ほど細かい粒度になり得ます)、ある割り当てからポインターを取り出し、オフセットして、別の割り当て内のメモリにアクセスするべきではありません。もしこれが許されるなら、あらゆる場所で小さな怒れる男たちの脅威が常に存在することになります。これが、「ポインターは単なる整数である」という見方が問題のある見方である理由の一部です。

さて、それでも私たちはインターフェイスには安全な参照が欲しいです。なぜなら、リストのユーザーがそれを知ったり心配したりしなくて済むような、優れた安全な抽象化を構築したいからです。

そこで、次のようにします。

  1. メソッドの開始時に、入力参照を使って生ポインターを取得する
  2. その時点以降は、unsafeポインターだけを使うよう最善を尽くす
  3. 必要に応じて、最後に生ポインターを安全なポインターへ変換し直す

ただし、私たちの型のフィールドはprivateなので、それらは完全に生ポインターのままにします。

実際、私たちが犯した大きな間違いの一部は、Boxを使い続けたことでした! Boxには、コンパイラーに「これは&mutによく似ている。なぜならそのポインターを一意に所有しているからだ」と伝える特別なアノテーションがあります。これは真実です!

しかし、私たちがリストの末尾に保持していた生ポインターはBoxの内部を指していたため、Boxに通常どおりアクセスするたびに、その生ポインターの「再借用」をおそらく無効化していたのです! ☠

次のセクションでは本来の姿に戻り、大量の例に頭をぶつけていきます。