アロケータの設計
この記事は翻訳されたものです: この記事はAllocator Designsをコミュニティの手により翻訳したものです。そのため、翻訳が完全・最新でなかったり、原文にない誤りを含んでいる可能性があります。問題があればこのissue上で報告してください!
翻訳者: @swnakamura.
この記事ではヒープアロケータをゼロから実装する方法を説明します。バンプアロケータ、連結リストアロケータ、固定サイズブロックアロケータなどの様々なアロケータの設計を示し、それらについて議論します。3つそれぞれのデザインについて、私たちのカーネルに使える基礎的な実装を作ります。
このブログの内容は GitHub 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。またこちらにコメントを残すこともできます。この記事のソースコード全体はpost-11
ブランチにあります。
目次
🔗はじめに
前回の記事では、カーネルへのヒープ割り当ての基本的なサポートを追加しました。そのために、ページテーブルに新しいメモリ領域を作成し、linked_list_allocator
クレートを使用してそのメモリを管理しました。ヒープは動作するようになりましたが、このアロケータクレートがどのように動作しているのかを理解しようとすることなく、仕事のほとんどを任せてしまっていました。
この記事では、既存のアロケータクレートに頼るのではなく、独自のヒープアロケータをゼロから作成する方法を紹介します。単純無比のバンプアロケータ、基本の固定サイズブロックアロケータなど、さまざまなアロケータの設計について議論し、この知識を使用して(linked_list_allocator
クレートと比較して)より性能のよいアロケータを実装します。
🔗設計目標
アロケータの責任は、利用可能なヒープメモリを管理することです。alloc
が呼ばれたら未使用のメモリを返し、dealloc
によって解放されたメモリが再利用できるように記録をとる必要があります。最も重要なことは、すでに他の場所で使用されているメモリを決して渡してはならないということです。これをすると未定義動作が起きてしまいます。
メモリの正しい管理のほかにも、多くの二次的な設計目標があります。たとえば、アロケータは利用可能なメモリを効果的に利用し、断片化があまり起きないようにすべきです。さらに、並列なアプリケーションにもうまく機能し、任意の数のプロセッサに拡張できなくてはなりません。性能を最大化するため、CPUキャッシュに合わせてメモリレイアウトを最適化し、キャッシュの局所性を改善したりfalse sharingを回避することすらするかもしれません。
これらの要件により、優れたアロケータは非常に複雑になりえます。例えば、jemallocには3万行以上のコードがあります。ここまで複雑なものは、たった一つのバグが深刻なセキュリティ脆弱性につながりうるカーネルコードでは望ましくない場合が多いでしょう。幸いなことに、カーネルのコードにおけるメモリ割り当てのパターンは、ユーザースペースのコードと比較してはるかに単純であることが多いため、比較的単純なアロケータ設計で十分です。
以下では、3つのカーネルアロケータの設計を示し、その長所と短所を説明します。
🔗バンプアロケータ
最も単純なアロケータの設計はバンプアロケータ(スタックアロケータとも呼ばれる)です。メモリを直線的に割り当て、割り当てられたバイト数と割り当ての数のみを管理します。このアロケータは非常に特定のユースケースでのみ有用です──なぜなら、一度にすべてのメモリを解放することしかできないという厳しい制約があるからです。
🔗考え方
バンプアロケータの考え方は、未使用のメモリの開始位置を指すnext
変数を増やす(“bump” する)ことによって、メモリを順に割り当てるというものです。はじめ、next
はヒープの開始アドレスに等しいです。next
は、各割り当てにおいて割り当てサイズだけ増加し、この値が使用済みメモリと未使用メモリの境界を常に指すようにします。
next
ポインタは1つの方向にしか移動しないため、同じメモリ領域を2回渡すことはありません。これがヒープの終わりに達すると、それ以上のメモリを割り当てることができないので、次の割り当てでメモリ不足エラーが発生します。
多くの場合、バンプアロケータは「割り当てカウンタ」付きで実装されます。これは、alloc
の呼び出しのたび1増加し、dealloc
の呼び出しのたび1減少します。割り当てカウンタがゼロになることは、ヒープ上のすべての割り当てが解除されたことを意味します。このとき、next
ポインタをヒープの開始アドレスにリセットし、ヒープメモリ全体を再び割り当てに使えるようにすることができます。
🔗実装
allocator::bump
サブモジュールを宣言するところから実装を始めましょう:
// in src/allocator.rs
pub mod bump;
サブモジュールの内容は、新しいsrc/allocator/bump.rs
ファイルに、以下の内容で作ります:
// in src/allocator/bump.rs
pub struct BumpAllocator {
heap_start: usize,
heap_end: usize,
next: usize,
allocations: usize,
}
impl BumpAllocator {
/// 新しい空のバンプアロケータを作る。
pub const fn new() -> Self {
BumpAllocator {
heap_start: 0,
heap_end: 0,
next: 0,
allocations: 0,
}
}
/// 与えられたヒープ領域でバンプアロケータを初期化する。
///
/// このメソッドはunsafeである。呼び出し元は与えられたメモリ範囲が未使用であることを
/// 保証しなければならない。また、このメソッドは一度しか呼ばれてはならない。
pub unsafe fn init(&mut self, heap_start: usize, heap_size: usize) {
self.heap_start = heap_start;
self.heap_end = heap_start + heap_size;
self.next = heap_start;
}
}
heap_start
フィールドとheap_end
フィールドは、ヒープメモリ領域の下限と上限を管理します。呼び出し元は、これらのアドレスが有効であることを保証する必要があります。そうでない場合、アロケータは不正なメモリを返すでしょう。このため、init
関数の呼び出しはunsafe
でなければなりません。
next
フィールドの目的は、常にヒープの最初の未使用バイト、つまり次の割り当ての開始アドレスを指すことです。最初はヒープ全体が未使用であるため、init
関数ではheap_start
に設定されています。各割り当てで、このフィールドは割り当てサイズだけ増加(“bump”)し、同じメモリ領域を2回返さないようにします。
allocations
フィールドは、有効な割り当ての単純なカウンタで、最後の割り当てが解放されたときにアロケータをリセットするためにあります。0で初期化します。
インターフェイスをlinked_list_allocator
クレートによって提供されるアロケータと同じにするために、初期化をnew
関数の中で直接実行するのではなく、別のinit
関数を作りました。こうすることで、コードの変更なしにアロケータを切り替えることができます。
🔗GlobalAlloc
を実装する
前回の記事で説明したように、すべてのヒープアロケータは、次のように定義されているGlobalAlloc
トレイトを実装する必要があります:
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { ... }
unsafe fn realloc(
&self,
ptr: *mut u8,
layout: Layout,
new_size: usize
) -> *mut u8 { ... }
}
必要なのはalloc
とdealloc
メソッドのみです。他の2つのメソッドにはデフォルト実装があるので省略できます。
🔗最初の実装
BumpAllocator
のalloc
メソッドを実装してみましょう。
// in src/allocator/bump.rs
use alloc::alloc::{GlobalAlloc, Layout};
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// TODO アラインメント・境界のチェック
let alloc_start = self.next;
self.next = alloc_start + layout.size();
self.allocations += 1;
alloc_start as *mut u8
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
todo!();
}
}
まず、割り当ての開始アドレスとしてnext
フィールドを使用します。次に、割り当ての終端アドレス(ヒープの次の未使用アドレスでもある)を指すようにnext
フィールドを更新します。allocations
カウンタを1増やしてから、割り当ての開始アドレスを*mut u8
ポインタとして返します。
境界チェックやアラインメント調整を行わないので、この実装はまだ安全ではないことに注意してください。まあいずれにせよ、以下のエラーでコンパイルに失敗するのでたいした問題ではないのですが:
error[E0594]: cannot assign to `self.next` which is behind a `&` reference
--> src/allocator/bump.rs:29:9
|
29 | self.next = alloc_start + layout.size();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written
(self.allocations += 1
の行でも同じエラーが発生します。簡潔のためにここでは省略しました)
このエラーが起こるのは、GlobalAlloc
トレイトのalloc
およびdealloc
メソッドが不変な&self
参照に対してのみ動作するため、next
フィールドとallocations
フィールドを更新できないために発生します。割り当てで毎回next
を更新することがバンプアロケータの大原則であるため、これは問題ですね。
🔗GlobalAlloc
と可変性
この可変性の問題にどんな解決策が可能かを見る前に、GlobalAlloc
トレイトメソッドがなぜ&self
引数で定義されているのかを考えてみましょう。前回の記事で見たように、グローバルヒープアロケータはGlobalAlloc
トレイトを実装するstatic
に#[global_allocator]
属性を追加することによって定義されます。静的変数はRustでは不変であるため、この静的なアロケータで&mut self
を取るメソッドを呼び出すことはできません。よって、GlobalAlloc
のすべてのメソッドは、不変な&self
参照のみを取ります。
幸いなことに、&self
参照から&mut self
参照を取得する方法があります。アロケータをspin::Mutex
スピンロックでラップすることで、同期された内部可変性を使えるのです。この型は、相互排他制御を行うlock
メソッドを提供し、&self
参照を&mut self
参照に安全に変換します。このラッパ型はカーネルですでに複数回使用しています(VGAテキストバッファなど)。
🔗Locked
ラッパ型
spin::Mutexラッパ型の助けを借りれば、バンプアロケータにGlobalAlloc
トレイトを実装できます。このトレイトをBumpAllocator
に直接実装するのではなく、ラップされたspin::Mutex<BumpAllocator>
型に対して実装するのがミソです。
unsafe impl GlobalAlloc for spin::Mutex<BumpAllocator> {…}
残念ながら、Rustコンパイラは他のクレートで定義された型のトレイト実装を許可していないため、これはまだうまくいきません。
error[E0117]: only traits defined in the current crate can be implemented for arbitrary types
--> src/allocator/bump.rs:28:1
|
28 | unsafe impl GlobalAlloc for spin::Mutex<BumpAllocator> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^--------------------------
| | |
| | `spin::mutex::Mutex` is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
これに対処するためには、spin::Mutex
型をラップする独自の型を作ればよいです:
// in src/allocator.rs
/// トレイト実装を許してもらうための、spin::Mutexをラップする型
pub struct Locked<A> {
inner: spin::Mutex<A>,
}
impl<A> Locked<A> {
pub const fn new(inner: A) -> Self {
Locked {
inner: spin::Mutex::new(inner),
}
}
pub fn lock(&self) -> spin::MutexGuard<A> {
self.inner.lock()
}
}
この型は、spin::Mutex<A>
の汎用ラッパです。ラップされる型A
に制限はないので、アロケータだけでなく、あらゆる種類の型をラップするために使用できます。このラッパは、指定された値をラップする単純なnew
コンストラクタ関数を提供しています。ラップされたMutex
でlock
を呼び出すlock
関数も、便利なので提供しています。Locked
型はとても汎用的であり、他のアロケータの実装にも役立つため、親のallocator
モジュールに入れることにします。
🔗Locked<BumpAllocator>
の実装
Locked
型は(spin::Mutex
とは違って)私たちクレートの中で定義されているため、私たちのバンプアロケータにGlobalAlloc
型を実装するために使用できます。実装の全体は次のようになります:
// in src/allocator/bump.rs
use super::{align_up, Locked};
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr;
unsafe impl GlobalAlloc for Locked<BumpAllocator> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let mut bump = self.lock(); // 可変参照を得る
let alloc_start = align_up(bump.next, layout.align());
let alloc_end = match alloc_start.checked_add(layout.size()) {
Some(end) => end,
None => return ptr::null_mut(),
};
if alloc_end > bump.heap_end {
ptr::null_mut() // メモリ不足
} else {
bump.next = alloc_end;
bump.allocations += 1;
alloc_start as *mut u8
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
let mut bump = self.lock(); // 可変参照を得る
bump.allocations -= 1;
if bump.allocations == 0 {
bump.next = bump.heap_start;
}
}
}
alloc
とdealloc
は両方、まず、inner
フィールドを通じてMutex::lock
メソッドを呼び出し、ラップされたアロケータ型への可変参照を取得します。インスタンスはメソッドの終了までロックされたままであるため、(まもなくスレッドのサポートを追加するのですが)マルチスレッドになってもデータ競合が発生することはありません。
前のプロトタイプと比較してみると、alloc
の実装はアラインメント要件を守るようになっており、割り当てがヒープメモリ領域内にあることを保証するために境界チェックを実行するようになっています。この関数はまず、next
アドレスをLayout
引数で指定されたアラインメントに切り上げます。align_up
関数のコードはすぐ後で示します。次に、要求された割り当てサイズをalloc_start
に足して、割り当ての終端アドレスを得ます。巨大な割り当てが試みられた際に整数のオーバーフローが起きることを防ぐため、checked_add
メソッドを使っています。オーバーフローが発生した場合、または割り当ての終端アドレスがヒープの終端アドレスよりも大きくなる場合、メモリ不足であることを示すためにヌルポインタを返します。それ以外の場合は、以前のように、next
アドレスを更新し、allocations
カウンタを1増やします。最後に、*mut u8
ポインタに変換されたalloc_start
アドレスを返します。
dealloc
関数は、指定されたポインタとLayout
引数を無視します。代わりに、単にallocations
カウンターを減らします。カウンターが0
に戻ったなら、それはすべての割り当てが再び解放されたことを意味します。このとき、next
アドレスをheap_start
アドレスにリセットして、ヒープメモリ全体を再び使用できるようにします。
🔗アドレスのアラインメント
align_up
関数の用途は広いので、親のallocator
モジュールに入れてもよいでしょう。基本的な実装は以下のようになります:
// in src/allocator.rs
/// 与えられたアドレス`addr`を`align`に上丸めする
fn align_up(addr: usize, align: usize) -> usize {
let remainder = addr % align;
if remainder == 0 {
addr // addr はすでに丸められていた
} else {
addr - remainder + align
}
}
この関数はまず、align
でaddr
を割った余りを計算します。余りが0
の場合、アドレスはすでに指定されたアラインメントに丸められているということです。それ以外の場合は、(余りが0になるように)余りを引いてアドレスをアラインし、(アドレスが元のアドレスよりも小さくならないように)アラインメントを足します。
実は、これはこの関数を実装する最も効率的な方法ではありません。はるかに高速な実装は次のようになります:
/// 与えられたアドレス`addr`を`align`に上丸めする
///
/// `align`は2の累乗でなければならない
fn align_up(addr: usize, align: usize) -> usize {
(addr + align - 1) & !(align - 1)
}
この方法では、align
が2の累乗である必要がありますが、これはGlobalAlloc
トレイト(およびそのLayout
パラメータ)を利用するならば保証されています。この場合、非常に効率的にアドレスを揃えるためのビットマスクを作成できます。その原理を理解するために、式の右側から一つずつ見ていきましょう:
align
は2の累乗であるため、その2進数表現は1つのビットのみが1であるはずである(例:0b000100000
)。これは、align - 1
ではそれより下位のすべてのビットが1であることを意味する(例:0b000011111
)。!
演算子すなわちビットごとのNOT
を行うことで、「align
より下位のビット」以外がすべて1であるような数字を得ることができる(例:0b…111111111100000
)- あるアドレスと
!(align - 1)
の間でビットごとのAND
を行うことで、アドレスを下向きにアラインする。なぜなら、align
よりも小さいビットがすべて0になるからである。 - 下向きではなく上向きにアラインしたいので、ビットごとの
AND
の前にaddr
をalign - 1
だけ増やしておく。こうすると、すでにアラインされているアドレスには影響がないが、アラインされていないアドレスは次のアラインメント境界に丸められるようになる。
どちらの実装を使うかは自由です。結果は同じで、計算方法が違うだけです。
🔗使ってみる
linked_list_allocator
クレートの代わりにバンプアロケータを使うには、allocator.rs
のALLOCATOR
静的変数を更新する必要があります:
// in src/allocator.rs
use bump::BumpAllocator;
#[global_allocator]
static ALLOCATOR: Locked<BumpAllocator> = Locked::new(BumpAllocator::new());
ここで、BumpAllocator::new
とLocked::new
をconst
関数として宣言しておいたことが効いてきます。static
の初期化式はコンパイル時に評価可能でなければならないため、もしそれらが通常の関数だったならコンパイルエラーが発生していたでしょう。
バンプアロケータはlinked_list_allocator
によって提供されるアロケータと同じインターフェイスを提供するため、init_heap
関数のALLOCATOR.lock().init(HEAP_START, HEAP_SIZE)
呼び出しを変更する必要はありません。
これで、私たちのカーネルはバンプアロケータを使うようになりました! 前回の記事で作ったheap_allocation
のテストを含め、すべての機能がうまくいくはずです。
> cargo test --test heap_allocation
[…]
Running 3 tests
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
🔗議論
バンプアロケータの大きな利点は、非常に速いことです。alloc
やdealloc
のたびにサイズの合うメモリを動的に探索し様々な管理タスクを行う必要があるほかのアロケータの設計(後述)に比べると、バンプアロケータはたった数個のアセンブリ命令に最適化することができるのですから。これによりバンプアロケータは、メモリ割り当ての性能を最大化したいとき、例えば仮想DOMライブラリを作成したいときなどに役に立ちます。
バンプアロケータがグローバルアロケータとして使われることはまれですが、バンプアロケーションの原理はしばしばアリーナアロケーションの形で使われます。これは要するに割り当てをバッチにまとめることで性能を上げるというものです。Rustにおけるアリーナアロケータの例はtoolshed
クレートに含まれています。
🔗バンプアロケータの欠点
バンプアロケータの主な制約は、すべてのメモリ割り当てが解放されないと割り当て解除されたメモリを再利用できないことです。これは、たった一つでも「寿命の長い」割り当てがあると、メモリの再利用ができなくなってしまうことを意味します。many_boxes
テストを少し変更したものを追加すると、それが起こるのを見ることができます。
// in tests/heap_allocation.rs
#[test_case]
fn many_boxes_long_lived() {
let long_lived = Box::new(1); // ここを追加
for i in 0..HEAP_SIZE {
let x = Box::new(i);
assert_eq!(*x, i);
}
assert_eq!(*long_lived, 1); // ここを追加
}
many_boxes
テストと同様、このテストは大量の割り当てを行うことで、アロケータが解放されたメモリを再利用できていない場合にメモリ不足エラーを引き起こします。さらに、このテストではループの間ずっと存在しているlong_lived
という割り当てを追加しています。
この新しいテストを実行しようとすると、確かに失敗することがわかります:
> cargo test --test heap_allocation
Running 4 tests
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
many_boxes_long_lived... [failed]
Error: panicked at 'allocation error: Layout { size_: 8, align_: 8 }', src/lib.rs:86:5
この失敗が発生する理由を詳しく理解してみましょう。まず、ヒープの先頭に変数long_lived
の割り当てが作成され、allocations
カウンタが1増加します。ループの反復ごとに、一時的な割り当てが作成され、次の反復が始まる前にすぐ解放されます。これは、allocations
カウンタが反復の開始時に一時的に2に増加し、終了時に1に減少することを意味します。問題は、バンプアロケータはすべての割り当てが解放された時、つまりallocations
カウンタが0に減ったときにのみメモリを再利用できるということです。これはループの間には起こらないため、各ループ反復で新しいメモリ領域が割り当てられ、結果として大量の反復の後にメモリ不足エラーを引き起こします。
🔗テストを成功させるには
このテストを成功させるために、私たちのバンプアロケータに行える工夫が二つほど考えられます:
dealloc
を更新し、解放されたメモリが前回のalloc
によって返されたものであるかを、その終端アドレスとnext
ポインタを比較することでチェックするようにします。もし等しいなら、next
を解放された割り当ての先頭に戻しても大丈夫でしょう。こうすれば、それぞれの反復は同じメモリブロックを使うようになります。- ヒープの末尾からメモリを割り当てていく
alloc_back
メソッドと、そのためのnext_back
フィールドを追加するという方法もあります。長期間生存する割り当てには手動でこちらを使うようにすることで、ヒープ上における短期間の割り当てと長期間の割り当てを分離するのです。この「分離」は、どの割り当てがどのくらい生存するか事前にわかっていないと使えないということに注意してください。また、割り当てを手動で行うのは面倒だしunsafeかもしれないという欠点もあります。
どちらのアプローチでもテストを成功させられますが、非常に限られたケースでしかメモリを再利用できないため、一般的な解決策とはいえません。問題は、解放されたすべてのメモリを再利用する一般的な解決策はあるのか、ということです。
🔗解放されたすべてのメモリを再利用するには?
前回の記事で学んだように、割り当ては任意の期間生存する可能性があり、どのような順序でも解放されえます。これは、次の例に示すように、個数に上限のない、非連続な未使用メモリ領域を管理する必要があることを意味します:
この図は、ヒープの経時変化を示しています。最初は、ヒープ全体が未使用で、next
アドレスはheap_start
に等しいです(1行目)。その後、最初の割り当てが行われます(2行目)。3行目では、2つ目のメモリブロックが割り当てられ、最初の割り当ては解放されています。4行目ではたくさんの割り当てが追加されています。それらの半分は非常に短命であり、すでに5行目では解放されていますが、この行では新しい割り当ても追加されています。
5行目が根本的な問題を示しています:サイズの異なる未使用のメモリ領域が5つありますが、next
ポインタはそのうち最後の領域の先頭を指すことしかできません。たとえば今回なら、長さ4の配列に、ほかの未使用メモリ領域の開始アドレスとサイズを保存することはできます。しかし、未使用メモリ領域の数が8個とか16個、1000個にもなる例だって簡単に作れてしまうので、これは一般的な解決策ではありません。
普通、要素数に上限がないときは、ヒープに割り当てられたコレクションを使ってしまえばいいです。これは私たちの場合には実際には不可能です──なぜなら、ヒープアロケータが自分自身に依存するのは不可能ですから(無限再帰やデッドロックを起こしてしまうでしょう)。なので別の解決策を見つける必要があります。
🔗連結リストアロケータ
アロケータを実装する際、任意の数の空きメモリ領域を管理するためによく使われる方法は、これらの領域自体を管理領域として使用することです。この方法は、未使用メモリ領域もまた仮想アドレスにマッピングされており、対応する物理フレームも存在しはするが、そこに保存された情報はもはや必要ない、ということを利用します。解放された領域に関する情報をそれらの領域自体に保存することで、追加のメモリを必要とせずにいくらでも解放された領域を管理できます。
最もよく見られる実装方法は、解放されたメモリの中に、各ノードが解放されたメモリ領域であるような一つの連結リストを作るというものです:
リストの各ノードには、メモリ領域のサイズと次の未使用メモリ領域へのポインタの2つのフィールドが含まれています。このアプローチでは、未使用領域がいくつあろうと、そのすべてを最初の未使用領域(head
と呼ばれる)へのポインタだけで管理できます。結果として生じるこのデータ構造は、しばしばフリーリストと呼ばれます。
名前から想像がつくかもしれませんが、この方法はlinked_list_allocator
クレートが使用しているものです(訳注:連結リストアロケータはlinked list allocatorの訳)。このテクニックを使用するアロケータは、しばしばプールアロケータとも呼ばれます。
🔗実装
以下では、解放されたメモリ領域を管理するために上記の方法を使用する、独自のシンプルなLinkedListAllocator
型を作成します。記事のこの部分は今後の記事には必要ありませんので、実装の詳細を飛ばしていただいてもかまいません。
🔗アロケータ型
まず、新しいallocator::linked_list
サブモジュールの中に非公開のListNode
構造体を作ることから始めましょう:
// in src/allocator.rs
pub mod linked_list;
// in src/allocator/linked_list.rs
struct ListNode {
size: usize,
next: Option<&'static mut ListNode>,
}
図に示したように、リストのノードはsize
フィールドと、次のノードへのオプショナルなポインタを持ちます。後者はOption<&'static mut ListNode>
型によって表されます。&'static mut
型はポインタで指されている所有されたオブジェクトを意味します。要するに、スコープの終了時にオブジェクトを解放するデストラクタを持たないようなBox
型です。
以下のListNode
のメソッドを実装します:
// in src/allocator/linked_list.rs
impl ListNode {
const fn new(size: usize) -> Self {
ListNode { size, next: None }
}
fn start_addr(&self) -> usize {
self as *const Self as usize
}
fn end_addr(&self) -> usize {
self.start_addr() + self.size
}
}
この型はnew
という単純なコンストラクタ関数を持ち、表現する領域の開始・終端アドレスを計算するメソッドを持っています。new
関数はconst関数としていますが、これは後で静的な連結リストアロケータを作る際に必要になるためです。const関数においては、あらゆる可変参照の使用(next
フィールドをNone
にすることも含め)はunstableであることに注意してください。コンパイルを通すためには、#![feature(const_mut_refs)]
をlib.rs
の最初に追加する必要があります。
ListNode
構造体を部品として使うことで、LinkedListAllocator
構造体を作ることができます:
// in src/allocator/linked_list.rs
pub struct LinkedListAllocator {
head: ListNode,
}
impl LinkedListAllocator {
/// 空のLinkedListAllocatorを作る。
pub const fn new() -> Self {
Self {
head: ListNode::new(0),
}
}
/// 与えられたヒープ境界でアロケータを初期化する。
///
/// この関数はunsafeである。なぜなら、呼び出し元は渡すヒープ境界が
/// 有効でヒープが未使用であることを保証しなければならないからである。
/// このメソッドは一度しか呼ばれてはならない。
pub unsafe fn init(&mut self, heap_start: usize, heap_size: usize) {
self.add_free_region(heap_start, heap_size);
}
/// 与えられたメモリ領域をリストの先頭に追加する。
unsafe fn add_free_region(&mut self, addr: usize, size: usize) {
todo!();
}
}
この構造体は、最初のヒープ領域を指すhead
ノードを持っています。ここではnext
ポインタの値にしか興味がないので、ListNode::new
関数ではsize
を0にしてしまいます。head
を単に&'static mut ListNode
にするのではなくListNode
にすると、alloc
メソッドの実装が単純にできるというメリットがあります。
バンプアロケータと同じように、new
関数はアロケータをヒープ境界で初期化したりはしません。この理由は、APIの互換性を保つためというのに加え、初期化ルーチンがノードをヒープメモリに書き込む必要があり、これは実行時にしか行えないということがあります。new
関数はALLOCATOR
静的変数を初期化するのに使われるので、const
関数すなわちコンパイル時に評価できる関数である必要があります。この理由によって、ここでも、非constなinit
メソッドを別に提供しているというわけです。
init
メソッドはadd_free_region
メソッドを使っていますが、この実装はすぐ後で示します。今のところは、todo!
マクロを実装の代わりに置いておいて、常にパニックするようにしておきましょう。
🔗add_free_region
メソッド
add_free_region
メソッドは連結リストの最も基本的な操作であるプッシュ操作を提供します。今はこのメソッドはinit
からしか呼んでいませんが、このメソッドは私たちがdealloc
を実装する際にも中心的な役割を果たします。dealloc
メソッドは割り当てられたメモリ領域が解放されたときに呼ばれるのだということを思い出してください。その解放されたメモリ領域を管理するために、それを連結リストにプッシュする必要があるのです。
add_free_region
メソッドの実装は以下のようになります:
// in src/allocator/linked_list.rs
use super::align_up;
use core::mem;
impl LinkedListAllocator {
/// 与えられたメモリ領域をリストの先頭に追加する。
unsafe fn add_free_region(&mut self, addr: usize, size: usize) {
// 解放された領域がListNodeを格納できることを確かめる
assert_eq!(align_up(addr, mem::align_of::<ListNode>()), addr);
assert!(size >= mem::size_of::<ListNode>());
// 新しいリストノードを作り、それをリストの先頭に追加する
let mut node = ListNode::new(size);
node.next = self.head.next.take();
let node_ptr = addr as *mut ListNode;
node_ptr.write(node);
self.head.next = Some(&mut *node_ptr)
}
}
このメソッドはメモリ領域のアドレスと大きさを引数として取り、リストの先頭にそれを追加します。まず、与えられた領域がListNode
を格納するのに必要なサイズとアラインメントを満たしていることを確認します。次に、ノードを作成し、それを以下のようなステップでリストに追加します:
Step 0はadd_free_region
が呼ばれる前のヒープの状態を示しています。Step 1では、add_free_region
メソッドが図においてfreed
と書かれているメモリ領域で呼ばれました。初期チェックを終えると、このメソッドはOption::take
メソッドを使ってノードのnext
ポインタを現在のhead
ポインタに設定し、これによってhead
ポインタはNone
に戻ります。
Step 2では、このメソッドは新しく作られたnode
をwrite
メソッドを使って解放されたメモリ領域の先頭に書き込みます。次にhead
ポインタがこの新しいノードを指すようにします。解放された領域は常にリストの先頭に挿入されていくので、結果として生じるポインタ構造はいささか混沌としているように思われますが、head
ポインタからポインタをたどっていけば、解放されたそれぞれの領域に到達できるというのには変わりありません。
🔗find_region
メソッド
連結リストの二つ目の基本操作は要素を探してリストからそれを取り除くことです。これはalloc
メソッドの実装の中核となる操作です。この操作をfind_region
メソッドとして以下のように実装しましょう:
// in src/allocator/linked_list.rs
impl LinkedListAllocator {
/// 与えられたサイズの解放された領域を探し、リストからそれを
/// 取り除く。
///
/// リストノードと割り当ての開始アドレスからなるタプルを返す。
fn find_region(&mut self, size: usize, align: usize)
-> Option<(&'static mut ListNode, usize)>
{
// 現在のリストノードへの参照。繰り返しごとに更新していく
let mut current = &mut self.head;
// 連結リストから十分大きな領域を探す
while let Some(ref mut region) = current.next {
if let Ok(alloc_start) = Self::alloc_from_region(®ion, size, align) {
// 領域が割り当てに適している -> リストから除く
let next = region.next.take();
let ret = Some((current.next.take().unwrap(), alloc_start));
current.next = next;
return ret;
} else {
// 割り当てに適していない -> 次の領域で繰り返す
current = current.next.as_mut().unwrap();
}
}
// 適した領域が見つからなかった
None
}
}
このメソッドはcurrent
変数とwhile let
ループを使ってリストの各要素に関して反復を行っています。はじめ、current
は(ダミーの)head
ノードに設定されています。繰り返しごとに(else
ブロックで)これは現在のノードのnext
フィールドへと更新されます。領域が与えられたサイズとアラインメントの割り当てに適しているなら、その領域がリストから取り除かれてalloc_start
アドレスとともに返されます。
current.next
ポインタがNone
になった場合、ループから抜けます。これは、リスト全体を反復したものの割り当てに適した領域が見つからなかったことを意味します。その場合None
を返します。領域が適しているか否かはalloc_from_region
によってチェックされていますが、この関数の実装はすぐに示します。
適した領域がリストから除かれる様子をもう少し詳しく見てみましょう:
Step 0はポインタに修正を行う前の状況を表しています。region
とcurrent
という領域と、region.next
とcurrent.next
というポインタが図中に示されています。Step 1では、region.next
とcurrent.next
ポインタがOption::take
メソッドによってNone
に戻されています。ポインタの元の値はnext
とret
というローカル変数に格納されています。
Step 2では、ポインタcurrent.next
がローカル変数であるポインタnext
(元々はregion.next
ポインタだったもの)に設定されています。これにより、current
はregion
の次の領域を指すようになっているので、region
はもはやこの連結リストの要素ではありません。この関数はその後、ローカル変数ret
に格納されていたregion
へのポインタを返します。
🔗alloc_from_region
関数
alloc_from_region
関数は領域が与えられたサイズとアラインメントの割り当てに適しているかどうかを返します。以下のように定義されます:
// in src/allocator/linked_list.rs
impl LinkedListAllocator {
/// 与えられた領域で与えられたサイズとアラインメントの
/// 割り当てを行おうとする。
///
/// 成功した場合、割り当ての開始アドレスを返す。
fn alloc_from_region(region: &ListNode, size: usize, align: usize)
-> Result<usize, ()>
{
let alloc_start = align_up(region.start_addr(), align);
let alloc_end = alloc_start.checked_add(size).ok_or(())?;
if alloc_end > region.end_addr() {
// 領域が小さすぎる
return Err(());
}
let excess_size = region.end_addr() - alloc_end;
if excess_size > 0 && excess_size < mem::size_of::<ListNode>() {
// 領域の残りが小さすぎてListNodeを格納できない(割り当ては
// 領域を使用部と解放部に分けるので、この条件が必要)
return Err(());
}
// 領域は割り当てに適している
Ok(alloc_start)
}
}
まず、この関数は行おうとしている割り当ての開始・終端アドレスを、先ほど定義したalign_up
関数とchecked_add
メソッドを使って計算します。オーバーフローが起こったり、(割り当ての)終端アドレスが領域の終端アドレスよりも後ろにあったりした場合は、割り当ては領域に入りきらないのでエラーを返します。
その後でこの関数は、必要な理由がやや分かりにくいチェックを行っています。このチェックが必要になるのは、多くの場合適した領域にも割り当てがぴったりフィットするわけではないので、割り当て後も一部の領域が使用可能なままになるからです。領域のこの部分は割り当て後も自分自身のListNode
を格納しなければならないので、それが可能なくらいのサイズがないといけません。このチェックはまさにそれを確かめています:割り当てが完璧にフィットするか(excess_size == 0
)、またはListNode
を格納するのに十分超過領域が大きいかを調べています。
🔗GlobalAlloc
を実装する
add_free_region
とfind_region
メソッドによって基本となる操作が提供されたので、ついにGlobalAlloc
トレイトを実装することができます。バンプアロケータの時と同じように、このトレイトをLinkedListAllocator
に直接実装するのではなく、ラップされたLocked<LinkedListAllocator>
に実装するようにします。Locked
ラッパはスピンロックによって内部可変性を追加するので、これにより&self
参照しか取らないalloc
やdealloc
メソッドでもアロケータを変更できるようになります。
実装は以下のようになります:
// in src/allocator/linked_list.rs
use super::Locked;
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr;
unsafe impl GlobalAlloc for Locked<LinkedListAllocator> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// レイアウト調整を行う
let (size, align) = LinkedListAllocator::size_align(layout);
let mut allocator = self.lock();
if let Some((region, alloc_start)) = allocator.find_region(size, align) {
let alloc_end = alloc_start.checked_add(size).expect("overflow");
let excess_size = region.end_addr() - alloc_end;
if excess_size > 0 {
allocator.add_free_region(alloc_end, excess_size);
}
alloc_start as *mut u8
} else {
ptr::null_mut()
}
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// レイアウト調整を行う
let (size, _) = LinkedListAllocator::size_align(layout);
self.lock().add_free_region(ptr as usize, size)
}
}
dealloc
メソッドのほうが単純なのでこちらから見ていきましょう:このメソッドではまず、何かしらのレイアウト調整(すぐ後で説明します)を行っています。その次に、&mut LinkedListAllocator
という参照をLocked
ラッパのMutex::lock
関数を呼ぶことによって取得します。最後に、add_free_region
関数で割り当て解除された領域をフリーリストに追加します。
alloc
メソッドはもう少し複雑です。(dealloc
と)同じようにレイアウト調整を行い、Mutex::lock
でアロケータの可変参照を得るところから始めます。次にfind_region
メソッドを使って割り当てに適したメモリ領域を見つけ、それをリストから取り除きます。これが成功せずNone
が返された場合、適したメモリ領域がないため、(このメソッドは)null_mut
を返すことでエラーを表します。
成功した場合、find_region
メソッドは(リストからすでに除かれた)適した領域と、割り当ての開始アドレスからなるタプルを返します。(それを受け、alloc
は)alloc_start
と割り当てのサイズ、および領域の終端アドレスを使うことで、割り当ての終端アドレスと超過サイズを再び計算します。もし超過サイズがゼロでないなら、add_free_region
を呼んでメモリ領域の超過サイズをフリーリストに戻します。最後に、alloc_start
アドレスを*mut u8
ポインタにキャストして返します。
🔗レイアウト調整
……で、alloc
とdealloc
両方の最初に行っていたレイアウト調整はいったい何なのでしょうか? これらは、それぞれの割り当てブロックがListNode
を格納することができることを保証しているのです。これが重要なのは、このメモリブロックはいつか割り当て解除されることになるので、そのときそこにListNode
を書き込む必要が出てくるからです。ブロックがListNode
より小さかったり正しいアラインメントがなされていなかったりすると、未定義動作につながります。
レイアウト調整はsize_align
関数によって行われています。この定義は以下のようになっています:
// in src/allocator/linked_list.rs
impl LinkedListAllocator {
/// 与えられたレイアウトを調整し、割り当てられるメモリ領域が
/// `ListNode`を格納することもできるようにする。
///
/// 調整されたサイズとアラインメントをタプルとして返す。
fn size_align(layout: Layout) -> (usize, usize) {
let layout = layout
.align_to(mem::align_of::<ListNode>())
.expect("adjusting alignment failed")
.pad_to_align();
let size = layout.size().max(mem::size_of::<ListNode>());
(size, layout.align())
}
}
まず、この関数は渡されたLayout
のalign_to
メソッドを使って、そのアラインメントをListNode
のアラインメントにまで(必要なら)増やします。次にpad_to_align
メソッドを使って、レイアウトのサイズがアラインメントの倍数であるようにし、次のメモリブロックのアラインメントもまたListNode
を格納できる適切なものになるようにします。
次に、max
メソッドによって割り当てが最低でもmem::size_of::<ListNode>
の大きさになるようにします。こうしておけば、dealloc
関数は安心してListNode
を解放されたメモリブロックに書き込むことができます。
🔗使ってみる
今や、allocator
モジュール内のALLOCATOR
静的変数を新しいLinkedListAllocator
で置き換えられます:
// in src/allocator.rs
use linked_list::LinkedListAllocator;
#[global_allocator]
static ALLOCATOR: Locked<LinkedListAllocator> =
Locked::new(LinkedListAllocator::new());
init
関数はバンプアロケータでも連結リストアロケータでも同じ振る舞いをするようにしたので、init_heap
内におけるinit
関数の呼び出しを修正する必要はありません。
heap_allocation
テストをもう一度実行すると、バンプアロケータでは失敗していたmany_boxes_long_lived
テストを含めすべてのテストをパスします:
> cargo test --test heap_allocation
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
many_boxes_long_lived... [ok]
これは、私たちの連結リストアロケータが、二つ目以降の割り当てのメモリが解放されたときも、それを再利用できていることを示しています。
🔗議論
解放されたメモリをすぐに再利用できるため、連結リストアロケータは汎用のアロケータとしてバンプアロケータよりはるかに優れています。しかし欠点もあります。そのうちいくつかは私たちの実装が高度でないために起きているのですが、アロケータの設計自体にも根本的な欠点があるのです。
🔗解放されたブロックを結合する
私たちの実装の大きな問題は、ヒープをより小さなブロックへと分割してはいくものの、それらを結合し直すことは全くやっていないことです。次の例を考えてみましょう:
最初の行では、ヒープ上に三つの割り当てが作られています。2行目ではそのうち2つが、3行目では3つ目が解放されています。今やヒープ全体が未使用状態に戻ったわけですが、まだ4つの別々のブロックに分かれたままです。この時点で、4つのブロックどれもサイズが足らず、巨大な割り当てが不可能ということがあり得るかもしれません。時間がたち、このプロセスがさらに続くと、ヒープはさらに小さいブロックへと分割されています。いつかのタイミングで、ヒープがあまりにも断片化したせいで、普通の割り当てすら失敗するようになってしまうでしょう。
この問題を解決するためには、隣り合う解放されたブロックを結合する必要があります。上の例の場合、以下を意味します:
図中の2
の行では、以前のように、3つの割り当てのうち2つが解放されています。ここで、ヒープを断片化したままにしておくのではなく、追加で2a
のステップを行って右端の二つのブロックを結合して一つに戻しましょう。3
行目では(以前のように)3つめの領域が解放され、3つの異なるブロックで表される完全に未使用のヒープができました。追加で3a
の結合ステップを行い、これらの隣り合ったブロックを結合して一つに戻します。
linked_list_allocator
クレートはこのような結合戦略を以下のように実装しています:deallocate
にて、解放されたメモリブロックを連結リストの先頭に入れるかわりに、リストを常に開始アドレスでソートされた状態にしておくのです。こうすると、deallocate
関数の呼び出しが行われたときに、リスト内で隣り合うブロックのアドレスとサイズを調べることで、結合を即座に行うことができます。もちろん、このようにすると割り当て解除操作は遅くなってしまいますが、上で見たようなヒープの断片化は防ぐことができます。
🔗性能
前述したように、バンプアロケータはとんでもなく速く、ほんの数個のアセンブリ命令に最適化することができます。これらと比べると、連結リストアロケータの性能はずっと悪いです。問題は、割り当ての要求に対し、適したブロックが見つかるまで連結リスト全体を調べ上げる必要があるかもしれないことです。
リスト長は未使用のメモリブロックの数によって決まるので、プログラムごとに性能は大きく変わりえます。いくつかしか割り当てを行わないプログラムは、割り当ての性能が比較的よいと感じることでしょう。しかし、大量の割り当てでヒープを断片化させてしまうプログラムの場合、連結リストがとても長くなり、そのほとんどがとても小さなブロックしか持たないということになるので、割り当ての性能は非常に悪くなってしまうでしょう。
この性能の問題は、私たちの実装が簡素なせいで起きているのではなく、連結リストを使った方法の根本的な問題であるということに注意してください。アロケータの性能はカーネルレベルのコードにとって非常に重要になるので、ここからは第三のアプローチ──性能を向上する代わりに、メモリの利用効率を犠牲にするもの──を見ていきましょう。
🔗固定サイズブロックアロケータ
以下では、割り当ての要求を遂行するために固定サイズのメモリブロックを使うアロケータの設計を示します。こうすると、アロケータはしばしば必要なものより大きなブロックを返すので、内部断片化によるメモリの無駄が発生します。いっぽうで、適切なブロックを見つけるのに必要な時間が(連結リストアロケータと比べて)激減するので、割り当ての性能はずっとよくなります。
🔗導入
固定サイズブロックアロケータの背後にある発想は以下のようなものです:要求された量ぴったりのメモリを返す代わりに、いくつかのブロックサイズを決めて、割り当てのサイズを次のブロックサイズに切り上げるようにするのです。たとえば、ブロックサイズを16, 64, 512バイトとしたら、4バイトの割り当ては16バイトのブロックを、48バイトの割り当ては64バイトのブロックを、128バイトの割り当ては512バイトのブロックを返します。
連結リストアロケータと同じように、未使用メモリ部に連結リストを作ることによって未使用メモリを管理します。しかし、様々なブロックサイズのブロックを持つ一つのリストを使うのではなく、それぞれのサイズクラスごとに別のリストを作ります。それぞれのリストは一つのサイズのブロックのみを格納するのです。例えば、ブロックサイズが16, 64, 512のとき、3つの別々の連結リストがメモリ内にできます:
.
head
ポインタも一つではなく、head_16
, head_64
, head_512
という、対応するサイズの最初の未使用ブロックを指す3つのポインタがあることになります。一つのリスト内のノードはすべて同じサイズです。たとえば、head_16
ポインタから始まるリストには16バイトのブロックのみが含まれます。これが意味するのは、ヘッドポインタの名前でそれぞれのリストのノードサイズは指定されているので、ノード内にそれらを格納する必要はないということです。
リスト内のそれぞれの要素は同じサイズを持っているので、割り当ての要求に要素が適しているかはすべての要素について同じです。これは、以下の手順をとることで非常に効率的に割り当てを行えるということを意味します:
- 要求された割り当てサイズを次のブロックサイズに切り上げる。たとえば、上の例で12バイトの割り当てが要求されたら、ブロックサイズを16バイトとする。
- リストのヘッドポインタを手に入れる。ブロックサイズが16なら、
head_16
を使う。 - リストから最初のブロックを取り除きそれを返す。
注目すべきは、常にリストの最初の要素を返せばよく、リスト全体を走査する必要はないということです。よって、連結リストアロケータに比べて割り当てはずっと高速になります。
🔗ブロックサイズと無駄になるメモリ
ブロックサイズの決め方によっては、切り上げによって多くのメモリを失うことになります。例えば、128バイトの割り当てに対し512バイトのブロックが返されるとき、割り当てられたメモリの3/4は使われません。適切なブロックサイズを使うことで、無駄になるメモリの量をある程度にまで減らすことはできます。例えば、ブロックサイズとして2の累乗(4, 8, 16, 32, 64, 128, ……)を使うと、無駄になるメモリを最悪でもメモリサイズの半分、平均してメモリサイズの1/4とすることができます。
ブロックサイズをプログラムにおいてよく使われるサイズに基づいて最適化するというのも、よく行われます。例えば、24バイトのメモリ割り当てをよく行うプログラムにおけるメモリ効率を向上するため、24バイトのブロックサイズを追加することができるでしょう。このように、無駄になるメモリの量はしばしば性能上の利点を失うことなく減らすことができます。
🔗割り当て解除
割り当てと同様、割り当ての解除もとても重要です。以下の手順をとります:
- 解放された割り当てサイズを次のブロックサイズに切り上げる。これが必要になるのは、コンパイラが
dealloc
に渡してくるのは要求したときの割り当てサイズであり、alloc
によって返されたブロックのサイズではないためである。alloc
とdealloc
で同じサイズ修正関数を使うことで、正しい量のメモリを解放していることは保証される。 - リストのヘッドポインタを手に入れる。
- ヘッドポインタを更新することで、解放されたブロックをリストの先頭に追加する。
注目すべきは、割り当て解除においてもリストの走査は必要ないということです。これが意味するのは、dealloc
に必要な時間はリスト長によらず一定だということです。
🔗代替アロケータ
(2KBを超えるような)大きな割り当ては、とくにオペレーティングシステムのカーネルにおいては珍しいことが多いので、そのような割り当てに対しては代替のアロケータを使うのがよいかもしれません。例えば、2048バイトより大きな割り当てに対してはメモリの無駄を減らすために連結リストアロケータにフォールバックするのです。そのようなサイズの割り当ての数は非常に少ないはずなので、連結リストの長さが長くなることはなく、割り当て・割り当ての解除も比較的速くできるでしょう。
🔗新しいブロックを作る
上では、リスト内には特定のサイズのブロックがつねに十分あり、すべての割り当ての要求を満足できることを仮定していました。しかし、いつかの時点で、あるブロックサイズの連結リストが空になってしまうでしょう。そのとき、割り当ての要求を満足するために特定のサイズの未使用ブロックを作り出す方法が二つ考えられます:
- 代替アロケータ(もしあるなら)から新しいブロックを割り当てる
- 別のリストからより大きなブロックを持ってきて、それを分割する。この方法は、ブロックサイズが2の累乗であるときに最もうまくいく。例えば、32バイトのブロックは二つの16バイトのブロックに分割できる。
実装がずっと簡単になるので、私たちの実装では代替アロケータから新しいブロックを割り当てることにしましょう。
🔗実装
固定サイズブロックアロケータの仕組みを理解したので、実装を始めることができます。以前のパートで作成した連結リストアロケータの実装は使わないので、もし連結リストアロケータの実装部分を飛ばしていたとしても、この部分は読み進めることができます。
🔗リストノード
実装は、新しいallocator::fixed_size_block
モジュールにListNode
型を作るところから始めましょう。
// in src/allocator.rs
pub mod fixed_size_block;
// in src/allocator/fixed_size_block.rs
struct ListNode {
next: Option<&'static mut ListNode>,
}
この型は連結リストアロケータの実装におけるListNode
型と似ていますが、size
フィールドがありません。固定サイズブロックアロケータにおいては、リスト内のすべてのブロックが同じサイズを持つため、必要ないのです。
🔗ブロックサイズ
つぎに、私たちの実装におけるブロックサイズをもつ定数スライスBLOCK_SIZES
を定義します:
// in src/allocator/fixed_size_block.rs
/// 使用するブロックサイズ。
///
/// これらは2の累乗でなければならない。なぜなら、これらは
/// (2の累乗でなければならない)ブロックのアラインメントとしても使われるからである。
const BLOCK_SIZES: &[usize] = &[8, 16, 32, 64, 128, 256, 512, 1024, 2048];
ブロックサイズとして、8から2048までの2の累乗を使います。8より小さいブロックサイズを定義しないのは、それぞれのブロックは、解放されたときに次のブロックを指す64ビットのポインタを格納することができなければならないからです。2048バイトより大きな割り当てに対しては、代替の連結リストアロケータに任せましょう。
実装を簡単にするために、ブロックのサイズとメモリに要求されるアラインメントを同じにすることにします。つまり、16バイトのブロックはつねに16バイトの境界に、512バイトのブロックは512バイトの境界に合わせられます。アラインメントは常に2の累乗でなければならないので、他のブロックサイズは許されないのです。2の累乗でないブロックサイズが必要になった場合は、(例えば、BLOCK_ALIGNMENTS
配列を定義することで)この実装を修正することもできます。
🔗アロケータ型
ListNode
型とBLOCK_SIZES
スライスを使って、私たちのアロケータ型を定義することができます:
// in src/allocator/fixed_size_block.rs
pub struct FixedSizeBlockAllocator {
list_heads: [Option<&'static mut ListNode>; BLOCK_SIZES.len()],
fallback_allocator: linked_list_allocator::Heap,
}
list_heads
フィールドはブロックサイズごとのhead
ポインタの配列です。これはBLOCK_SIZES
にlen()
を使うことで配列長とすることで実装しています。最大のブロックサイズよりも大きな割り当てに対する代替アロケータとして、linked_list_allocator
の提供するアロケータを使います。私たち自身で実装したLinkedListAllocator
を使っても良いのですが、これには解放されたブロックを結合する機能が実装されていません。
FixedSizeBlockAllocator
を作るには、他のアロケータ型に実装したのと同じnew
関数とinit
関数を実装すればよいです:
// in src/allocator/fixed_size_block.rs
impl FixedSizeBlockAllocator {
/// 空のFixedSizeBlockAllocatorを作る。
pub const fn new() -> Self {
const EMPTY: Option<&'static mut ListNode> = None;
FixedSizeBlockAllocator {
list_heads: [EMPTY; BLOCK_SIZES.len()],
fallback_allocator: linked_list_allocator::Heap::empty(),
}
}
/// アロケータを与えられたヒープ境界で初期化する。
///
/// この関数はunsafeである;呼び出し元は与えるヒープ境界が有効であり
/// ヒープが未使用であることを保証しなければならないからである。
/// このメソッドは一度しか呼ばれてはならない。
pub unsafe fn init(&mut self, heap_start: usize, heap_size: usize) {
self.fallback_allocator.init(heap_start, heap_size);
}
}
new
関数がするのは、list_heads
配列を空のノードで初期化し、fallback_allocator
としてempty
で空の連結リストアロケータを作ることだけです。EMPTY
定数が必要なのは、Rustコンパイラに配列を定数値で初期化したいのだと伝えるためです。配列を直接[None; BLOCK_SIZES.len()]
で初期化するとうまくいきません──なぜなら、そうするとコンパイラはOption<&'static mut ListNode>
がCopy
トレイトを実装していることを要求するようになるのですが、そうはなっていないからです。これは現在のRustコンパイラの制約であり、将来解決するかもしれません。
もしLinkedListAllocator
を実装するときにまだやっていないのなら、 #![feature(const_mut_refs)]
をlib.rs
の最初に追記しないといけません。const関数内におけるあらゆる可変参照型の使用はまだunstableで、それにはlist_heads
フィールドの配列要素の型であるOption<&'static mut ListNode>
も(その値をNone
にしているにもかかわらず)含まれるからです。
このunsafeなinit
関数はfallback_allocator
のinit
関数を呼ぶだけで、list_heads
配列の初期化などは行いません。これらの配列の初期化は、alloc
とdealloc
呼び出しが行われたときに初めて行います。
利便性のため、fallback_allocator
を使って割り当てを行う非公開のメソッドfallback_alloc
も作ります:
// in src/allocator/fixed_size_block.rs
use alloc::alloc::Layout;
use core::ptr;
impl FixedSizeBlockAllocator {
/// 代替アロケータを使って割り当てを行う。
fn fallback_alloc(&mut self, layout: Layout) -> *mut u8 {
match self.fallback_allocator.allocate_first_fit(layout) {
Ok(ptr) => ptr.as_ptr(),
Err(_) => ptr::null_mut(),
}
}
}
linked_list_allocator
クレートのHeap
型はGlobalAlloc
を実装してはいません(ロックを使わない限り不可能なため)。代わりに、allocate_first_fit
というインターフェイスの少し違うメソッドを提供しています。これは、*mut u8
を返したり、エラーを表すためにヌルポインタを使うのではなく、Result<NonNull<u8>, ()>
を返します。NonNull
型は、ヌルポインタでないことが保証されている生ポインタの抽象化です。Ok
の場合はNonNull::as_ptr
メソッドへ、Err
の場合ヌルポインタへと対応づけることで、これを簡単に*mut u8
型に戻すことができます。
🔗リストのインデックスを計算する
GlobalAlloc
トレイトを実装する前に、与えられたLayout
を格納できる最小のブロックサイズを返すようなヘルパ関数list_index
を定義します:
// in src/allocator/fixed_size_block.rs
/// 与えられたレイアウトに対して適切なブロックサイズを選ぶ。
///
/// `BLOCK_SIZES`配列のインデックスを返す。
fn list_index(layout: &Layout) -> Option<usize> {
let required_block_size = layout.size().max(layout.align());
BLOCK_SIZES.iter().position(|&s| s >= required_block_size)
}
ブロックは少なくとも与えられたLayout
の要求するサイズとアラインメントを持っていないといけません。私たちはブロックサイズがブロックのアラインメントでもあると定義していたので、これはrequired_block_size
がレイアウトのsize()
とalign()
属性の最大値であるということを意味します。BLOCK_SIZES
スライスの中でそれよりも大きいブロックを探すために、まずiter()
メソッドでイテレータを得て、つぎにposition()
メソッドでrequired_block_size
以上の大きさを持つ最初のブロックのインデックスを見つけます。
ブロックサイズそのものではなく、BLOCK_SIZES
スライスのインデックスを返していることに注意してください。これは、ここで返したインデックスをlist_heads
配列のインデックスとして使いたいからです。
🔗GlobalAlloc
を実装する
最後のステップは、GlobalAlloc
トレイトを実装することです:
// in src/allocator/fixed_size_block.rs
use super::Locked;
use alloc::alloc::GlobalAlloc;
unsafe impl GlobalAlloc for Locked<FixedSizeBlockAllocator> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
todo!();
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
todo!();
}
}
他のアロケータの時と同じく、GlobalAlloc
トレイトをアロケータ型に直接実装するのではなく、Locked
ラッパを使って同期された内部可変性を追加しています。alloc
とdealloc
の実装は結構長いので、以下で一つ一つ示していきます。
🔗alloc
alloc
メソッドの実装は以下のようになります:
// src/allocator/fixed_size_block.rsの`impl`ブロックの中
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let mut allocator = self.lock();
match list_index(&layout) {
Some(index) => {
match allocator.list_heads[index].take() {
Some(node) => {
allocator.list_heads[index] = node.next.take();
node as *mut ListNode as *mut u8
}
None => {
// リストにブロックがない→新しいブロックを割り当てる
let block_size = BLOCK_SIZES[index];
// すべてのブロックサイズが2の累乗であるときにのみ正しく動く
let block_align = block_size;
let layout = Layout::from_size_align(block_size, block_align)
.unwrap();
allocator.fallback_alloc(layout)
}
}
}
None => allocator.fallback_alloc(layout),
}
}
一つ一つ見ていきましょう:
まず、Locked::lock
メソッドを使ってラップされたアロケータのインスタンスへの可変参照を手に入れます。次に、ついさっき定義したlist_index
関数を呼んで、与えられたレイアウトに対して適切なブロックサイズを計算し、list_heads
配列の対応するインデックスを得ます。これがNone
だったなら、割り当てに適したブロックサイズはないので、fallback_alloc
関数を使ってfallback_allocator
を使います。
もしリストのインデックスがSome
なら、list_heads[index]
から始まる対応するリストからOption::take
メソッドを使って最初のノードを取り出すことを試みます。リストが空でないなら、match
文のSome(node)
節に入り、(ふたたびtake
を使って)node
の次の要素を取り出しリストの先頭のポインタとします。最後に、取り出されたnode
ポインタを*mut u8
として返します。
もしリストのヘッドがNone
だったなら、ブロックリストが空であったということです。この場合、上で説明したように新しいブロックを作らなくてはなりません。そのために、まず現在のブロックサイズをBLOCK_SIZES
スライスから得て、それを新しいブロックのサイズとアラインメント両方として使います。それによって新しいLayout
を作り、fallback_alloc
メソッドを使って割り当てを行います。レイアウトとアラインメントの調整をしているのは、割り当て解除の際にこのブロックがブロックリストに追加されるからです。
🔗dealloc
dealloc
メソッドの実装は以下のようになります:
// in src/allocator/fixed_size_block.rs
use core::{mem, ptr::NonNull};
// `unsafe impl GlobalAlloc`ブロックの中
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
let mut allocator = self.lock();
match list_index(&layout) {
Some(index) => {
let new_node = ListNode {
next: allocator.list_heads[index].take(),
};
// ブロックがノードを格納できるサイズとアラインメントを持っていることを確認
assert!(mem::size_of::<ListNode>() <= BLOCK_SIZES[index]);
assert!(mem::align_of::<ListNode>() <= BLOCK_SIZES[index]);
let new_node_ptr = ptr as *mut ListNode;
new_node_ptr.write(new_node);
allocator.list_heads[index] = Some(&mut *new_node_ptr);
}
None => {
let ptr = NonNull::new(ptr).unwrap();
allocator.fallback_allocator.deallocate(ptr, layout);
}
}
}
alloc
と同じように、まずlock
メソッドを使ってアロケータの可変参照を得て、list_index
関数で与えられたLayout
に対応するブロックリストを得ます。インデックスがNone
なら、BLOCK_SIZES
にはサイズの合うブロックサイズがなかった、つまりこの割り当てが代替アロケータによって行われたことを意味します。従って、代替アロケータのdeallocate
を使ってメモリを解放します。このメソッドは*mut u8
ではなくNonNull
を受け取るので、先にポインタを変換しておく必要があります(ここのunwrap
はポインタがヌル値だったときのみ失敗するのですが、コンパイラがdealloc
を呼ぶときにはそれは決して起きないはずです)。
もしlist_index
がブロックのインデックスを返したなら、解放されたメモリブロックをリストに追加しなければなりません。このために、まず現在のリストの先頭を指す新しいListNode
を(ここでもOption::take
を使って)作ります。新しいノードを解放されたメモリブロックに書き込む前に、index
によって指定されている現在のブロックサイズがListNode
を格納するのに必要なサイズとアラインメントを満たしていることをassertします。その後与えられた*mut u8
ポインタを*mut ListNode
ポインタに変換し、これに対しunsafeなwrite
メソッドを使うことで書き込みを実行します。最後のステップはリストの先頭ポインタ──これに対してtake
を呼んだので現在はNone
です──を設定することです。このために、生のnew_node_ptr
を可変参照に変換します。
いくつか注目すべきことがあります:
- 私たちは、ブロックリストによって割り当てられたブロックと代替アロケータによって割り当てられたブロックを区別していません。これにより、
alloc
で作られた新しいブロックはdealloc
でブロックリストに追加されるので、そのサイズのブロックの数は増えることになります。 - 私たちの実装において、新しいブロックが作られる唯一の場所は
alloc
メソッドです。つまり、最初は空のブロックリストから始めて、それらのブロックサイズの割り当てが行われたときに初めてリストを埋めていくということです。 alloc
とdealloc
でunsafe
な操作を行っていますが、unsafe
ブロックは必要ありません。これは、Rustは現在unsafeな関数の中身全体を大きなunsafe
ブロックとして扱っているからです。明示的にunsafe
ブロックを使うと、どの操作がunsafeなのかそうでないのかが明白になるという利点があるので、この挙動を変更するRFCが提案されています。
🔗使う
私たちが今作ったFixedSizeBlockAllocator
を使うには、allocator
モジュールのALLOCATOR
静的変数を更新する必要があります:
// in src/allocator.rs
use fixed_size_block::FixedSizeBlockAllocator;
#[global_allocator]
static ALLOCATOR: Locked<FixedSizeBlockAllocator> = Locked::new(
FixedSizeBlockAllocator::new());
init
関数は、私たちの実装してきたすべてのアロケータで同じように振る舞うので、init_heap
内におけるinit
関数の呼び出しを修正する必要はありません。
heap_allocation
テストをもう一度実行すると、すべてのテストが変わらずパスしているはずです:
> cargo test --test heap_allocation
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
many_boxes_long_lived... [ok]
私たちの新しいアロケータはうまく動いてるみたいですね!
🔗議論
固定サイズブロック方式は連結リスト方式よりはるかに優れた性能を持っていますが、(2の累乗をブロックサイズとして使うとき)最大でメモリの半分を無駄にします。このトレードオフに価値があるかは、行われる割り当ての種類に大きく依存します。オペレーティングシステムのカーネルについては、性能が非常に重要なので、固定サイズブロック方式はよりよい選択であるように思われます。
実装の面では、現在の実装には様々な改善可能な箇所があります。
- ブロックが必要になってから代替アロケータで割り当てる代わりに、リストを事前に埋めておき最初の割り当ての性能を向上させる方が良いかもしれません。
- 実装を簡単にするため、2の累乗のブロックサイズのみを許すことで、ブロックサイズをアラインメントとしても使えるようにしました。アラインメントを別のやり方で格納する(もしくは計算する)ことで、任意の他のブロックサイズを使うこともできるでしょう。こうすると、より多くのブロックサイズ(例えば、よくある割り当てサイズのもの)を追加でき、無駄になるメモリを最小化できます。
- 現在、新しいブロックを作ることはしますが、それらを解放することは行っていません。これは断片化につながり、最終的には巨大な割り当ての失敗につながるかもしれません。それぞれのブロックサイズの最大リスト長を制限する方が良いかもしれません。最大長に達すると、その後の割り当て解除はリストに加える代わりに代替アロケータを使って解放するようにします。
- 4KiB以上の割り当てについて、連結リストアロケータで代替するかわりに特別なアロケータを使うことが考えられます。発想としては、4KiBのページの上で動作する仕組みであるページングを利用し、連続した仮想メモリのブロックを非連続な物理フレームへと対応づけるのです。こうすると、巨大な割り当てに関する未使用メモリの断片化はもはや問題ではなくなります。
- この「ページアロケータ」があるなら、ブロックサイズを4KiBまで増やし、連結リストアロケータはなくしてしまっても良いかもしれません。このやり方の利点は、断片化が少なくなり、性能の予測性が高まる──つまり、最悪の場合の性能がより良くなる──ことです。
上で述べた実装の改善点は、あくまで提案に過ぎないということを忘れないでください。オペレーティングシステムのアロケータは、概してカーネル特有の作業のために高度に最適化されていますが、これは詳細なプロファイリングをしてこそ可能になるものなのです。
🔗亜種
また、固定サイズブロックアロケータの設計には多くの亜種があります。有名な例としてスラブアロケータとバディアロケータの二つがあり、これらはLinuxのような有名なカーネルにおいても使われています。以下では、これらの二つの設計を軽く紹介します。
🔗スラブアロケータ
スラブアロケータの発想は、カーネルで使われる型をいくつか選び、それらに直接対応するブロックサイズを使うというものです。こうすると、それらの型の割り当てサイズはブロックサイズに完全に一致するので、メモリは一切無駄になりません。時には、未使用ブロック内の型インスタンスを事前初期化することでさらに性能を向上させられるかもしれません。
スラブアロケータはしばしば他のアロケータと組み合わせて使われます。例えば、固定サイズブロックアロケータと組み合わせて、割り当てられたブロックをさらに分割しメモリの無駄を減らすことができます。一つの巨大な割り当ての上でオブジェクトプールを実装するのにもよく使われます。
🔗バディアロケータ
バディアロケータでは、解放されたブロックの管理に連結リストを使う代わりに、二分木を使い、ブロックサイズを2の累乗にします。あるサイズの新しいブロックが必要になったら、より大きいサイズのブロックを二つに割り、木に二つの子ノードを作ります。ブロックが解放されたときは毎回、木での隣のブロックを調べます。もし隣も解放されているなら、二つのブロックを合わせて二倍の大きさのブロックに戻します。
この合体ステップのおかげで、外部断片化が少なくなり、解放されたブロックが大きな割り当てに再利用できます。代替アロケータも使わないので、性能の予測可能性も高まります。最大の問題は、2の累乗のブロックサイズしか使えないので、大量のメモリが内部断片化で無駄になるかもしれないことです。このためバディアロケータはしばしば、割り当てたブロックをより小さな複数のブロックに分割するスラブアロケータと組み合わせて使われます。
🔗まとめ
この記事では様々なアロケータの設計を概観しました。一つのnext
ポインタを増やしていくことでメモリを線形に渡していく、基本のバンプアロケータの実装を学びました。バンプアロケータはとても速いですが、割り当てがすべて解放されてからでないとメモリを再利用できません。そのため、グローバルアロケータとして使われることはまれです。
次に、解放されたメモリブロック自体を使ってフリーリストと呼ばれる連結リストを作る連結リストアロケータを作りました。このリストによって、さまざまなサイズ・任意の数の解放されたブロックを格納することができます。この手法は、メモリが一切無駄にならない一方、割り当ての要求によってリスト全体を走査する必要が出てくる可能性があり、性能が悪いです。私たちの実装では、隣接する解放されたブロックを結合することをしていないので、外部断片化も起きてしまいます。
連結リスト方式の性能の問題を解決するため、決められたブロックサイズの集合を事前に定義しておく固定サイズブロックアロケータを作りました。ブロックサイズごとに別々のフリーリストが存在するので、割り当て・割り当て解除はリストの先頭で挿入・取り出しを行えば良いだけになり、非常に速いです。それぞれの割り当てはそれより大きなブロックサイズに丸められるので、内部断片化によっていくらかのメモリが無駄になります。
アロケータの設計はもっとたくさんあり、それぞれ異なるトレードオフがあります。スラブアロケータはよくある固定サイズの構造の割り当てをうまく最適化できますが、どのような状況でも使えるとは限りません。バディアロケータは二分木を使って解放されたブロックを結合し直しますが、2の累乗のブロックサイズしか使えないので、大量のメモリを無駄にしてしまいます。また、カーネルの実装ごとに行う作業の内容は違うので、どんな状況にも対応できる「最強の」アロケータの設計などないということを覚えておくのが大事です。
🔗次は?
この記事で、メモリ管理の実装に関してはいったん終わりとします。次はマルチタスクについて、手始めにasync/awaitの形を取った協調的マルチタスクから学んでいきます。その後の記事で、スレッド、マルチプロセス、プロセスについても学びます。
コメント
Do you have a problem, want to share feedback, or discuss further ideas? Feel free to leave a comment here! Please stick to English and follow Rust's code of conduct. This comment thread directly maps to a discussion on GitHub, so you can also comment there if you prefer.
Instead of authenticating the giscus application, you can also comment directly on GitHub.
可能な限りコメントは英語で残すようにしてください。