
2026/04/16 15:11
Rust の Zero-Copy ページ:あるいは、我々がいつの間にか「ライフタイム」を心配せず、むしろ愛し始めた話
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
提供されたサマリーは、鍵となるポイントリストに記載されている主要な技術的業績およびアーキテクチャの詳細を効果的に捉えており、よく構成されており包括的です。ゼロコピー設計、所有権の保証、ハードウェア制約、バッファ管理といった重要な概念については明瞭さを保ちつつ、不要な専門用語を排除しています。改良点は必要ありません。
サマリー:
本記事は、カーネル空間とユーザー空間間の高価なデータのコピーを排除し、スループットを大幅に向上させる高性能な Rust データベースアーキテクチャを導入します。
O_DIRECT フラグを活用して OS ページキャッシュを迂回することで、このシステムは速度を犠牲にせず安全性を維持します。これは、標準的なエンジン設計においてメモリの境界で頻繁にコピーを行うことで処理が低速化するという点に対する主要な利点です。このソリューションは、Rust の独自の所有権モデルに頼っており、PageReadGuard を使用して読み取り、RwLockWriteGuard を使用して書き込みを行いながら、固有のコピーを作成するのではなく借用されたビューを保持することでデータ整合性を確保します。また、64 ビット DMA サポートがない古いシステムといった特定のハードウェア制約についても巧妙に対応しており、HeapHeaderRef などの間接化技術を利用します。実践的な意味では、これにより挿入操作時のメモリの境界シフトが効率的に行われ、バッファプール内のページ除去ポリシーも滑らかに管理されます。結局のところ、このアーキテクチャを採用して導入する企業は、CPU オーバーヘッドの大幅な削減を期待でき、高ボリュームなデータ処理アプリケーションが最小限のリソース浪費と最大効率で負荷の高い運用を処理できることを意味します。本文
プロジェクトのソースコードはこちらから入手できます。
ゼロコピー(Zero-Copy) とは、カーネルとユーザー空間のバッファ間で CPU がデータを転送することを省略する手法です。特にデータベースエンジンのような高スループットなアプリケーションにおいて非常に有利です。負荷の高い状況下、特に作業セットがキャッシュから追い出されている場合に、そのパフォーマンス向上への寄与は顕著です。
ゼロコピーとは
ここで一般的なデータベースエンジンの構成を示します。今回の記事では、主に「OS の境界」と「バッファプールから上位レイヤーに至る経路」の 2 つのコピー境界に焦点を当てます。
┌─────────────────────────────────────────────────────────┐ │ クエリ層 (Query Layer) │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────┐ │ 実行エンジン (Execution Engine) │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────┐ │ トランザクションマネージャー │ └──────────┬─────────────┴─────────────────┬──────────────┘ │ │ ┌──────────▼──────────┐ ┌────────────▼────────────┐ │ ロックマネージャー│ │ ログマネージャー │ └─────────────────────┘ └─────────────────────────┘ │ より上位のレイヤーへの新鮮なコピー ┌────────────────────────▼────────────────────────────────┐ │ バッファプール (Buffer Pool) │ └────────────────────────┬────────────────────────────────┘ │ OS 境界でのコピー ┌────────────────────────▼────────────────────────────────┐ │ ディスク │ └─────────────────────────────────────────────────────────┘
高性能なエンジンを構築しようとする場合、できるだけ不要な作業を省略することが求められます。そしてデータのコピーはまさにその範疇に分類されます。
各コピー操作を
memcpy() の同等物と考えれば理解しやすいでしょう。memcpy() は実際にはパイプラインのストールを引き起こし、これは高性能アプリケーションでは回避したいものです。CPU はデータをソースから読み取り、宛先に書き込む作業を行うためです。つまり、重要な処理以外のことにサイクル(処理時間)を費やしており、これによりホットデータが CPU キャッシュから追い出される原因となります。
次に、通常の読み書き操作が行われるライフサイクルについて示します。ここで挙げられるすべての CPU コピーは、サイクルを消費するだけで実効性を欠く作業です。
イメージ元:Linux Journal
さて、まずはバッファプールとディスクの間のレイヤーにあるコピーを取り除くことに焦点を当ててみましょう。
バッファプールと Direct IO
リーナス・トルバルズが直接 I/O インターフェースに関する有名な激怒した発言です。彼はデータベース開発者を嫌います(と書かれています)。
バッファプールは
open() システムコールを通じてファイルディスクリプターを開き、保管します。これらのファイルディスクリプターに対して read() や write() を呼び出すと、前述のユーザー空間、カーネル、DMA 間のコピーを含む一連の循環を辿ります。
ここで手軽に成果が得られるアプローチは、
O_DIRECT フラグを使用した Direct I/O を採用することです。多くの現代のデータベースがこの手法を採用していますが、Postgres など例外もあります。これにより、アプリケーションは OS のページキャッシュをバイパスさせられます(64 ビット DMA 対応ハードウェアを搭載していることを前提とします)。32 ビットの DMA デバイスを持つシステムや、機密計算用仮想マシン(AMD SEV、Intel TDX)においては、カーネルがサイレントに SWIOTLB バウンซ์バッファを導入し、結果として CPU コピーが再導入される可能性があります。詳細については Linux カーネルドキュメントの swiotlb を参照してください。
O_DIRECT を使用するには、提出されるバッファはポインタアラインメント、I/O の長さとファイルオフセットに対して整列している必要があります。Rust では、ページを保持するバッファに #[repr(align(4096))] によって前者を保証し、4 KiB ページサイズの読み書きとページアラインメントされたオフセットを使用することで、残りの要件も満たします。これを満たさないと、O_DIRECT の読み書きは EINVAL エラーで頻繁に失敗します(C 言語でのこの挙動を示す gist が存在します——最初のプログラムでは malloc を使用しているため 4096 字節アラインメントされていないため書き込みが失敗し、2 つ目のプログラムでは posix_memalign を使用するため成功しています)。
カーネルのページキャッシュをバイパスするため、リードaheadやライトの融合(coalescing)といった有益な恩恵を受けることはできません。しかし、これがなぜデータベースにおいてバッファプールが非常に重要なのかという理由そのものです。
バッファプールは OS のページキャッシュに代わるものであり、特定のワークロードのために設計されています。これについては「メカニズム + ポリシー」という観点で考えることが常に有益です。
- メカニズム: 固定サイズのパージートブルであり、上位レイヤーからのページ要求に応えつつ、他のページを収容するために一部のページを驱逐(evict)します。
- ポリシー: どのページを驱逐するかを決定する方法(驱逐ポリシー)です。
┌─────────────────────────────────────────────────────────┐ │ クエリ層 (Query Layer) │ │ │ │ テーブルスキャン / インデックススキャン / B ツリートル スキャン │ │ (行をイテレーティングし、next()、get_value() を呼び出す) │ └────────────────────────┬────────────────────────────────┘ │ 使用 ┌────────────────────────▼────────────────────────────────┐ │ トランザクション │ │ │ │ tx_id | ConcurrencyManager | RecoveryManager │ └────────────────────────┬────────────────────────────────┘ │ pin() / unpin() ┌────────────────────────▼────────────────────────────────┐ │ バッファプール (Buffer Pool) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ フレーム 0 │ │ フレーム 1 │ │ フレーム 2 │ ... │ │ │ │ │ │ │ │ │ │ │ file:3 │ │ file:7 │ │ empty │ │ │ │ pins: 1 │ │ pins: 0 │ │ pins: 0 │ │ │ │ │ │ │ │ │ │ │ │ 4KB データ│ │ 4KB データ│ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ポリシー: LRU | CLOCK | SIEVE │ └────────────────────────┬────────────────────────────────┘ │ │ ┌────────────────────────▼────────────────────────────────┐ │ ディスク │ │ │ │ [ ファイル 1 ] [ ファイル 3 ] [ ファイル 7 ] [ ファイル 9 ] │ └─────────────────────────────────────────────────────────┘
システムに適したポリシーを選択するのはワークロードの特性に依存しますが、ほとんどのシステムは CLOCK(LRU の近似)を採用するのが一般的です。バッファプールで使われている置換ポリシーの非排他的なリストが以下の通りです(※ここでの記述は要約と推測を含みます)。
O_DIRECT は OS 境界のコピーを除去します。次の課題は、ページがすでにバッファプールにある状態から、そこから新たなコピーを引き起こさないことです。
リードパスからのコピーの排除
これまで「ゼロコピー」とは、カーネルとバッファプール間のコピーを除去することを意味していました。ここからは、その範囲を少し広げて、エンジン内部での冗長なコピーも除去することを指すと定義します。
Rust はデータを複製する際の課題に対処するための素晴らしい反面、恐ろしい方法を持っています。それが「参照(references)」です。素晴らしいところは単一の文字(
&)で記述できる点ですが、恐ろしいところは生命期(lifetimes)を取り扱えるようになる必要がある点です。
生命期の最も単純な考え方は、「タイプ A が保持する参照が、その参照が指し示すデータの寿命を超えないことをコンパイラに証明している」というものです。
まずは単一のページの生データ(raw bytes)を以下のように定義しましょう:
pub struct PageBytes { bytes: [u8; PAGE_SIZE_BYTES as usize], }
次に、単一のバッファプールのフレーム内に保持されるデータを定義します。ここで
RwLock<T> はページラッチとして機能します。
#[derive(Debug)] pub struct BufferFrame { page: RwLock<PageBytes>, }
さて、このフレームのデータを
PageReadGuard 内に保管しましょう。
// この例を小さく保つために、以下の型は図式的なもの而非常に実際のものです。 // ここでは所有権とのトレードオフを示し、RwLockReadGuard の詳細な実装 // は省略します。 /// ピンされたページへの共有アクセスを提供するリードガーディアン。 pub struct PageReadGuard { page: PageBytes, }
このバージョンはモデリングが簡単ですが、設計にコピーを埋め込んでいます。より上位のレベルのページオブジェクトがそれぞれ
PageBytes を所有する場合、バッファプールストアからこれらのオブジェクトを構築すると、新しい所有された値を実体化(materialize)しなければなりません。
私たちが実際に望んでいるのは「所有権」ではなく、「すでに他 somewhere に存在するバイトに対する借用ビュー(borrowed view)」です。これを表現するには生命期を導入します。
pub struct PageReadGuard<'a> { page: &'a PageBytes, }
この生命期の注釈により、コンパイラに対して
PageReadGuard が PageBytes の寿命を超えないことを証明しており、より上位のページオブジェクトが新しいコピーを持つのではなく、既存のバイトに対するビュー(参照)となるように動作します。
実際の実装では、フィールドは
'a PageBytes ではなく RwLockReadGuard<'a, PageBytes> ですが、所有権に関するストーリーは同じです:ガーディアンはページバイトを所有するのではなく借用し、ラッパーはその借用を先送りします。
pub struct PageReadGuard<'a> { page: RwLockReadGuard<'a, PageBytes> }
通常のデータベースエンジンはヒープページと B ツリートルページの 2 つのコアなページタイプを持っています。ここでは前者に焦点を当てましょう。ページの構造は標準的なスロット付きページ(slotted page)レイアウトとなります。
この時点では、
PageReadGuard<'a> を通じてバイトがすでに借用されています。問題は、ガーディアンがどこに住み、解析された参照がどこに住むべきかという点です。
最も自然な試みはすべてを 1 つの構造体に保つことです。
struct HeapPage<'a> { guard: PageReadGuard<'a>, header: &'a [u8], line_pointers: &'a [u8], record_space: &'a [u8], }
これは Rust の古典的な「自己参照構造体」の問題へと導きます。ポインタの無効化が非常に難しくなります。想像してみてください、構造体に 2 つのフィールド A と B があり、B が A を指している場合、もし構造体 A が移動(move)した場合、B はどこを指していますか?A のあった場所を指し続けることになりますが、これは無効であり未定義挙動(UB)につながります。Rust では
Pin、unsafe な生ポインタ、Arc ポインタや ouroboros などの外部パッケージを使ってこの問題を回避する方法がありますが、これらにはすべてオーバーヘッドが伴います。
したがって、次の試みとして、ガーディンの所有権とページに対する解析されたビューの所有権を分けます。
pub struct HeapPage<'a> { guard: PageReadGuard<'a>, } pub struct HeapPageView<'a> { header: &'a [u8], line_pointers: &'a [u8], record_space: &'a [u8], layout: &'a Layout, }
let page = HeapPage::new(guard); let view = HeapPageView::new(&'a page, layout); // ページとライフタイムをスタック上に維持し、ページが有効なまま
これは機能しますが、バイトを変更したい場合に問題が発生します。ヒープページにレコードを挿入したい場合、
record_space と line_pointers の両方を変更する必要があります。この時点で、両者の境界(bounds)が変わる可能性があり、構造体内にある参照が古くなる(stale)ことになります。構造体をドロップして再構築する必要があります。これにはコストはかからないものの、漏れのある抽象化(leaky abstraction)です。
より良い形式は以下のようになります:
pub struct HeapPage<'a> { guard: PageReadGuard<'a>, } pub struct HeapPageView<'a> { header: &'a [u8], body_bytes: &'a [u8], layout: &'a Layout, }
let page = HeapPage::new(guard); let view = HeapPageView::new(&'a page, layout); // ページとライフタイムをスタック上に維持し、ページが有効なまま
スロット付きページでは、ヘッダーサイズは固定されており、何か操作を行うたびに、ヘッダーから得た情報を元にボディバイトを再解析して、バイトへの正確なビューを取得します。
しかし、これはページとビューの両方をスタック上に維持する必要があり、クエリ層に実装詳細を漏らしてしまうという問題があります。これもまた、漏れのある抽象化です。
私が採用したバージョンはこれを逆にしました。従来のコンピュータサイエンスの原則である「コンピュータサイエンスにおける問題は、別の階層の間接性(indirection)で解決できる」を適用します。具体的には、スロット付きページはヘッダー、ラインポインタ、レコード空間に分割されるため、これら解析された参照を
HeapPage に格納し、PageReadGuard を HeapPageView に保持します。
pub struct HeapHeaderRef<'a> { bytes: &'a [u8], } struct LinePtrBytes<'a> { bytes: &'a [u8], } struct LinePtrArray<'a> { bytes: LinePtrBytes<'a>, len: usize, capacity: usize, } struct HeapRecordSpace<'a> { bytes: &'a [u8], base_offset: usize, } struct HeapPage<'a> { header: HeapHeaderRef<'a>, line_pointers: LinePtrArray<'a>, record_space: HeapRecordSpace<'a>, } pub struct HeapPageView<'a> { guard: PageReadGuard<'a>, layout: &'a Layout, }
これらすべてが
'a という正確な同じ生命期を共有しており、他の型によって保持されるこれらの参照のいずれかがその型の寿命を超えないことを意味します。そして、これらのすべてのタイプは BufferFrame の内の PageBytes に保持される同じセットのバイトへの参照です。
PageBytes (BufferFrame によって所有) ┌─────────────────────────────────────────────────────┐ │ ヘッダー (34 バイト) │ │ page_type | slot_count | free_lower | free_upper .. │ ├─────────────────────────────────────────────────────┤ │ ラインポインタ(成長) │ │ [ slot 0 ] [ slot 1 ] [ slot 2 ] ... │ ├─────────────────────────────────────────────────────┤ │ 未使用領域 │ ├─────────────────────────────────────────────────────┤ │ レコード空間(減少) │ │ ... [ tuple 2 ] [ tuple 1 ] [ tuple 0 ] │ └─────────────────────────────────────────────────────┘ │ │ │ HeapHeaderRef<'a> LinePtrArray<'a> HeapRecordSpace<'a> │ │ │ └───────────────┴────────────────────┘ │ HeapPage<'a> ←─────────────────────────┐ │ built from │ HeapPageView<'a> ┌──────────────────────────┐ │ guard: PageReadGuard<'a> │ │ layout: &'a Layout │ └──────────────────────────┘ │ 保持するロックを所有 │ PageBytes in BufferFrame
それらをつなぐのは、
HeapPageView のメソッドで、いくつかの操作がページのバイトに対する解釈済みビューを必要とするたびに HeapPage を構築します。
impl<'a> HeapPageView<'a> { fn build_page(&'a self) -> HeapPage<'a> { HeapPage::new(self.guard.bytes()).unwrap() } pub fn row(&self, slot: SlotId) -> Option<LogicalRow<'_>> { let view = self.build_page(); let mut current = slot; loop { match view.tuple_ref(current)? { // 簡潔にするためのコード省略 } } } } impl<'a> HeapPage<'a> { fn new(bytes: &'a [u8]) -> SimpleDBResult<Self> { // PageKind トrait から共有された解析ロジックを使用 let layout = Self::parse_layout(bytes)?; let header = HeapHeaderRef::new(layout.header); // ヒープ固有の追加検証 let free_upper = header.free_upper() as usize; let page_size = PAGE_SIZE_BYTES as usize; if free_upper < header.free_lower() as usize || free_upper > page_size { return Err("heap page free_upper out of bounds".into()); } let page = Self::from_parts(header, layout.line_ptrs, layout.records, layout.base_offset); assert_eq!( page.slot_count(), header.slot_count() as usize, "slot directory length must match header slot_count" ); Ok(page) } }
クエリ層がデータページから何かを読み取る場合、
HeapPageView を受け取り、HeapPageView の操作で特定の論理的セグメントへのアクセスが必要となる場合、ページのバイトのレイアウトを理解する HeapPage を構築します。
さて、おそらく毎回
HeapPage を再構築するコストについて疑問に思っているかもしれません。実は非常に低コストです。それは完全に算術操作からなり、いくつかの不変条件が満たされていない場合に数少ないパニック(panic)で構成されています。算術操作は CPU が行うのに極めて低コストであり、特に memcpy() 操作と比較した場合です。
すべての CPU 操作は等しいわけではありません。出典: ithare.com, Andrew Kelley の Data Oriented Designに関する演説より
ライトパスからのコピーの排除
前のセクションでは
PageReadGuard、HeapPage、および HeapPageView を見てきましたが、これらはページの読み込み側パス全体を構成します。しかし、Rust にはエイリアシングか排他性変更の原則(aliasing XOR mutability)があり、これは複数の &T または単一の &mut T のいずれかであることを意味します。上記ではすべて &T パスでしたが、&mut T パスも必要です。
/// ピンされたページへの排他的アクセスを提供するライトガーディアン。 pub struct PageWriteGuard<'a> { page: RwLockWriteGuard<'a, PageBytes>, } pub struct HeapPageMut<'a> { header: HeapHeaderMut<'a>, body_bytes: &'a mut [u8], } pub struct HeapPageViewMut<'a> { guard: PageWriteGuard<'a>, layout: &'a Layout, } impl<'a> HeapPageViewMut<'a> { fn build_mut_page(&mut self) -> SimpleDBResult<HeapPageMut<'_>> { HeapPageMut::new(self.guard.bytes_mut()) } pub fn insert_tuple(&mut self, tuple: &[u8]) -> SimpleDBResult<SlotId> { let mut page = self.build_mut_page()?; page.insert(tuple) } }
さて、Rust のエイリアシング規則に従い、
BufferFrame がある場合、読み込みラッチを複数回取得して読み込みパスを構築するか、または書き込みラッチを取得して書き込みパスを構築するかのいずれかを行います。
BufferFrame RwLock<PageBytes> │ ┌──────────┴──────────┐ │ │ read_page() write_page() │ │ RwLockReadGuard RwLockWriteGuard (共有、複数のリーダー) (排他的、1 つのライター) │ │ PageReadGuard PageWriteGuard &'a [u8] &'a mut [u8] │ │ HeapPage HeapPageMut (借用 '&a [u8]) (借用 '&a mut [u8]) │ │ HeapPageView<'a> HeapPageViewMut<'a>
そして、もちろん Rust の借 Checker は使用後無効化(use-after-unpin)をコンパイルエラーにしますが、ランタイムの危険にはしません。
ここで注目すべきもう一つの非対称性があります。
HeapPage はバイトを 3 つのフィールド(ヘッダー、ラインポインタ、レコード空間)に分割していますが、これは読み込みが冓的であり、これらの境界は決してシフトしないためです。
一方、
HeapPageMut はこれをできません:単一の挿入操作は free_lower と free_upper の両方を移動させ、分割されたすべての参照を即座に古くしてしまいます。したがって、HeapPageMut は単一の body_bytes: &mut [u8] を保持し、各操作でヘッダーからサブリージョンを再導出します。Rust はエイリアSED な変更アクセスを防ぎますが、分割点が常にヘッダーと一致するという深い不変条件は設計を通じて強制する必要があります。
ネストされた借用(Nested Borrows)
気づいたでしょうか、これまで私たちは単一の生命期
'a のみを使用してきました。これは意味があることです。なぜなら、私たちはすべて同じセットの基礎となるバイトから借用しているためです。
しかし、実際には私たちが不正確で、コンパイラにいくつかの作業を任せていました。これまでの借用の連鎖は以下の通りです:
PageBytes → RwLock{Read,Write}Guard<'a> → Page{Read,Write}Guard<'a> → HeapPage[Mut]<'a> → HeapPageView[Mut]<'a>
各次の借用は前のものにネストされていますが、コンパイラは生命期の多変数性(variance)により、これらすべてのことを単一の生命期で行うことを許可します。
生命期多変数の核心的は、共有参照は共変(covariant)であり、排他参照は不変(invariant)であるということです。これは別の方法でよく表現されるのですが、
&T は 'a に対して共変で、&mut T は 'a に対して不変です。
共変な生命期の場合、Rust は必要なときにより長い寿命を持つ
&T を短い寿命を持つ &T に短縮することができ、これによりネストされた生命期を省略し、コンパイラが中間の生命期を推測することを可能にします。
変更可能な借用は許容性が低く、変更はそれらの強制(coercions)をより厳密にします。
&mut T の場合、Rust は短い寿命を持つ参照が長い寿命を持つことを約束する場所にある参照を書き込むリスクを避けるために、内部の生命期についてそれほど柔軟には扱うことができません。
この非対称性を示す具体例があります。2 つの生命期を持つ構造体を定義し、関数内で 1 つを短縮しようと試みます:
struct Inner<'a> { data: &'a [u8], } struct Outer<'short, 'long> { inner: &'short Inner<'long>, } fn shorten<'long, 'short>(outer: Outer<'short, 'long>) -> Outer<'short, 'short> { outer }
これはコンパイルされます。
&T は 'long に対して共変であるためです。関数は、データが借用の寿命を超えても参照を強制することができます。
さて、外部参照を可変に変更してみます:
struct Outer<'short, 'long> { inner: &'short mut Inner<'long>, } fn shorten<'long, 'short>(outer: Outer<'short, 'long>) -> Outer<'short, 'short> { outer }
これはコンパイルされません。
&mut T はその生命期パラメータに対して不変であり、データが借用の寿命を超えていても短縮することはできません。生命期は正確に一致する必要があります。
これは
HeapPageViewMut::row_mut() と LogicalRowMut の構築において起こるまさにそれです:
pub struct LogicalRowMut<'row, 'page: 'row> { view: &'row mut HeapPageViewMut<'page>, } impl<'a> HeapPageViewMut<'a> { /// スロットにあるライブのタプルを `LogicalRowMut` にデコードして編集します。 /// 戻り値がドロップされる際に、変更は自動的にページに書き込まれます。 pub fn row_mut<'row>( &'row mut self, slot: SlotId, ) -> SimpleDBResult<Option<LogicalRowMut<'row, 'a>>> { // 簡潔にするためのコード省略 Ok(Some(LogicalRowMut { view: self, slot, values, layout, dirty: false, })) } }
'page は、すでにピンされたページバイトを借用している下位のページビューの生命期です。'row は、そのビューの上での 1 つの変更セッションの短い生命期です。関係 'page: 'row は、ページビューが少なくとも mutable row エディタがそれを借用する期間と同じくらい有効である必要があることを示しています。
BufferFrame のページバイト ┌─────────────────────────────────────────────────────────────┐ │ ヘッダー │ ラインポインタ │ 未使用領域 │ タプル 2 │ タプル 1 │… │ └─────────────────────────────────────────────────────────────┘ <---------------------- 'page 用に借用 ----------------------> row_mut() の呼び出し 1 回 │ ▼ LogicalRowMut<'row, 'page> ┌─────────────────────────┐ │ 論理的なレコードの 1 つを編集 │ └─────────────────────────┘ <---- 'row 用に借用 -----> 制約:'page : 'row
セーフ抽象化のコスト
読み取り用と書き込み用の型への分割は、実際のユーザビリティのコストをもたらします。
HeapPageViewMut は自動的に HeapPageView の読み取りメソッドを取得しません。Rust 標準ライブラリでは、&mut Vec<T> が Deref を通じて自動的に &Vec<T> に強制されるため、可変参照は無償で不変メソッドを利用できます。
これは機能します。なぜなら、
Vec<T> は内部で単一の生ポインタによって支えられているからです。それは Deref<Target=[T]> および DerefMut<Target=[T]> を実装し、どちらか &[T] または &mut [T] を提供します。1 つのポインタから、借 Checker は呼び出しサイトで参照を借用した方法に基づいて共有スライスか排他スライスかを決定します。unsafe な部分は Vec 内部にあり、一度監査され、呼び出し先からは目に見えません。
pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> { buf: RawVec<T, A>, len: usize, } #[stable(feature = "rust1", since = "1.0.0")] impl<T, A: Allocator> ops::Deref for Vec<T, A> { type Target = [T]; #[inline] fn deref(&self) -> &[T] { self.as_slice() } } pub const fn as_slice(&self) -> &[T] { unsafe { slice::from_raw_parts(self.as_ptr(), self.len) } }
私たちの設計はこれを行うことはできません。
PageReadGuard と PageWriteGuard は根本的に異なるタイプ、つまり RwLockReadGuard と RwLockWriteGuard を保持しており、これらを単一の生ポインタに統合することはできません。RwLock は実行時に読み書きの区別を強制するため、コンパイル時にも 2 つの異なる型として反映する必要があります。HeapPageViewMut の読み取りメソッドのいずれかを追加するには、明示的に書かなければなりません。
これは Rust API 設計におけるトレードオフです。unsafe なことはありませんが、明確な能力の分離があり、しかし非対称型の得るようなユーザビリティの良さではありません。
結論
最終的には、パスはこのようになります:
┌─────────────────────────────────────────────────────────┐ │ クエリ層 (Query Layer) │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────┐ │ 実行エンジン (Execution Engine) │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────┐ │ トランザクションマネージャー │ └──────────┬─────────────┴─────────────────┬──────────────┘ │ │ ┌──────────▼──────────┐ ┌────────────▼────────────┐ │ ロックマネージャー│ │ ログマネージャー │ └─────────────────────┘ └─────────────────────────┘ │ バイトに対する借用ビュー ┌────────────────────────▼────────────────────────────────┐ │ バッファプール (Buffer Pool) │ └────────────────────────┬────────────────────────────────┘ │ `O_DIRECT` はこのコピーを除去 ┌────────────────────────▼────────────────────────────────┐ │ ディスク │ └─────────────────────────────────────────────────────────┘
O_DIRECT はディスクとバッファプール間のコピーを除去し、ページ/ビュー設計はバッファプール上にある上位のページオブジェクトをすでにメモリにピン留めされたバイトに対する借用ビューに変換することで、バッファプール以上の鮮やかなコピーを除去します。
私にとって、この設計に興味深い点は、所有権を 1 つの場所、つまりバッファプールに移動させることです。それより上のすべてのものは、同じセットのバイトに対するビューに関するものです。ただし、これは API のユーザビリティのコストを伴います。ゼロコピーページアクセスを構造化して、コンパイラが私が気にする同じ不変条件を見られるようにしなければならなかったからです。生命期の注釈でコードが散在していますが、特定の種類のバグを排除し、冗長なデータ移動を回避します。