Open14

冬休み勉強会:Rustから学ぶメモリ管理周り

enoeno

所有権

Rustには所有権と呼ばれる概念がある。

https://doc.rust-jp.rs/book-ja/ch04-00-understanding-ownership.html

GolangのようにGC(Garbage Collection)を使うことなく、所有権の譲渡を介してメモリの解放等をプログラマが意識しなくても行われるようになる。これは、JSやPythonなどの言語と比べたときにオブジェクトを明示的に破棄することができるという点で特別。メモリリークを防げるようになる。

GC、聞いたことあるけど定義とかはっきりとは分かってない

enoeno

GCについて

GCとは不要になったメモリを解放する機能です。
アプリケーションは常にサーバやPCのメモリを確保しつつ何かしらの処理を行なっています。
GCは確保したメモリのうち、不要なメモリを解放します。
不要かどうかの判定は、メモリを使用しているオブジェクトが何かしら(ルート集合あるいは他のオブジェクト)から参照されているかどうかで行っています。

https://qiita.com/gold-kou/items/4431d3dd41606d41732b#gcとは

なるほど、他から参照されなくなったオブジェクトのメモリを解放するところのことか。アプリケーションの実行と並行して稼働していそう。

enoeno

話を戻して所有権について。

所有権には規則が存在する。

  1. Rustの各値は、所有者と呼ばれる変数と対応している。
  2. いかなる場合でも所有者は一意に決まる。
  3. 所有者がスコープから外れたら値は破棄される。
enoeno

スタックとヒープ

一般に、メモリ領域についてスタックとヒープの2種類が存在するらしい。なんか聞いたことがある。スタックはキューの対のイメージ。アプリケーションのログ見る時とかスタックトレースとかいうもんなぁ。

https://doc.rust-jp.rs/book-ja/ch04-01-what-is-ownership.html?search=へ#所有権とは

スタック

スタックは知っていた通り、LIFOのあのスタック。スタック領域に値を積むと、その値にアクセスする際は非常に高速に実現できる。なぜなら置いた新しいデータも取得する新しいデータも常に一番上にあるようになっているから。
スタックにはどんな値を持ってくるからというと固定サイズのデータだけ。ここがミソ。

ヒープ

スタックと反対に可変長のデータを置くところがヒープ。可変長のデータを置くため、ヒープには十分にサイズの大きなスペースが必要。
用語としては、「ヒープに領域を確保する」という意味でallocateするという言い回しがされるらしい。
また、ヒープへのデータアクセスはスタックへのそれよりも遅くなる。なぜなら所定の値のありかがどこかが一意ではないから。この時、ポインタでその探しに行く。単純にアクセスしに行くのに手間がかかるのに加えて、allocateする行為自体にも時間がかかるらしい。

enoeno

スタックとヒープの具体例について。

Rustにおける

  • String型 String
  • 文字列リテラル &str

が比較できるらしいとdocにある。

&str の型を持つ値はそのサイズが変わらないと約束されている。

let moji = "mojimoji";

みたいに突然宣言されるものが文字列リテラルに該当する。
ちなみに、文字列リテラルはスタックに格納されると見せかけておいて、ヒープでもなく静的領域という領域に格納されるらしい。なんじゃそりゃ。

String型では可変かつ伸長可能なテキストを実現するために、ヒープ領域に十分なサイズを持って格納される。ここまでは「String型が可変長な文字列を表す型」という事実を鵜呑みにすれば納得できる。が、公式docには

  • メモリは、実行時にOSに要求される。
  • String型を使用し終わったら、OSにこのメモリを返還する方法が必要である。

とある。

1個目は

let mut s = String::from("hello");

とString型の変数を定義するときに行うString::fromが「OSにメモリを要求する」という行為に相当するらしい。
2つ目はC言語のような明示的なメモリ解放やGCが内部的に行なってくれるメモリを解放する行為が該当するっぽいが、それこそRustでは変数(所有者)がスコープを抜けるだけで使っていたメモリをOSに返せるらしい。

変数がスコープを抜ける時、Rustは特別な関数を呼んでくれます。この関数は、dropと呼ばれ、 ここにString型の書き手はメモリを返還するコードを配置することができます。Rustは、閉じ波括弧で自動的にdrop関数を呼び出します。

https://doc.rust-jp.rs/book-ja/ch04-01-what-is-ownership.html?search=y#メモリと確保

enoeno

あ、C言語とかで見るmallocって、memory allocationのことか。

enoeno

GoのGCや、Goにおけるスタック、ヒープの取り扱いについて解説してくれてるサイトがあった。

https://developer.so-tech.co.jp/entry/2022/07/28/112845

これによると、

  • 関数内でしか使われない値は基本的にスタックに割り当てられる。
  • 関数外で使われると判断された場合はヒープに割り当てられる。
  • GCを使わないので(他にも理由はあるが)、基本的にスタックに割り当てられた方が速い処理になる。

とあるので

str := "hello"

func main(){
  println(str)
}

よりも

func main(){
  str := "hello"
  println(str)
}

の方がヒープに割り当てられなくなり、スタックに割り当てられるようになるため処理速度としては向上するって感じか。直感的に前者がダメそうなのはわかるけどなんで後者がベターなのかをメモリのロジックで明文化できるのはなんかいいな。

ちなみに公式docはめっちゃ論文みたいに書かれててイカつい。難しそうすぎる。
https://go.dev/doc/gc-guide#Eliminating_heap_allocations

enoeno

https://deeeet.com/writing/2016/05/08/gogc-2016/

↑の記事の「GOGC(GOのGCをチューニングする)」という章でGOGCのチューニングについて触れられていた。
これによると、GoのGCの実行をするまでのヒープサイズを(100+x)%倍することでGCの実行間隔を調整できるというものらしい。
RAMの容量が2倍になったら、つまりメモリの使用量の限界が2倍に増えたらメモリ使用量を増やしてもいいことになるからGOGCを大きく設定することができるって感じか。

ていうかRAMってなんか高校の情報で習ったことありそう。ここがメモリを扱う部分か。

enoeno

String型のような可変長のオブジェクトについては以下のように

  • 文字列の中身を保持するメモリへのポインタ
  • ながさ
  • 許容量

の3つを備えている。そしてこれらはスタックに積まれるらしい。
String型はヒープに積まれると思っていたが、部分的に文字列の中身自体はヒープに積まれるのか。

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

とすればs1が所有権をs2にあげて無効化されていてコンパイラがエラーを出すのはわかる。このとき厳密には文字列の中身を保持する(ヒープ領域にある)メモリへのポインタ自体は使い回すらしい。これが所有権を渡すという現象の実態っぽいな。これでスコープを抜けたときにs1とs2を同時に解放してしまうことを防ぐためにs1は代入した時点で無効化されるから都合がいいのか。s1をs2に代入することをs1をs2にムーブするっていうらしい。

enoeno

上記のコンパイルエラーが発生するのはあくまで可変長のオブジェクト(String型のインスタンス)を扱っている場合に限り、整数のようなコンパイル時に既知のサイズを持つものはスタックにその中身が保持されるためムーブするときに無効化する必要がない。こんがらがりそう。。。。つまり以下はコンパイラが許容する。

#![allow(unused)]
fn main() {
let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);
}

まあでも確かに、あくまでヒープ領域に対してallocateする回数を減らすためにString型ではその中身へのポインタを引き回していたのであって、整数の中身自体がスタックに保持されるのであればわざわざallocateする回数を節約する必要もなさそうだな。なぜならスタックへのデータアクセスは高速であるから。

enoeno

String型に限らず多分実用でもっと扱うのはmutを使って可変長の変数を宣言する場合かな。

つまり、

fn main() {
    let x = 5;

    let y = x;

    println!("x = {}, y = {}", x, y);  // x = 5, y = 5
}

はコンパイルエラーにならないけど

fn main() {
    let mut x = 5;

    let y = x;

    println!("x = {}, y = {}", x, y);  // コンパイルエラーになる
}

はコンパイルエラーになる。

と思ったけど、

warning: variable does not need to be mutable
  --> src/main.rs:41:9
   |
41 |     let mut x = 5;
   |         ----^
   |         |
   |         help: remove this `mut`
   |
   = note: `#[warn(unused_mut)]` on by default

warning: `begging_cargo` (bin "begging_cargo") generated 1 warning (run `cargo fix --bin "begging_cargo"` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s
x = 5, y = 5

って言われて、コンパイルできるっぽい。よくわからん。mutについて理解があやふやだ。