ガベージコレクターの排除:RAII Way
公開: 2022-03-11最初はCがありました。Cには、静的、自動、動的の3種類のメモリ割り当てがあります。 静的変数はソースファイルに埋め込まれている定数であり、サイズは既知であり、変更されることはないため、それほど興味深いものではありません。 自動割り当ては、スタック割り当てと考えることができます。スペースは、字句ブロックに入るときに割り当てられ、そのブロックが出るときに解放されます。 その最も重要な機能はそれに直接関連しています。 C99までは、自動的に割り当てられた変数は、コンパイル時にサイズがわかっている必要がありました。 これは、文字列、リスト、マップ、およびこれらから派生した構造が、ダイナミックメモリ内のヒープ上に存在する必要があることを意味します。
動的メモリは、malloc、realloc、calloc、およびfreeの4つの基本操作を使用して、プログラマーによって明示的に割り当てられ、解放されました。 これらの最初の2つは初期化をまったく実行せず、メモリにがらくたが含まれている可能性があります。 無料を除くすべてが失敗する可能性があります。 その場合、それらはnullポインターを返しますが、そのアクセスは未定義の動作です。 最良の場合、プログラムは爆発します。 最悪の場合、プログラムはしばらくの間動作しているように見え、爆発する前にガベージデータを処理します。
この方法で物事を行うのは、プログラマーであるあなたが、違反したときにプログラムを爆発させる一連の不変条件を維持する責任を単独で負うため、一種の苦痛です。 変数にアクセスする前に、malloc呼び出しが必要です。 変数を使用する前に、mallocが正常に返されることを確認する必要があります。 実行パスには、mallocごとに1つの空き呼び出しが存在する必要があります。 ゼロの場合、メモリリークが発生します。 複数の場合、プログラムは爆発します。 解放された後は、変数へのアクセス試行がない可能性があります。 これが実際にどのように見えるかの例を見てみましょう:
int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
$ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576
そのコードは、そのままでは単純ですが、すでに1つのアンチパターンと1つの疑わしい決定が含まれています。 実際には、バイトカウントをリテラルとして書き出すのではなく、sizeof関数を使用する必要があります。 同様に、char *配列を、必要な文字列のサイズに正確に2回割り当てます(null終了を考慮して、文字列の長さより1つ大きくします)。これは、かなりコストのかかる操作です。 より洗練されたプログラムは、より大きな文字列バッファを構築し、文字列サイズを大きくすることができます。
RAIIの発明:新たな希望
控えめに言っても、そのすべての手動管理は不快でした。 80年代半ば、Bjarne Stroustrupは、彼のまったく新しい言語であるC++の新しいパラダイムを発明しました。 彼はそれをResourceAcquisitionIsInitializationと呼び、基本的な洞察は次のとおりでした。オブジェクトは、コンパイラによって適切なタイミングで自動的に呼び出されるコンストラクタとデストラクタを持つように指定できます。これにより、特定のオブジェクトのメモリを管理するためのはるかに便利な方法が提供されます。が必要であり、この手法はメモリではないリソースにも役立ちます。
これは、C ++での上記の例が、はるかにクリーンであることを意味します。
int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
$ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0
手動のメモリ管理は見えません! 文字列オブジェクトが作成され、オーバーロードされたメソッドが呼び出され、関数が終了すると自動的に破棄されます。 残念ながら、その同じ単純さは他の合併症につながる可能性があります。 例をもう少し詳しく見てみましょう。
vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
$ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.
それはすべてかなり簡単に思えます。 ベクトル線は埋められ、返され、呼び出されます。 ただし、パフォーマンスを気にする効率的なプログラマーであるため、これについて何か気になります。returnステートメントでは、値のセマンティクスが機能しているため、ベクトルは破棄される直前に新しいベクトルにコピーされます。
これは、最近のC++では厳密には当てはまりません。 C ++ 11では、移動セマンティクスの概念が導入されました。この概念では、原点は有効なままですが(適切に破棄できるように)、指定されていない状態のままです。 リターン呼び出しは、コンパイラがセマンティクスを移動するために最適化するための非常に簡単なケースです。これは、オリジンがそれ以上のアクセスの直前に破棄されることを知っているためです。 ただし、この例の目的は、80年代後半から90年代初頭にガベージコレクションされた言語を大量に発明した理由を示すことであり、当時はC++の移動セマンティクスが利用できませんでした。
大きなデータの場合、これは高額になる可能性があります。 これを最適化して、ポインタを返しましょう。 構文の変更がいくつかありますが、それ以外は同じコードです。
実際には、vectorは値ハンドルです。ヒープ上のアイテムへのポインターを含む比較的小さな構造です。 厳密に言えば、単純にベクトルを返すことは問題ではありません。 この例は、返される大きな配列の場合にうまく機能します。 事前に割り当てられた配列にファイルを読み込もうとするのは無意味なので、代わりにベクトルを使用します。 それが非現実的な大規模なデータ構造であると偽ってください。
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
$ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)
痛い! 行がポインターになったので、自動変数がアドバタイズされたとおりに機能していることがわかります。スコープが離れると、ベクトルは破棄され、ポインターはスタック内の前方の位置を指します。 セグメンテーション違反は、単に不正なメモリへのアクセスを試みただけなので、実際にそれを予期していたはずです。 それでも、何らかの方法でファイルの行を関数から戻したいので、自然なことは、変数をスタックからヒープに移動することです。 これは、新しいキーワードを使用して行われます。 ファイルの1行を編集するだけで、次の行を定義できます。
vector<string> * lines = new vector<string>;
$ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.
残念ながら、これは完全に機能しているように見えますが、それでも欠陥があります。メモリリークが発生します。 C ++では、ヒープへのポインターは、不要になった後で手動で削除する必要があります。 そうでない場合、最後のポインタがスコープから外れるとそのメモリは使用できなくなり、プロセスの終了時にOSがメモリを管理するまで回復されません。 慣用的な最新のC++は、ここでunique_ptrを使用します。これは、目的の動作を実装します。 ポインタがスコープから外れたときにポイントされたオブジェクトを削除します。 ただし、その動作はC++11まで言語の一部ではありませんでした。
この例では、これは簡単に修正できます。
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }
残念ながら、プログラムがおもちゃのスケールを超えて拡大するにつれて、どこでいつ正確にポインタを削除すべきかを推論することが急速に難しくなります。 関数がポインタを返すとき、あなたは今それを所有していますか? 使い終わったら自分で削除する必要がありますか、それとも後ですべて解放されるデータ構造に属しているのでしょうか。 ある方法でそれを間違えるとメモリリークが発生し、他の方法でそれを間違えると、現在は無効になっているポインタを逆参照しようとするため、問題のデータ構造とおそらく他のデータ構造が破損しています。
「ガベージコレクターに、フライボーイ!」
ガベージコレクターは新しいテクノロジーではありません。 それらは1959年にジョンマッカーシーによってLispのために発明されました。 1980年のSmalltalk-80で、ガベージコレクションが主流になり始めました。 ただし、1990年代は、この手法の真の開花を表しています。1990年から2000年の間に、多数の言語がリリースされ、そのすべてが、Haskell、Python、Lua、Java、JavaScript、Ruby、OCamlなどのガベージコレクションを使用していました。 、およびC#は最もよく知られているものの1つです。
ガベージコレクションとは何ですか? つまり、手動のメモリ管理を自動化するために使用される一連の手法です。 多くの場合、CやC ++などの手動メモリ管理を備えた言語のライブラリとして利用できますが、それを必要とする言語でははるかに一般的に使用されます。 大きな利点は、プログラマーが単にメモリについて考える必要がないことです。 それはすべて抽象化されています。 たとえば、上記のファイル読み取りコードに相当するPythonは、次のとおりです。
def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
$ python3 python3.py makefile File makefile contains 38 lines.
行の配列は、最初に割り当てられたときに作成され、呼び出し元のスコープにコピーせずに返されます。 タイミングが不確定であるため、そのスコープから外れた後、いつかガベージコレクターによってクリーンアップされます。 興味深いことに、Pythonでは、非メモリリソースのRAIIは慣用的ではありません。 許可されています。withブロックを使用with
代わりにfp = open(file_name)
と記述し、後でGCをクリーンアップすることもできます。 ただし、推奨されるパターンは、可能な場合はコンテキストマネージャーを使用して、決定論的な時間にリリースできるようにすることです。

メモリ管理を抽象化するのは良いことですが、コストがかかります。 ガベージコレクションをカウントする参照では、すべての変数の割り当てとスコープの出口は、参照を更新するためのわずかなコストを獲得します。 マークアンドスイープシステムでは、GCがメモリをクリーンアップしている間、予測できない間隔ですべてのプログラムの実行が停止します。 これは、ストップザワールドイベントと呼ばれることがよくあります。 両方のシステムを使用するPythonのような実装には、両方のペナルティがあります。 これらの問題は、パフォーマンスが重要な場合、またはリアルタイムアプリケーションが必要な場合に、ガベージコレクションされた言語の適合性を低下させます。 これらのおもちゃのプログラムでも、パフォーマンスの低下が実際に見られます。
$ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s
Pythonバージョンは、C++バージョンのほぼ3倍のリアルタイムを要します。 その違いのすべてがガベージコレクションに起因するわけではありませんが、それでもかなりの違いがあります。
所有権:RAIIが目覚める
それで終わりですか? すべてのプログラミング言語は、パフォーマンスとプログラミングの容易さのどちらかを選択する必要がありますか? 番号! プログラミング言語の研究は継続されており、次世代の言語パラダイムの最初の実装が見られ始めています。 特に興味深いのは、Rustと呼ばれる言語です。これは、Pythonのような人間工学と、Cのような速度を約束し、ダングリングポインター、nullポインターなどを不可能にしますが、コンパイルされません。 どうすればそれらの主張をすることができますか?
これらの印象的な主張を可能にするコアテクノロジーは、ボローチェッカーと呼ばれます。これは、コンパイル時に実行される静的チェッカーであり、これらの問題を引き起こす可能性のあるコードを拒否します。 ただし、その影響について深く掘り下げる前に、前提条件について説明する必要があります。
所有
C ++でのポインターの説明で、所有権の概念に触れたことを思い出してください。これは、大まかに言うと、「この変数の削除の責任者」を意味します。 Rustは、この概念を形式化して強化します。 すべての変数バインディングには、バインドするリソースの所有権があり、ボローチェッカーは、リソースの全体的な所有権を持つバインディングが1つだけあることを確認します。 つまり、RustBookの次のスニペットはコンパイルされません。
let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^
Rustの割り当てには、デフォルトで移動セマンティクスがあります。所有権を譲渡します。 型にコピーセマンティクスを与えることは可能であり、これは数値プリミティブに対してすでに行われていますが、それは珍しいことです。 このため、コードの3行目以降、v2は問題のベクトルを所有しており、vとしてアクセスできなくなります。これが役立つのはなぜですか。 すべてのリソースに所有者が1人だけいる場合、そのリソースがスコープから外れる瞬間も1つあります。これは、コンパイル時に決定できます。 これは、RustがRAIIの約束を果たし、ガベージコレクターを使用したり、プログラマーに手動で何かを解放したりすることなく、スコープに基づいて決定論的にリソースを初期化および破棄できることを意味します。
これを参照カウントのガベージコレクションと比較してください。 RC実装では、すべてのポインターに少なくとも2つの情報があります。1つはポイントされたオブジェクトであり、もう1つはそのオブジェクトへの参照の数です。 そのカウントが0に達すると、オブジェクトは破棄されます。これにより、ポインターのメモリ要件が2倍になり、カウントが自動的にインクリメント、デクリメント、およびチェックされるため、使用にわずかなコストがかかります。 Rustの所有権システムは、オブジェクトが参照を使い果たしたときにオブジェクトが自動的に破棄されるという同じ保証を提供しますが、実行時のコストなしでそうします。 各オブジェクトの所有権が分析され、コンパイル時に破棄呼び出しが挿入されます。
借用
移動セマンティクスがデータを渡す唯一の方法である場合、関数の戻り型は非常に複雑になり、非常に高速になります。 2つのベクトルを使用して整数を生成し、後でベクトルを破棄しない関数を作成する場合は、それらを戻り値に含める必要があります。 それは技術的には可能ですが、使用するのはひどいです:
fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);
代わりに、Rustには借用の概念があります。 このように同じ関数を書くことができ、それはベクトルへの参照を借用し、関数が終了したときに所有者に返します。
fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);
v1とv2は、fn fooが戻った後、所有権を元のスコープに戻します。スコープから外れ、含まれているスコープが終了すると自動的に破棄されます。
ここで言及する価値があるのは、コンパイル時にボローチェッカーによって強制されるボローイングに制限があることです。これはRustBookが非常に簡潔に述べています。
借用は、所有者の範囲を超えない範囲で継続する必要があります。 次に、これら2種類の借用のいずれかを使用できますが、同時に両方を使用することはできません。
リソースへの1つ以上の参照(&T)
正確に1つの可変参照(&mut T)
これは、データ競合に対するRustの保護の重要な側面を形成するため、注目に値します。 コンパイル時に特定のリソースへの複数の可変アクセスを防止することにより、どのスレッドが最初にリソースに到達したかに依存するため、結果が不確定なコードを記述できないことが保証されます。 これにより、イテレータの無効化や解放後の使用などの問題を防ぐことができます。
実用的な用語での借入チェッカー
Rustの機能のいくつかについて理解したので、前に見たのと同じファイル行カウンターを実装する方法を見てみましょう。
fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }
ソースコードですでにコメントされている項目を超えて、さまざまな変数の存続期間を調べて追跡する価値があります。 file_name
とfile_lines
は、main();の終わりまで続きます。 それらのデストラクタは、C ++の自動変数と同じメカニズムを使用して、追加コストなしでその時点で呼び出されます。 read_lines_from_file
を呼び出すと、 file_name
はその期間中その関数に不変に貸し出されます。 read_lines_from_file
内では、 buffer
は同じように動作し、スコープから外れると破棄されます。 一方、 lines
は持続し、 main
に正常に返されます。 なんで?
最初に注意することは、Rustは式ベースの言語であるため、return呼び出しは最初は1つのように見えない場合があることです。 関数の最後の行で末尾のセミコロンが省略されている場合、その式が戻り値になります。 2つ目は、戻り値が特別に処理されることです。 彼らは、少なくとも関数の呼び出し元と同じくらい長く生きたいと考えています。 最後の注意点は、移動セマンティクスが関係しているため、 Ok(lines)
をOk(file_lines)
に変換するためにコピーは必要ないということです。コンパイラーは、メモリーの適切なビットに変数ポイントを作成するだけです。
「最後になって初めて、RAIIの真の力に気づきます。」
手動のメモリ管理は、コンパイラの発明以来、プログラマーが回避する方法を発明してきた悪夢です。 RAIIは有望なパターンでしたが、C ++では機能しませんでした。これは、いくつかの奇妙な回避策がなければ、ヒープに割り当てられたオブジェクトに対しては機能しなかったためです。 その結果、90年代にガベージコレクションされた言語が爆発的に増加し、パフォーマンスを犠牲にしてもプログラマーの生活をより快適にするように設計されました。
しかし、それは言語デザインの最後の言葉ではありません。 Rustは、所有権と借用の新しく強力な概念を使用することで、RAIIパターンのスコープベースとガベージコレクションのメモリセキュリティを統合することができます。 他の言語では見られない安全性を保証しながら、世界を止めるためにガベージコレクターを必要とせずにすべて。 これがシステムプログラミングの未来です。 結局のところ、「間違いを犯すのは人間ですが、コンパイラーは決して忘れません」。
Toptal Engineeringブログでさらに読む:
- WebAssembly / Rustチュートリアル:ピッチパーフェクトなオーディオ処理
- Javaでのメモリリークのハンティング