Rustの`Pin`について理解する
(without.boats)- Rustの
Pinは、async/awaitが生成するFutureの中で自己参照状態を安全に扱うために導入された基盤要素である - async Futureは
await地点ごとに状態を保存するため、1つのオブジェクト内のフィールドが同じオブジェクトの別のフィールドを参照する自己参照型になり得る - move constructor、offset pointer、
?Move設計は、それぞれランタイム追跡コスト、コンパイル可能性、既存APIとの後方互換性の問題により採用されなかった - 最終的な設計は、ポインタを包んで対象をpinned typestateに置く
Pinであり、Unpinauto traitのおかげで大半の型は従来どおり移動可能である Pinの難しさは不変性の概念自体よりもライブラリ型の限界に由来し、reborrowing、Pin::set、pinned projection、Dropとの相互作用が使い勝手を大きく下げている
Pinが必要になる問題
- Rustのasyncエコシステムにおいて
Pinとpinningは中核的な基盤だが、async Rustを学ぶ人にとって依然として難しく、誤解の多い領域である Pinの目的は、安全なRustだけでユーザーが直接自己参照型を作れるようにすることではない- コンパイラがasync関数から生成した自己参照Futureや、tokioのようなランタイムがunsafeコードで作成した自己参照型を安全に操作できるようにすることにある
- 例として
async fn barでは、foo(&mut z).awaitの地点でzと、zを参照するFooFutureを同じFuture状態の中に一緒に保存する必要がある- このときFutureオブジェクト内部のあるフィールドが、同じオブジェクト内の別のフィールドを参照する
- このようなFuture型は自己参照型になる
- オブジェクトがこの状態に入った後で移動すると、内部参照は以前のメモリ位置を指したままになり、その場所は無効なメモリであったり別の値に再利用されたりする可能性がある
Pin以前のRustでは、所有権を持っているかmutable referenceを持っていればオブジェクトを移動できたため、ある時点以降は移動禁止であることを表現する方法が必要だった
解決策にならなかったアプローチ
-
move constructor
- move constructorは、値が移動されるときにdestructorのようにコードを実行し、自己参照ポインタを新しい位置に修正する方式である
- Rustではポインタは移動される値の「中」にだけあるとは限らず、たとえば自分の状態を指すポインタ群を保持したベクタの中にある場合もある
- こうしたポインタをすべて追跡するには、結局ガベージコレクションに近いランタイムメモリ管理が必要になる
- Rustは初期段階でmove constructorを持たない方針を選んでおり、多くのunsafeコードは値をメモリコピーだけで移動できるという前提に依存している
- 後からmove constructorを追加するとbreaking changeになる
-
offset pointer
- offset pointerは、自己参照を通常の参照ではなく、自己参照オブジェクトのアドレス基準のオフセットとしてコンパイルする方式である
- コンパイル時点では、ある参照が自己参照かどうかを常に判定できるわけではない
- 分岐によっては同じ値が自分自身のオブジェクト内部を指すこともあれば、外部を指すこともある
- これを処理するには、参照をoffsetとreferenceのenumのような形にコンパイルする必要があり、async/awaitの開発当時には現実的でない方法だと判断された
pinned typestateの要件
- 自己参照Futureは、最初から常に移動不能である必要はなく、ライフサイクル中のある時点までは自由に移動でき、特定の時点以降だけ移動してはならない
- Futureを別のFutureと組み合わせている間は移動可能である必要がある
- pollされる場所に配置された後は、それ以上移動してはならない
- Ralf Jungのモデルでは、既存の「owned」と「shared」というtypestateに加えて、自己参照Futureのための第3の状態としてpinned typestateが追加される
- オブジェクトがpinned typestateに入ったら、二度と移動してはならない
- より正確には、destructorを先に実行せずにそのオブジェクトのメモリを無効化してはならない
- 実質的には、オブジェクトを新しい場所へ移してはならないという要件だとみなせる
- ほとんどの型は自己参照を含み得ないため、pinned typestateは特別な意味を持たない
- そのような型はpinningの制約から外れ、再び移動できるほうが望ましい
- pinned typestateの詳細な形式モデルは、Ralf JungのA Formal Look at Pinningにまとめられている
?Move設計が失敗した理由
Pin以前には、Moveという新しいtraitに基づく設計が試みられていた- ほとんどの型は
Moveを実装する - 自己参照を含み得る型は
Moveを実装しない Moveを実装しない型の値に参照を作ると、その値はpinned typestateに入り、以後移動できなくなる
- ほとんどの型は
- この方式は、参照を作るタイミングとpinningへの遷移を結びつけて安全性を保証するという点で直感的だった
- 実際にコンパイラのブランチに実装もされた
- 根本的な限界は、将来的に自己参照になる値を一時的に参照したいが、まだpinningしたくないケースがあることだった
- たとえば値を一時的に
Optionに保存し、あとでOption::takeで取り出したいことがある
- たとえば値を一時的に
- より大きな問題は後方互換性だった
Moveをauto traitにできなかったmem::swapのように、mutable referenceから常に値を移動できることを前提にした安定APIがすでに存在していたためである
?Moveとして追加する方式も、associated typeのために後方互換ではなかった- traitのassociated typeに
?Traitboundを追加する位置はtrait定義側である - 既存traitのassociated type boundを緩和すると、そのboundに依存していたコードが壊れる可能性がある
IntoFutureのassociated future type、DerefMutのTarget、関数の戻り値型、iterator item、index operatorの戻り値、算術operatorの戻り値など、多くの基本操作がassociated typeと結びついている
- traitのassociated typeに
- editionでも簡単には解決できなかった
- 異なるeditionのcrate同士を組み合わせるには、traitインターフェースが同一に保たれている必要があるためである
Pinの設計
- 最終的な設計では、pinned typestateをオブジェクト型の属性としてではなく、特殊なポインタが作り出す状態として表現する
Pinはポインタを包むwrapper typeである- built-inのreference typeも包める
Boxのようなライブラリ定義のsmart pointerも包める
Pinは、そのポインタが指す対象をpinned typestateに入れ、対象は以後移動してはならない- 変更を最小限に抑えるため、この設計はコンパイラ機能ではなくライブラリAPIとして実装された
- pinnedオブジェクトを実際に変更しなければならないコードはunsafe APIにアクセスする必要がある
- その際、通常のmutable referenceを通じてオブジェクトが移動しないという保証を提供しなければならない
- ほとんどの型ではpinned状態と通常状態の間に意味のある差がないため、
Unpinauto traitが追加された- 型が自己参照になり得ないなら、pinned pointerからunsafeなしでmutable referenceを取得できる
Unpinを実装したオブジェクトはPinの外へ移動しても安全である
- pinningはpinned pointerにだけ適用されるため、通常のunpinned referenceは
Unpinでない型に対しても引き続き機能する - 詳細は標準ドキュメントの
Pin型とpinモジュールにまとめられている - この設計の最大の利点は、既存コードを壊さずに追加できた点にある
swapのように参照先データを移動できるAPIにはmutable referenceが必要である- オブジェクトを
Pinでpinningすると、そのようなAPIはそのオブジェクトに対して以後呼び出せなくなる - pinned typestateが特殊なpinned referenceにのみ適用されるため、Rust言語全体の後方互換性保証を壊さない
Pinの使い勝手の問題
Pinは要件を後方互換の形で満たしたが、ユーザーが直接扱う瞬間に複雑性の崖が生じる- ひとつの説明として、pinnedオブジェクトを変更するにはunsafeコードが必要だという点がある
- ただし、この問題は誇張すべきではない
Pin::setを使えば、pinnedオブジェクトに安全に代入できる- 実際にpinnedオブジェクトを変更する必要があるコードの大半は、async関数をFutureへ落とし込むコンパイラ生成コードであり、ユーザーが直接書く場面はまれである
Pinが条件付きで動作するから難しいという説明も、本質的な原因ではない- Rustには、条件に応じて異なる振る舞いをしつつも理解しやすさを保っている機能がある
- non-lexical lifetimesは、条件分岐ごとにlifetimeが異なる地点で終わるようにした例である
- 核心的な問題は、
Pinが純粋なライブラリ型である一方、通常のreference typeは言語組み込み型であり、多様な構文サポートやsugarを受けている点にある- 通常の参照では自然にできたことが、pinned referenceでは失われる
- ユーザーがコンパイラに受け入れられる参照の振る舞いを基準に作っていたmental modelが、pinned referenceでは崩れてしまう
reborrowingとPin::as_mut
- 通常のmutable referenceである
&mut TはCopyを実装しないが、同じ引数として複数回渡すことができる- これはコンパイラが
xの代わりに&mut *xを入れたかのように、reborrowingを暗黙に行うためである
- これはコンパイラが
Pin<&mut T>は通常のライブラリ型であり、Copyを実装しないため、このような利便性がないPin<&mut T>を2回以上使うと、move後に値を使ったというエラーや、さらに分かりにくいlifetimeエラーになることがある- 明示的に
Pin::as_mutを呼んでreborrowする必要がある
- 通常のmutable referenceではdereferenceやassignment operatorで直接代入できるが、
Pinではsetメソッドを覚える必要がある- このような特殊APIが増える理由は、
Pinが言語構文サポートを持たないライブラリ型だからである
- このような特殊APIが増える理由は、
pinned projectionとDrop
- pinned projectionとは、オブジェクトへのpinned referenceから、そのオブジェクトのフィールドへのpinned referenceを取得する問題である
- projectionは、オブジェクトからフィールドへアクセスすることを意味する
- 通常の参照におけるフィールドアクセスよりはるかに難しいため、pin-project-liteのようなサードパーティcrateが使われる
- こうしたcrateでは、macroを含む複雑な新しいAPIを学ぶ必要がある
- 最も厄介な相互作用は、pinned projectionと
Droptraitの間で発生するDrop::dropは通常のmutable referenceを受け取る- ある型が自己参照フィールドを持ち、そのフィールドにpin projectしてpollしたあと、destructorでそのフィールドを移動してしまうと、pinning保証を破り得る
- たとえばdestructorの中でそのfutureをstack上にpinningしてpollすると、既存のpinning保証が破られる
pin-project-liteのようなcrateは、destructorを定義する能力を制限することでこの問題に対処している- 実務上は機能するが、pinning保証を説明する際に文書化すべき複雑さが増える
DropはPinより先にstable化されていたため、回避策が必要だった
現在の評価と次の改善方向
Pinは、任意の参照を含むasync関数を安全な自己参照オブジェクトへコンパイルできるようにした- 参照はRustユーザーがコードを書くうえでの基本的な手段であり、これがなければasync/awaitの使い勝手は大きく損なわれていただろう
- 同時に
Pinは、既存Rustと完全に後方互換な形で追加された Pinは、高性能ネットワークサービスや非同期プログラミングのその他のユースケースを支えるエコシステムの基本構成要素となった- しかしpinned referenceを扱うのは通常のreferenceを扱うよりはるかに難しく、
Pinは実際に複雑性の崖を作っている - 次の改善方向における中核概念はpinned placesである
1件のコメント
Hacker Newsのコメント
Pinは公式ドキュメントで明確に説明されておらず、理解しにくいとずっと思っていた
特に「Pinはオブジェクトが絶対に移動しないことを保証する」といった説明が多いが、実際にはそうではない
オブジェクトが
Unpinでない場合にだけ正しい話で、ほとんどの普通のオブジェクトはUnpinなので、Pinは通常何もしないこれを理解するのにとても長くかかったし、Pinが実際に意味を持つ型
Tの集合はかなり特殊で奇妙なのに、ドキュメントはそこを十分に強調していないと思うもちろん、実際に固定された状態として扱うことになる型であるFutureとStreamは、そうした特殊なオブジェクトである可能性がはるかに高い
それでも、ドキュメントはここ数年でかなり良くなったと思う
この記事を書くときに確認したら、かなり適切な点に焦点を当てていて驚いたし、2019年ごろは
stdAPIドキュメントというより、Rustリファレンスに入るような契約仕様にずっと偏っていたと記憶しているユーザーがPinを難しいと感じる理由は、Pinそれ自体には意味がないからだと思う
これは言語の他のラッパーとは異なり、例外を挙げるなら本来の用途ではほとんど誰も使っていない
AssertUnwindSafeくらいだPin<&mut InnerType>があるとき、言語や標準ライブラリ内のPinだけでは、何ができて何ができないのかを示すものはないただし
InnerTypeがUnpinだと宣言されていれば、通常のポインタでできることはすべてできるという意味になる代わりにPinは「意味は自分で持ち込め」という方式で動作し、
InnerTypeの提供者が、固定されたオブジェクトを安全に操作するための内部的にはunsafeなメソッドやAPIを追加で作る構造になっているPinそれ自体の目的は、
&mutの置き換えやBoxから取り出して移動することのような内在的な能力がより少ないポインタを提供し、内部の型がその上に追加の能力を安全に許可できるようにすることにあるこの意味の曖昧さが人々を最も混乱させていると思うし、自分も理解するのにかなり時間がかかった
構造的フィールドと非構造的フィールドに関する概念は、「このフィールドは普通のデータだが、あのフィールドはそれ自体が固定されることを望むオブジェクトを含んでいる」といった一般的なアクセスパターンを可能にするための仕組みにすぎない
Unpinを実装していない限り、このポインタの対象は二度と移動できないという意味だより正確には、デストラクタを実行せずに対象を無効化できないという意味で、移動が問題になる理由もそこにある
特定の権利を手放すと、自己参照値を保存する権利のような別の権利を得る
コンポーネント間の契約はおおむねこのように動作する
同様に、参照を通じて変更する権利を手放すと、同時にその参照をエイリアスにできる
この点を考えるたびに、まったく別でずっと重いテーマではあるが、映画『Lincoln』の台詞「われわれが法に従うなら、Alex、自由を失うところまで従うなら――たとえば抑圧する自由を――以前には知らなかった別の自由を見つけるかもしれない」を思い出す
ただし、安全なコードではそうした権利を直接使えないという点が、教育上の問題だということには同意する
固定された参照で何ができるのかを、「コンパイラが作ってくれた
pollメソッドを呼び出す」以外では簡単に示しにくいからだRustで数年間プロとして開発してきたが、正直なところPinをそこまでよく理解していない
理論は分かるが、いつ使うべきかについての直感はあまりない
Pinの使用は実質的に「何かを試したらコンパイラに文句を言われて、あれこれ固定してみたらコンパイルできた」に近い
日々のコーディングで、本当に腰を据えて深く理解しなければならないほどの障害になったことはまだない
unsafeを避けて、賢いコンパイラの人たちがすでに全部解決してくれたことに感謝しよう」に当たる、最もよくあるケースの一つだ一方C++では、「分からないが必ず使わなければならないもの」の水辺をしょっちゅう歩いていて、ワニに食べられることがよくあった
教えるときに
Unpinな項目はPinの影響を受けないことを明確にするには、その場に留めるために作られた道具を使っても影響を受けない現実の比喩を使うとよいと思うベルクロのフックは滑らかな表面にはくっつかない:
Pin→ ベルクロ、Unpin→ 滑らかな表面磁石は非磁性物質に影響しない:
Pin→ 磁石、Unpin→ 非磁性/ガラス/真鍮接着剤はノンスティック表面には付かない:
Pin→ 接着剤、Unpin→ ノンスティックこうすれば、「ベルクロ」は物をその場に固定するが、物が「滑らか」ならベルクロの仕組みの影響を受けないことが明確になる
Rustエコシステムの命名の雰囲気を考えると、トレイト名を磁石と非磁性のような方向に変えていたら美しかったかもしれない
Unpinは、オブジェクトがいつでも固定される準備ができているという意味ではないかと思う昨夜記事を読んだのだが、固定に補正ステップが必要かどうかはもう忘れてしまった
だから
T: Pin + !Unpinはホチキスでしか固定できない紙のようなもので、T: Pin + Unpinは輪の付いた絵のように、釘に掛けてから輪を壊さずにまた下ろせるものに近いと思う「値の同一性」という用語はこの記事のどこにも定義されておらず、Mojo のドキュメントでも見つけられなかったので、Modular が Mojo は Pin が解決しようとしている問題を解決すると言う根拠がはっきりしない
私も答えを知っていると主張するつもりはないが、Chris Lattner とともに Swift の値セマンティクスに取り組んだ Dave Abrahams のすばらしい発表を思い出す
発表タイトルは「Value Semantics: Safety, Independence, Projection, & Future of Programming」
[0] https://www.youtube.com/watch?v=QthAU-t3PQ4
Rust は参照もファーストクラスの型として持っている一方、Swift と、私の見る限り Mojo は、参照を引数の渡し方としてのみ許している
Mojo は Swift の
inoutパラメータを拡張し、不変参照渡しの方式も備えているように見えるオブジェクト内に参照を保存できないようにすれば、Rust がコンパイルするようなコードは実装できないので、「自己参照構造体」の問題は解決する
しかし引用された段落が Mojo について述べている内容はまったくそういう話ではないので、何を意味しているのかかなり混乱する
私には、問題はある値への
&mut参照があると、mem::swap/replaceのようなものでその値を移動できてしまう点にあるように見えるしかし実際にそうする必要があるケースはまれだ
それが許されていなければ、自己参照値への
&mut参照を持っても完全に安全だったように思う必要なときだけ参照経由の移動を明示的に選択する方法があったかもしれないし、
swapとreplaceをunsafeにしていれば、この問題全体を避けられたのかもしれない誰かこの設計空間を探ってほしい
&mutは強力すぎると表現していた&mutがその中の値を移動する権限を与えていなければ、設計全体はずっと単純になっていただろう次の記事でこの内容を扱う予定
Rust は後方互換性を守らなければならず、すでに
&mutから値を移動できると決めてしまっているが、過去の決定に縛られないなら、もっとずっときれいな設計が可能なのは明らかだmem::swapは可変参照を通じて値を移動する方法の一つにすぎず、ほかにも方法は非常に多いOption::takeは私がかなりよく使う例だが、これがunsafeだとしたら本当に変だろうこの背景話はいい。WithoutBoats はすでに、非常に時宜を得た非同期イテレータ、
poll、pinというテーマで活発な議論を多く行ってきたhttps://news.ycombinator.com/from?site=without.boats
言語の細かな内部をここまで深く公開の場で掘り下げるコミュニティは多くないように思うし、見ていてとても面白い
async はまだ半分未完成で、非常に複雑だ
過去 3 年間、週 40 時間 Rust コードを書いてきた立場からの発言だ
ムーブコンストラクタがあり、生成されるすべての
Futureサブタイプが不透明で、自動的にヒープに割り当てられる、Rust に似た言語を想像することはできるそうすれば、ユーザーにはそれを壊す方法がなく、不透明でヒープ上の別の場所にあるため移動する方法もないので、Pin が不要になる可能性がある
ムーブコンストラクタがあるということは、移動が概念的には破棄後の再生成だという意味だからだ
そのおかげで、実行前に
Futureたちを結合してインライン化できるという良い効果があるRust の不変性にも似ている。不変なメモリがあるのではなく、不変参照だけがあるのだ
しかしそうすると、非同期関数呼び出しのたびに別個の割り当てが発生し、これはメモリ局所性にとって非常に悪い
何らかの形の仮想スタックのほうがはるかにましだろうが、スタックを基本的に小さく最適化しようとすると、結局ガベージコレクションが必要になる
Copyと同程度に言語へ組み込まれたMoveトレイトをstdに追加してくれたら本当にうれしいMoveは値をメモリ上のあるアドレスから別のアドレスへ移動する関数を定義し、impl Moveのない構造体は移動できないようにするものだほぼすべての型には
#[derive(Move)]を付け、これはバイトをコピーするだけの単純な移動関数を実装すればよいしかしこうすれば、自己参照型、Future、そしてより複雑な移動動作を必要とする多くのものへの道が開ける
実際には、
CopyとCloneの違いを反映して 2 つのトレイトに分けるほうが筋が通るかもしれない片方はバイトをそのまま移してよいとコンパイラに伝えるマーカートレイトで、もう片方はユーザー定義の「ムーブコンストラクタ」実装を許す方式だ
Pin は理解するのがあまりに難しいので、Move があればいいのにと思う
複雑な概念が二重否定、時には三重否定で包まれている。
fn(...)のようなものを見ると「何だこれは?」となり、unsafeな pin プロジェクションあたりまで来るとついていけなくなるいつ安全で、いつ安全でないのか分からず、ただ手を引くことになる
Moveのない Rust からMoveのある Rust へ移行するのは不便だろうこれまで書かれたほぼすべての構造体に
#[derive(Move)]を追加しなければならず、stdも同じだ既存のエディションで固定されていないすべての型については、コンパイラが
Moveトレイト実装を推論しなければならない機械的には可能だろうが、作業量が多いだけだ
非同期 Rust はひどい。ほぼ他のあらゆる言語の Future/Promise と比べると特にそうだ
いつか誰かが Rust のメモリ安全モデルを改善し、
Moveトレイトとより良い Future を備えた新しい Rust 風のシステム言語を作るだろう個人的には、Rust のマクロシステムの代わりにコンパイル時実行もあってほしい
Rust は好きだし、チームが何年もかけて行ってきたすべての仕事も好きだ
だが本当に期待している言語は、Rust の次に来る言語だ
同じアイデアだが Rust の失敗から学んだ言語であり、そのようなより良い Rust 系言語がどんな姿になるのか、だんだん明確になってきている
本当に待ち遠しい
WithoutBoats のまた別のすばらしい記事だ
正直なところ、これは Rust では非同期ランタイム内部に抽象化されて埋もれているおかげで助かっている部分の一つだと感じる
それでも、カスタム
Future実装以外でPin を実際にどこで使うのかは気になる表現だけを見ると、FFI の中で使えそうにも見える
たとえば
extern関数が*mut Tを返してポインタを受け取るなら、Pin<&mut T>で包むことで、より良い意味論を与えられそうに見えるところが記事では「固定された型状態に関するもう一つの事実は、ほとんどの型にはまったく関係がないということだ。型の値が自己参照を絶対に含み得ないなら、それを固定することは役に立たない」と述べている
FFI についてはまだ完全な初心者なので、安全な Rust で包む最良の方法を理解したい
アドレスに依存するシステム型とやり取りする場合もこれに当てはまる
たとえば一部の OS の mutex/futex では、カーネル文書がユーザー空間のロックオブジェクトは初期化後にアドレスが変わってはならないと述べているため、
stdが Pin に相当するものを使っていると理解しているロックされていないときでもアドレスが変わってはならない点が特異だ
通常はロックされている間だけの条件で、その場合は参照が指している対象を移動できないため Pin は必要ない
本当の問題であるスレッドの非効率性を直そうとせず、膨大な作業をしているように見える
すべての非同期コードは例外なく、状態管理のための大量の構文糖衣で軽量スレッドを実装するハックである
Rust のような言語では、本来なくてもよい複雑さを途方もなく追加している
スレッドの効率性とスケーラビリティの問題を直せば、これらはすべて消えるはずだ
ぱっと消えてなくなる
Java のような言語で
nullが「兆単位のコストを生んだ過ち」だったのと似ている1つの設計判断、あるいはここでは設計の欠如によって、膨大な複雑さが生まれている
キャンセルはネットワークアプリケーションや GUI で非常に有用である
スレッドは CPU とネットワークの両方を十分に活用しつつ、どちらも過度に占有しないようにするのを難しくする
スレッドプール間で作業を受け渡し始めると、それは Future を再実装する道に入ったということだ
あるいはコールバック/イベントで処理することになるが、コードが断片化し、
async/awaitはまさにそのための構文糖衣になろうとしていたものだキャンセルとタイムアウトの代替案は、Go のように
Contextオブジェクトをコード全体に組み込むことだが、末端のコードがContextに正しく従わない関数を素朴に呼び出してしまう問題が生じるこれは非同期コード内の非非同期関数の問題より、ほんの少しましなだけである
なので具体的に何をすべきだったというのか気になる
ただ手を上げて「いつか誰かが Linux を直してスレッドを魔法のように速くしてくれるかもしれないので、私たちの言語には非同期を追加しません」と言うべきだったのか?
また、OS をすべての非同期処理のスケジューラにすると、すべてのランタイムが OS スケジューラを使わざるを得なくなるため、多様なスケジューラ設計が不可能になる
「好ましいアプローチ」が実行不可能だと明らかになった後でも、費用対効果を再評価しないように見える
「機能 X が欲しい、結果は知ったことではない」という姿勢は、言語設計ではめったに勝ち筋にならない