飽きっぽい人のブログ@qwerty2501

プログラマとしてもテスターとしても中途半端な人のブログ

RustでOS作成したという論文を訳してみた その3

ここの翻訳になります
原文は2015年に書かれたものなので現在のRustの仕様と異なることが書いてある場合があります
本文章はGithubで管理されています
この翻訳は7割方間違っているので信用しないでください

前:その2

3 Rustの評価

小節1.2で言及した通り、Rustはかなり新しく、メモリと型安全に注目したシステム指向型プログラミング言語である。Rustのより包括的な型とメモリ分析は、ますます速度と安全性の両方を求められるようになった状況に置かれているCの素晴らしい代替にするためにある。以前はRustでOS作成など誰も真剣に試みたことはなかったし、Reenix実装を通して私はこの界隈でのRustの強みと弱みの深い認識を得ることができた。また、私は様々な分野においてRustが変更されてこの領域での利便性が向上するのを体験することができた。
ここではRustを使うことによる利点とプロジェクト全体を通して発見したことについて解説する。次に、Rust使用上の問題について検証し、現在起こっている言語上の主要な問題について見ていく。最後に、RustのパフォーマンスをCと比較して短い検証をして終える。

3.1 Rustの利点

このプロジェクトの過程で、私はシステムプログラミングにおいてRustを使うことは、CまたはC++を超える沢山の利点があることを見つけた。これらを並べると、利便性における問題が簡単であること、可用性における主要な進歩の明快であること、正確なコード作成のための機能からなる。

3.1.1 高水準言語

最も明確である、OS開発(もちろん一般的な開発も)において、Cのような言語を超えるRustを使用することの利点は、C++JavaPython、そして他の言語から多くの期待した高水準言語プログラマで構成されることが事実であるということだ。Rustはオブジェクトに付けられているメソッド、データとメソッドの明確な関係の許可、(健全な)演算子オーバーロード、そして単一の名前空間で機能を纏めてグループに関連さることを許可するモジュールシステムなどといった標準的なプログラミング抽象化を提供する。これら全ては、遥かに強く関連付けられた部品間の概念的な繋がりの作成による冗長さと曖昧さをより少なくすることができる。
糖衣構文より、Rustの簡単で便利な実装の更なる高水準な特質はまた、どのように我々がこの言語を使うかという主要な効果をもつ。トレイトを通すことにより、Rustはインターフェースの利用による仮想メソッドテーブル(vtable)の作成と自動的な仕様をより簡単に提供する。これはまた、C関数のような仮想メソッドの定義を書く必要性があるものよりも難解さを少なくしたキャストを実行する。さらに優れているのが、Rustは使用される前に正確な型を知っていることを証明できる場合、vtableを省略してコードをコンパイルするのために十分賢いということだ。これにより、通常のCでできることよりも、多くの一般的なシステム作成と、前よりもより多くのインターフェースの利用を簡単にすることができる。Rustは自動的にスコープ内でオブジェクトを破棄するセマンティクスを持つ上、破棄されたオブジェクトの型を基底とするカスタムデストラクタをサポートする。これでできるリソース取得の利用は初期化(RAII)セマンティクスとなる。我々は例えば、関連付けられたMutexのロックなしで同期されたデータへのアクセス、またはデータの保護が終わった場合に解放しないMutexといった、今までにないことを確実にするためにこれを使用することができる。さらにこれは常に正確なカウントを保障する参照カウントポインタ作成のために使われることができる。これは、関数が失敗するときに単純にエラーを返すことができるので、多くの関数での失敗のハンドリングを大幅に簡素化し、Rustのコンパイラは全ての初期化されたオブジェクトのデストラクタが正確に実行されるのを確実にする。
最後に、Rustは可能ならデータ量が大きなコピーを避けるため、関数のシグネチャを少しばかり自動的に書き直す。これはRustがしばしば戻り値に外部ポインタの代わりに巨大なオブジェクトを返す関数のシグネチャを書き換えることを意味する。返されたオブジェクトの配置を定義することを呼び出したものに認めることにより、例えばmoveでヒープ上から返されたオブジェクトのような、メモリコピーを防ぐ。rustcがRustソースコードの可読性を向上させるこの書き換えと、実関数が行うことの曖昧さを減少させる。
例えば、図5で見れるstat関数と図9で見れるそのCバージョンの比較をしよう。両関数はセマンティクスとして'stat'構造体またはエラーを返す。しかしながら、Cではコストのかかるメモリコピー操作を回避するために'stat'構造体を外部ポインタとして取るためにシグネチャを変更する必要がある。しかも、この曖昧さは少しばかり関数の監視を要する。だがRustの場合、この関数は'Stat'またはエラーを返すというこの型シグネチャの本来のセマンティクスを持つ。この関数をコンパイルする時のRustコンパイラは、同様にコストの高いコピー操作を避けるために、シグネチャをCのそれと一致するように変更する。同じことをするのにCのコードと同じことをするのに、速さでは匹敵するが、セマンティクスはよりきれいになることを意味する。

typedef struct vnode_ops {
    // ...
    int (* stat )( struct vnode * vnode ,
    struct stat * buf );
    // ...
} vnode_ops_t ;

図9: C stat関数インターフェース

3.1.2 型及びライフタイムと借用チェッカー

他の主要なRustを使うことでOS開発者を招く利点として包括的な型、ライフタイム、借用チェックシステムがある。これら三つの道具とともに、コード上のエラークラスを完全に除去する手助けを行う。これらのシステムはCで可能な方法よりもよりリッチなコードの意味を表すことを認める。
Rust言語が型安全で手続き指向的であることは小節1.2で述べた。標準ライブラリが提供するいくつかのラッパー型は型安全なコードを正しく書くのを手助けする。これらラッパー型の最も重要なものは二つあり、一つは Result<R,E> 型で、成功した場合は操作の(型Rの)値を返し、失敗した場合(型Eの)値を示すものと、もう一つは Option<T> 型で、T型の値またはその型の値がないことを示す None からなる。操作が失敗する可能性があるか、値が提供されない可能性を示すために使用される。これらの型はRustのコードの全ての場合で使用され、インターフェース内に通知可能性があることは事実で、上記で見れる(図4、図5、図6を見よ)最も関数が返すKResultオブジェクトは Result<T, errno::Errno> のシンプルなエイリアスだ。これらの型の使用を通して、コメントによる説明なしで正確な戻り値の意味をさらに具体的に伝達することができる。さらには、型システムがCでは一般的な、このような関数のふるまいによって多くの失敗が起きてしまうのを防ぐ。Rustでは明示的にハンドルされなければならないエラーが発生してないことを確認することなしで、結果オブジェクトを返す関数の結果にアクセスすることが可能である。これは例えば、ほぼ完全に明確に、図5でのstat関数のセマンティクスを作り、型システムによってチェックされるが、一方図9におけるCのそれは同様のセマンティクスを持っているのにもかかわらず、より少ない明示と全てにおいてチェックされない。
型チェッカーに加えて、借用チェッカーとライフタイムシステムもまた多くのエラーの一般的な型を防ぐことを、幾分コードをきれいにすることで助ける。ライフタイムシステムは全ポインタ参照が安全で、かつ未初期化のものや可能ではあるが解放済みのデータを参照しないことを要求する。時々この証明が得るためには、違う型とそれらのライフタイムの相互関係を明確にするアノテーションを与える。加えて、それはまた、オブジェクトのポインタが解放された後、保持されるポインタがないことを明確にする。それらとともに、ほぼすべての"解放後の使用"バグと"未初期化データ"バグ発生を防ぐ。加えて、ライフタイムシステムはどの程度の間相違する型の間でポインタを保有している時に有効かを重んじることに依存する型シグネチャを正確に指定することを強要する。最後に、借用チェッカーはデータ構造体間の依存を確認するためにライフタイムシステムを使用することで、同時に変更することを防ぐ。これにより他のコードによって、コードが無効なポインタを保持するバグを防ぐ。

3.1.3 マクロとプラグイン

他のRustを使うことでCより優れている利点はRustは包括的で力強いマクロとプラグインシステムを有しているということだ。小節1.2.1で述べたように、Rustのマクロは衛生的なRust構文抽象化ツリー(AST)の変換である。これらは全く別物で、Cプリプロセッサを超えてさらに力強くそして純粋なテキスト変換マクロである。Cマクロではマクロが使用されたコンテキストで展開されることを常に承知しておかなければならない。これはCではマクロは書いた人間が注意しないと上書きか変数の再定義する可能性があることを意味する。Rustでのマクロは、明示的にマクロに渡されたローカル変数を上書きすることはない。
Rustでのマクロ定義の標準的な方法は、パターンマッチドメイン固有言語(DSL)を使用することだ。このDSLを使用するときに、マクロ引数は基本的な型情報を持つ。各引数は、式、構文、型、識別子、そして大体他6つのいずれかである。これらは正しくマクロパースを確認するために、余分な括弧や区切り文字を通常必要としないことを意味している。さらには、マクロ展開する時に完全に力強いパターンマッチが行える、正しいフォーマットや引数の数に依存する違うコードの作成できる、Cのマクロシステムではできない何かができることを意味する。
加えて、常に強力なマクロシステムでRustコンパライラであるrustcはまた、コンパイラの振る舞いを変更するコンパイラプラグインの作成をサポートする。これらのプラグインは、さらに複雑で、標準マクロDSLを使うことで作成することが不可能な(そうでなければ非常に難しい)マクロを実装するために使用されることができる。例えば、quasiquote操作と、コンパイラプラグイン内だけでファイルシステムにアクセスを利用することができる。各コンパイラプラグインの使用によって、望むなら(そして実際にこれはどのようにマクロDSLが実装されるか)全ての新しいDSLを作ることができる。さらには、これらは図にで見た #[derive(…)] と同様に前面の項目を変更するタグの作成に使用される。最後に、様々な種類のエラーとレポートのためのAST確認に使用されるリント作成に使用される。これら全てでできることは、全ての開発者にとって非常に有用であり、それらがカーネル開発に使える純粋なコンパイルタイムプラグインであることに感謝する。

3.1.4 エコシステムの成長

最後の利点として、Rustはとても堅牢でモダンな標準ライブラリを備えており、カーネル開発において最も小さな労力で使うことができる点でCを超える。Rust標準ライブラリは他のどの高水準言語よりも期待するもの全てをを備えている。Rust標準ライブラリはlist、map、setを含む複数のコンテナ型、参照カウントスマートポインタと合理的に優れたstringモジュールを有する。一方、C標準ライブラリはpipe、file、シグナルハンドル、そしてメモリマッピングのようなカーネル機能には巨大なインターフェースであるため、カーネルセッティングでほとんど使えない。カーネルコンテキストで使われる沢山のC標準ライブラリのパーツがあるとはいえ、これらのパーツは遥かに限られていてそれらをRustに対応させて使用するには難しい。さらには、list型とmap型のようなRustで提供されているこれら多くの一般的な型はC標準ライブラリには含まれていない。
さらに多くのコミュニティのRustプロジェクトはほとんど標準ライブラリを使用するので、変更がほとんどまたは全くなく使用される。これはソースの大部分のOSのように複雑なもの作るときに非常に使えるパッケージをかなりシンプルにインクルードすることを意味する。例えば、Reenix作成に取り掛かった時、私は関数で実行されるテストを書くために、外部コンパイラプラグインを利用した。このプラグインは非常に便利で、システム初期化を実行していた方法に関連する複数のバグを見つける手助けになった。ついてないことに、多くのReenixコミュニティプロジェクトで利用することが出来なかった。これはほとんど私がReenix作っていた時に、Rust変更ペースが早くて機会を逃したことに起因し、Rustの最新バージョンで作業しようとするとプロジェクトが停滞することを意味している。Rustの最初の安定版リリース(現在の計画では2015年5月15日となっている)を過ぎるとこれは開発者が言語の安定バージョンだけにターゲットできるので、問題は少なくなるだろう。

3.2 Rustでの問題

Reenixを作っている間、私は主にRustコンパイラに起因する多くの問題に直面した。これらは私が知る限り最善の方法で解決するための必要なツールは、ほとんどの言語で提供されていなかった。これらのどの問題もRustがOS開発言語として使われる妨げにはならなかったが、その間私はOS開発の作業をできたし、問題の場所を特定できたなら、言語は改善するだろうと感じている。

3.2.1 構造体継承

一つ目の問題は、RamFSの実装をしていた時に構造体間の継承全体の欠如である。大体のケースだと複数の型が違う関数へのアクセスを要求するが、多くの同じデータを保持する。これの最適な例が図5で見せたVNodeである。これを見て8,9行目のget fsと get modeが同様にシンプルなアクセサメソッドであることに気付くかもしれない。両方のメソッドは全てVNodeのフィールドであるため簡素であり、実装に関係しないが、動作するために持っていないといけない。確かに、VNode実装するもの全てがNodeの型と、簡素なファイルシステムからなるフィールドのペアを持つ。
全ての一般的なVNodeのフィールド全てを保持し、抽象化された基底クラスがあったなら、よりシンプルになり、そしてサブタイプはこれらのフィールドを加えられられたし、クラスのメソッドのいくつかを実装するだけでよかった。このアイディアはRustコミュニティにとって新しくなかったし、それどころか半ダースを超える提案がRustコミュニティにされたし、継承と同じような提案もあったが、Rust1.0リリースまでの間ペンディングされることになった。

3.2.2 匿名列挙型

RamFS を実装している間、カーネルの残りであったRamFSとVNodeとしての型を描く必要があったのでかかりっきりになるという問題があった。これは全ての同じ型でのVNode作成方法を確認するため、また、そうでなければRustはそれらを型チェックできないためである。しかしながら、内部的にそれはVNode自身を分割した型の、それぞれ違う型を持つよりも遥かに理にかなった、VNodeが必要とする型を指定するメソッド実装である。さらに、この方法で行うことは、ほとんどすべてのVNodeメソッドが任意のものであり、かつ図5のような与えられた型のVnodeで使用される場合、予想されるエラーを返すことを許可する。この制限を満たすために、 RamFSVNode の分割変数の全てを保持することができた自前の列挙型を作った。そしてこの列挙型は全ての VNode メソッドが実際の VNode へ引数を簡単に伝達するための実装を持っていた。他の全ての VNode 型はその後作成とメソッド参照で、この列挙型の戻りとして自身を宣言した。これはうまくいったが、どれほど多くのVNodeトレイトの関数があっただろうか、それはもうとてつもなくコードを書くのに時間を浪費した。これを改善する方法であるが、人間工学に基づいて、Rustで何らかの匿名列挙型を認めることだ。そのような型が自動的に実装できて伝達できたなら、構成要素型の共有関数はこの手のコードであふれるようなことから回避することができた。さらに、この型はコンパイル時にサイズを知られていることを保障し、トレイトを返すことを要求するといった間接参照ポインタなしに使用されることを認める。これは適合する一つの型に関連付けられたデータ構造体のコレクションを簡単に提供する。

3.2.3 静的な所有権

最後のRustの改善点は、静的アロケートされたデータのハンドリングだ。OSカーネルでは、グローバルで全てのプロセスによって共有される多くのデータがある。これにはスケジューラのキュー、読み込まれたドライバのリスト、ファイルシステム、メモリのキャッシュページ、そして多くのものが含まれる。これは全てグローバルデータであり、実在する単一コピーで、規定値として None をセットする Option<T> を静的アロケートとしてこれら全てのデータ構造体を保持することは理に適っている。これは明確に型で値保持が全システムによって静的に所有され、初めは未初期かであり使用前にセットアップを要求することを表している。
不幸にも、アクティブなプロセスの Vec 、もしくは初期化済みデバイスドライバのコレクションのようなものの多くは、コンテンツが初期化されていないプロパティでそのバッキングメモリが破棄されていることを確認する特殊なデストラクタコードを持つデータ構造体である。現状のRustはこのような構造体が静的にアロケートされることを認めていない。恐らくこれはプログラム終了時にデストラクタが確実に実行されるための方法が何もないためだ。これは理にかなっているが、デストラクタが実行終了時に実行されることを気にしないケースも割とあるというのに、この些細なことを無効にするためのコンパイラへの命令方法を提供しないことはいささか奇妙に思う。
私がReenixで使った回避策は全ての構造体を動的アロケートすることだった。そしてそれら構造体を使うのに必要とあればいつでも逆参照される静的なポインタとして配置した。これが動いていた間、他のことに使われるべきだったヒープ領域を浪費するという残念な結果になった。さらには、それはヒープで確保されたデータはあまり効率にパックされないことを意味しており、静的データで消費するメモリよりもさらに多くのメモリを消費することになった。最後に、任意ポインタの逆参照は安全ではない操作(ポインタが初期化されていない、間違っているか、確保済メモリ)で、Rustのこの言語機能はカーネルを確認するのには不必要で邪魔だ。

3.3 致命的な問題:アロケーション

比較的マイナーではあるが、OS開発言語として本格的に使われる前に絶対対処しなければならない熟慮されるべき問題が、私がRustを使用している時に直面した。この問題はRustでのヒープメモリアロケーションに関するセマンティクスであり、より具体的にはアロケーションが失敗した時に起こる問題である。

3.3.1 Rustでのアロケーション

RustでのヒープメモリーはBoxと呼ばれるコンストラクトにより表現される。ヒープでアロケートされたメモリに属する オブジェクトはBox化されたオブジェクトと呼ばれる。Boxは値をヒープに保存するために、値の前にboxキーワードを書くことによって作られ、新しく作られたBoxに値の所有権を渡す。boxキーワードはプログラマのために、ヒープ領域のアロケーションとその初期化両方を扱う。
Rustにおいての標準的な推奨はBox化されたオブジェクトを直接返す関数を書かないことだ。代わりに、関数は値オブジェクトを返すべきで、ユーザはboxキーワードを使ってその値をBox内に配置すべきだ。これは(小節3.1.1で論じたように)Rustは、自動的にコピーを回避するために外部ポインタを使う代わりに、オブジェクトを返す関数の多くを書き換えるためだ。図10ではヒープメモリアロケーションを利用するプログラムの例を見てみよう。このプログラムは通常のRustが推奨するスタイルに従っていて、値によるバッファを返す関数のみを定義している。Buf::new()でヒープアロケートによって返されたバッファ作成は17行目でboxキーワードを使用している。セマンティクスとしてこの行は(1)Buf::new関数を使用してBufオブジェクトを作成するべき(2)新しく作成されたBufを保持するためにヒープ領域のアロケートをするべき(3)ヒープ領域にBufを移動するべきということを述べている。コンパイラはこの操作をより効果的にローカルコピーの除去によって行い、よりシンプルにBuf::newを直接ヒープメモリに書いて、同様のセマンティクスをわずかに違う手順を用いる。
このコードをコンパイルする時Rustコンパイラは図11で見れるように、バッファに必要なメモリアロケートのためにexchange_malloc関数を使用する。exchange_malloc関数は言語アイテムと呼ばれる関数の特殊な型である。言語アイテムはRustコンパイラが知っている追加情報の関数または型で、それらは当然不変であることが既定である。このケースにおいてrustcはexchange_mallocがnullまたはアロケーションした長さが0ではない無効なポインタを決して返さないことを知っている。これは行の確認によって関数自身で保証され、アロケーション失敗した場合はabortが呼ばれる。
コンパイラはこの情報を使用して、コンパイル済みのコードを最適化して、アロケートされたポインタがnullではないことの確認をすることによって作成する。さらにそれはコンストラクタが図10の17行目のようにコピーなしで実行を許可するために外部引数を使用するために書き直される。これらはコンパイラが図10から図12と同様のコード作成するためよるので、統合する。メモリをアロケートできない時に中断と決してnullを返さないことによって、Rustは完全にプログラマからアロケーションが失敗することを隠す。アロケーションが誤りなく完全であることをRustプログラマ(そしてコンパイラ)までは知っている。

 #![ feature ( box_syntax )]

    /// バッファ
    struct Buf {
        val : [u8 ;16] ,
    }

    impl Buf {
        /// バッファを作成する
        #[ inline ( never )]
        fn new () -> Buf {
            Buf { val : [0;16] , }
        }
    }

    fn main () {
    let x = box Buf::new ();
    // バッファで何かする...
 }

図10: ヒープアロケートを使用するプログラム

// Copyright 2014 The Rust Project Developers .
// Licensed under the Apache License , Version 2.0
// liballoc / heap .rs からもってきたもの

/// ユニークポインタのためのアロケータ
#[ cfg ( not ( test ))]
#[ lang =" exchange_malloc "]
#[ inline ]
unsafe fn exchange_malloc ( size : usize , align : usize ) -> * mut u8 {
    if size == 0 {
        EMPTY as * mut u8
    } else {
        let ptr = allocate ( size , align );
        if ptr . is_null () {::oom () }
        ptr
    }
}

// liballoc /lib.rs からもってきたもの

/// 一般的なOut of memoryルーチン
#[ cold ]
#[ inline ( never )]
pub fn oom () -> ! {
    // FIXME (#14674): ここには中断以外の動作を必要とされて
    // いるが、 どの完了したprintingもアロケートしないことを保障されることが
    // 必須となっている。
    unsafe { core::intrinsics::abort () }
}

図11: Rustのliballocコードのアロケート定義

main :
# 除去前の関数スタック確認。
# 詳細は図7を見ること。
# 関数の領域を確保。
    subl $56 , % esp
# exchange_mallocに引数をわたす。
    movl $1 , 4(% esp )
# exchange_mallocに引数のサイズをわたす。
    movl $16 , (% esp )
# exchange_malloc呼び出し。
# nullチェックなしだと返されたポインタで
# 実行されることに注意。
    calll heap::exchange_malloc
# 返されたポインタをスタック上に配置。
    movl % eax , 36(% esp )
# 返されたポインタを第一引数として
# Buf::new に渡す。
    movl % eax , (% esp )
# Buf::newを呼び出す。これは何も返さないことに注意すること。
# 第一引数は外部ポインタである。
    calll Buf::new
# 36(% esp ) は初期化されたBufオブジェクトへの
# ポインタである。

図12: (わかりやすくした)図10でコンパイル時に作成されるアセンブリ

3.3.2 どんな失敗か、そしてなぜやっかいなのか

Rustがヒープアロケート失敗することに触れさせないのか不思議に思うかもしれない。ドキュメントにはなぜこれが隠されたままなのか公式的見解が書いていないが、様々なRust開発者がそれについ議論していた。一般的なアイディアはBoxが露にするアロケーションが失敗することができるようにすることで、どの一般的ではないアイディアに対して、"重い"。さらにはアロケーションが失敗するRustは現在のタスクのスタックを解きほぐし、全てのデストラクタが実行されることを確実にするため、C++で作られたコールフレーム情報(CFI)ベースの例外ハンドリングシステムを使用する。
これはいくつかの方法において最も使用するケースに対して確かに理解できる解決策だ。ユーザコードを書くときにこれは一般的にはページング要求やスワップ、そしてほとんどのシステムでの多くの物理メモリが実際のアロケート失敗するのが実際のアロケーションで失敗するのはかなりレアで問題にはならない。事実なのは、プロセスはmallocmmap、またはbrk呼び出し(linuxの最新版では)に失敗するよりも、とても小さなメモリがある時にkillされる可能性が高いということだ。さらにCFI例外のハンドリングはC++の人気のおかげで、ユーザスペースで多くのよい実装があり、(信じられないほどレアな)Rustプロセスでの別スレッドのアロケート失敗というケースにおいては一貫した状態を持ち続けるということを意味している。不幸にも、これはユーザにとっては容認可能な方法で、カーネルコードにとっては絶対的に致命的であるということだ。カーネルコードは一般的にページング要求を行えない。これにはいくつかの理由がある。第一に、ユーザスペースプロセスがカーネルメモリのほぼすべては実際に使われるのは好ましくなく、最たるものはディスクからのデータのキャッシュページと全システムプロセスの実メモリの保持である。これはどのメモリもカーネルのために特徴づけられるが使われないのは本当に無益で、そのようなメモリがページング要求のおかげでアロケートされていない可能性が高い場合にユーザプロセスは似ていないということは筋が通っている。実際、多くのカーネルは他の継続作業がない場合ハードディスクからのデータについて全て空のメモリで埋める。次に、カーネルの大部分は、頻繁にアクセスされるため、セカンダリページにページアウトできないか(例として、システムコールコードなど)またはそれはページを戻すのは不可能にさせる(例として、ディスクドライバまたはスケジューラ)ことなしにページアウトされることができないのは致命的であるかのどちらかである。これは結論としてはアロケーション失敗はOSカーネルにおいては当然普通に発生する事故だということだ。さらに悪いことに、それは一般的にカーネルコードでは大きな問題にならない。普通は何かアロケートに失敗した時に、解決策はその失敗(ENOMEM)のエラー番号を返すか、単純にキャッシュされていて使用されていないページメモリ削除システムに伝えて、もう一度アロケートを試みる。最後になるが、これがもっとも厄介な問題で、学生が実装したWeenixの通す必要があるテストはプロセスが使用可能なメモリを全て消費している時でさえ実行し続ける。このテストが実行されている時、だいたい他のWeenixのパーツほとんど全てのアロケーションが失敗する。
これは主要なカーネル開発におけるエラーハンドリング構造の実際の問題にさえ入らない。これらで真っ先に挙げられるものは、CFIスタックの解明を使用することはユーザランドのものよりもカーネルコンテキストにおいてさらに困難ということだ。カーネルコンテキストにおけるC++の多くのユーザはアップルのI/Oドライバシステムのようなもの全てを禁止する。さらには、CFI解明 が実装された場合、OS開発に置いてより一層多くのシステム全体にわたるデータの操作が通常のユーザプロセスにおけるものよりも必要とされ、これらの変更を完全に戻すデストラクタを作成するのは困難だ。

3.3.3 解決策と応急処置

ありがたいことに、私が出くわしたRustの他の問題の多くはそうでもなく、これはセオリーに基づいてかなりシンプルな解決策があった。行われる必要があった全てのものはヒープメモリアロケートが失敗するために変更するというもので、全てのヒープメモリは使用される前に明示的に確認されなければならない。これは理論的には標準ライブラリコードにパッチを当てることによって解決されたのだ(が、一つはbox構文を使用することができなかった)。運悪くこれを行うにはいくつか障害があった。
第一に、上で論じたように、Rustのアロケート用の関数は特殊な言語アイテム関数であるということだ。これは図12で見て取れるように、exchange_malloc関数がコンテンツのコンストラクタを呼ぶ前に呼ばれることができるためだ。他の関数に使用するように切り替えることは、この最適化がRustコンパイラの変更なしに実行されないということを意味している。これはすべてのスタックからコピーされ、ヒープに割り当てられたオブジェクトを全て強制することによってRustのコードはとても重くなる。オブジェクトのサイズに依存することでオブジェクトはスタック上一時的に配置されるので、いともたやすくスタックオーバーフローが起きる。RFC80932の最近の承認をうけて、これは近い将来には問題とはならなくなるだろうが、しかし2015年4月のRustコンパイラのパッチを当てる必要がある。
第二に、Rust標準ライブラリの大部分は、誤ってるものから正しいものまである今現在のアロケートの性質に依存する。絶対最小値において、データ構造体とスマートポインタ一つあたりが謝りやすいアロケートモデルを,それに直接依存するデータ構造体と同様にサポートするために書き直されることを必要とする。さらに、多くの一般的なインタフェースは、例えばリストへの値の挿入が成功するといったことはもはや想定されないので、変更されることを必要とする。最終的には恐らくほぼすべての標準ライブラリが書き直しを必要とする変更を行い、謝りやすいメモリの性質をさらす関数を追加する。   最終的に、この問題には応急処置を施さなければならなかった。これには、システムブート上で直ちに数パーセントのメモリが与えられる、Rustにおける単純なアロケータを作成することが含まれる。関数が通常の方法を通してメモリをアロケートできなかった時、バックアップアロケータを使用するのだ。いつでもカーネルは、このバックアップアロケータが使用されているか確認するためのマクロにラップされた、メモリアロケート操作をできた。もし、マクロがアロケートが失敗することを予想して、操作の結果を破棄するものだったのなら、うまくすればメモリの解放はバックアップアロケータで使用されている。これはむしろ明確な理由のために壊れやすく、そして、非常によくスケールすることが期待されることができない。代わりに、この解決策がこのプロジェクトで作用したとしても、メモリアロケート失敗が仮想メモリ実装のVMプロジェクトに入るまで実際の問題のようにはならないため、明らかにはならない。
不幸にも、この問題はOSカーネル作成においてRustの使用を大きく妨げる。一つは安全かつ明確に中断することなくメモリアロケート失敗から復旧できなくてはならない。多大な速度の犠牲なしにこれを行うことができるまでRustはOS開発において実用的になることはないだろう。

3.4 パフォーマンス

最後の一つは、新言語の試験をしている時にされなければならない比較はパフォーマンスだ。これら測定する型を用いることはより難しく、ホストコンピュータ上で行き当たりばったりでバラバラになる傾向が多い。さらに、二つの実装の間で有効な比較を見出すのは難しい。ドライバが終わった後、私は現在のReenixとWeenixの私の実装二つで同一のテストケースをずっとせっとあっぷしていた。全ての通常パフォーマンステストやトラッキングツール呼び出しをするには足りなかったので、Reenixのプロセスコントロールサブシステムといくつかのテストのみの実行をするための比較を制限した。
私は二つのOSでのパフォーマンステストを終えた。初めに、私はマルチスレッドが全てがこの共有された整数のリソースアクセス競合される場合のシステムをシミュレートした。全てのスレッドは協調してともにこの整数に追加し、時たま奪う。このためのコードと時間は図13で見れる。Rustのコードを用いたこのためのテストの平均はCコードの実行したものの3.040倍と同じくらいだ。この数字は全ての応急処置に渡ってとても誤りがないため、これらのコストはRustが行っている作業量に合わせてスケールする。
また、二つのシステムで一般的なシステムコールであるwaitpidの実行を行った場合の時間をテストした。これらの時間は図14で見れる。この関数により、我々はRustが平均的に2.036倍Cよりも遅いことを知った。
これは違う関数においての遅延量が違うのと、言語コンパイラよりも他の何かと関連づいていることを指示していると考えれられる。最もこの遅くなる原因の候補として挙げられるのはアロケートだろう、なぜならRustはCのコードのアロケートよりも幾分多いと考えられ、そしてRustの全てのアロケートは正しいアロケータをリストから見つけ出さなければならないため高コストであるためだ。他に挙げる要因は、Rustではスタックチェックコードが含まれることで、多大な量のコードが全ての要求されたエラー確認を実行するか、Rustトレイトの利用により、生成された仮想関数テーブルの検索の量が多かったかによるものだ。

RustでのMutexテスト関数

extern "C" fn time_mutex_thread ( high : i32 , mtx : * mut c_void ) -> * mut c_void {
    let mtx : & Mutex  = unsafe { transmute ( mtx ) };
    let mut breakit = false ;
    loop {
        if breakit { kthread :: kyield (); breakit = false ; }
        let mut val = mtx . lock (). unwrap ();
        if * val == high { return 0 as * mut c_void ; }
        * val += 1;
        if * val % 4 == 0 { kthread :: kyield (); }
        else if * val % 3 == 0 { breakit = true ; }
    }
}

CでのMutexテスト関数

typedef struct { int cnt ; kmutex_t mutex ; } data_mutex ;

void * time_mutex_thread (int high , void * dmtx ) {
    data_mutex * d = ( data_mutex *) dmtx ;
    int breakit = 0;
    while (1) {
        if ( breakit ) { yield (); breakit = 0; }
        kmutex_lock (& d - > mutex );
        if (d - > cnt == high ) { break ; }
        d - > cnt ++;
        if (d - > cnt % 4 == 0) { yield (); }
        else if (d - > cnt % 3 == 0) { breakit = 1; }
        kmutex_unlock (& d - > mutex );
    }
    kmutex_unlock (& d - > mutex );
    return 0;
}

アクセス回数 #内スレッド Rust実行時間(秒) C実行時間(秒)) 遅延
100 2 0.0238 0.0079 2.999
100 10 0.0851 0.0294 2.889
100 100 0.7865 0.2782 2.827
10000 2 1.9372 0.5920 3.272
10000 10 6.7841 2.1688 3.128
10000 100 61.611 19.697 3.128

図13: RustとCのMutexタイミングコードと実行10回あたりの平均

子プロセスの状態 C実行時間 Rust実行時間
Alive 0.51325 ms 1.1435 ms
Dead 0.37493 ms 0.6917 ms

図14: 1000回超実行での平均waitpid関数実行のミリ秒時間

4 あとがき

私がこのプロジェクトを開始した時、RustでWeenixの水準でUnixライクなOS作成をするということを目標に定めた。この目標は、プロジェクトを完遂するための時間が与えられるのをとても必要としていることを証明した。なんとかできたが、とはいえ、まず手始めにReenixでも同様に全て完備できる、Weenixプロジェクトの二つのパーツを作った。さらに、このプロジェクトからカーネル開発環境においてRustの使用について重大な結論を見出すことができた。これは、取り分け重要な、私の知る限りで今までのRustでこの範囲におけるOSプロジェクトで他の誰もが試みたことがないか、多くが進行中でありるものを与えられた。UnixライクなOSカーネルの基礎デザインは、Rustを使用してのビルドを実行できることを示せたと私は確信している。
さらに、この経験は、まだまだ粗削りで粗削りではあるが、実装作業がWeenixプロジェクトをを行っている生徒によってCよりもRustの方がより簡単に達成できたを私に示した。しかし、サポートコード実装はしばしばRustでは難しいときがあり、わかりづらいコードに結果的にはなった。まだまだ全体的にRustは改善の余地がある。
このプロジェクトを通して、RustがOSカーネルプログラミングだと考えられる限りの改良できたいくつかの方法の感覚を掴むことができた。 これら多くの望まれた問題の修正の実装は苛立ちのレベルで、簡単にRustに追加するか、将来的にどこかでRustに追加されるかのどちらかで、あるいは応急処置で事足りるのだ。私は一つだけプロジェクトを行っている最中にメモリアロケーションに関する、実際カーネルプログラミングにおいてRustを真剣に選択して作るのを本気で取り掛かるには致命的な問題を見つけた。不幸にもこの問題は、すぐに回避するにはほぼ不可能で、修正するのが困難だったし、そしてRustの開発者によって重要な問題ではないと考えられてしまった。
Rustが事実上Cよりも遅いままで終わってしまったという事実は、より落胆させられた。一方、同様にこの遅延の多くがReenixでのメモリアロケートのオーバヘッドと関係している。この問題の領域は私がここでできたことよりも遥かに多い研究をされなければならないということを完全に決定づけられた。さらに、Cの場合ではできる上、しばしばRustコンパイラが常に埋め込む確認を無視できる。これらの全ては共に結論に至るまでイライラさせられるだろうが、Rustの速度減少は乗り越えられないものではない。OSプロジェクトでRust使用を考えることを望むなら、これはまだ熟慮される必要がある。
長々と述べたが言いたかったこととしてはReenixはまあまあ成功したということだ。私が行った限りで得たことは、このプロジェクトに隠れたアイディアが基本的に健全であることを証明することができたということだ。また、全体を通してRust言語は、この手の高水準な完成されたシステムを作る手助けをすること、そしてこの領域において少量の変更で効果的にCを置き換えることができることを示すことができた。

<<前