Go言語のメモリ管理

Home » 媒体 » ASCII.jp » Go言語のメモリ管理
ASCII.jp, ASCII.jp- プログラミング+, IT・インターネット コメントはまだありません


Goならわかるシステムプログラミング
第19回

Go言語によるプログラマー視点のシステムプログラミング

ソフトウェアにとってメモリは不可欠です。 実行する命令も、メモリにロードしなければ実行できません。 ソースコードに書かれた定数値も、いったんメモリにロードしないと使えません。 関数を呼び出すにも、スタックと呼ばれるメモリ領域が必要です。 スタック以外に、ヒープと呼ばれるメモリ領域が必要なこともあります。

今回は、Go言語のプログラマーが作成するプログラムの下で、どのようにメモリが管理され利用されるかを探ります。 Go言語のメモリ管理というとガベージコレクターの話を思い起こすかもしれませんが、ガベージコレクターについては本連載では取り上げません。

メモリ確保の旅

コンピューターに接続されている物理的なメモリチップが、どのような過程を経てプログラムで使われるのか、順番に見ていきましょう。

(1): カーネル

最近のオペレーティングシステムでは複数のプロセスを同時に実行できます。 それらのプロセスのそれぞれに、そのプロセスだけが使うメモリ空間があります。 各プロセスのメモリ空間は、物理的なメモリとどう対応しているのでしょうか。

いま、メモリが8GBのマシンがあり、そのうちの1GBバイトをOSが消費しているとします。 残り7GBを使って、1GBずつのメモリを消費する7個のプロセスを順番に起動し、奇数番目に起動したプロセスを終了しました。残りのメモリは、1GBずつ細切れになった4GBです。 この状態で、4GBのメモリを消費するプロセスを追加で起動するとどうなるでしょうか? (以降、話を単純にするため、インテル製の64ビットCPUの場合について説明します。)

現代のOSでは、この追加のプロセスを問題なく起動できます。 その秘密は、CPUに内蔵されているメモリ管理ユニット(MMU)仮想メモリの仕組みです。 プロセスはメモリを読み書きするのに物理的なアドレスを直接使っているわけではなく、プロセスごとに仮想的なメモリアドレスがあり、それを使ってメモリにアクセスしているのです。

仮想メモリアドレスから実際の物理アドレス上のデータへのアクセスには、ページテーブルと呼ばれる階層型のデータ構造が使われます。 「ページ」というのは、メモリを管理する単位です。メモリ全体は4KBずつの「ページ」に分けられています。 それぞれのページが物理メモリのどのアドレスに確保されているかを示すのがページテーブルです。 OSは、プロセスがメモリを確保すると、このページテーブルを用意します。 仮想メモリのおかげで、物理的な保存領域が1GBずつの細切れになっていても、プロセスから見ると4GBのフラットなメモリ領域が確保されているように見えるのです。

ページテーブルによる変換が挟まるので遅くなるように思えますが、実際には変換テーブルをキャッシュして高速化する仕組みがCPUには備わっています。 このキャッシュをTLB(Translation Lookaside Buffer)と呼びます。

メモリ管理のこの部分は、Go言語から直接触れることはできません。とはいえ、パフォーマンスに影響を与えうるため、ページサイズの情報を返すAPIが用意されています。

package main
 
import (
    "fmt"
    "os"
)
 
func main() {
    fmt.Printf("Page Size: %d\n", os.Getpagesize())
}

(2): プロセスのメモリ空間

プロセスは、起動するとOSからメモリをもらいます。 OSは、プロセスごとに仮想メモリの領域を確保します。

確保される領域の大きさは、アドレスを示す番地の桁数によるので、基本的にはCPUのビット数に基づきます。 とはいえ、CPUのビット数で表現しうる範囲のメモリ領域へ自由にアクセスできるわけではなく、扱えるメモリの量とCPUのビット数は必ずしも一致しません。 たとえば、現在主流のインテルアーキテクチャの64ビットモードだと、リニアに扱える仮想メモリ長は47ビット(128テラバイト)が2つで、合計256テラバイトです。 ただし、実際にはWindowsもLinuxも、1プロセスあたりの最大のプロセスのメモリサイズは128テラバイトに制限されています。 そのため、プロセスから見える仮想メモリ空間の範囲は、128テラバイト分ということになります。

いま、プログラムAとプログラムBの2つのプロセスを起動すると、それぞれのプロセスに0番地~0x00007fffffffffff番地まで広がる空間が見えます。 同じ0番地であっても、プログラムAとプログラムBが見ている実際の物理的なメモリの場所は違います。 他のプログラムのメモリ領域は、覗き見たり書き込んだりできません。

47ビットの空間すべてをプロセスが自由に使えるわけではありません。「メモリをこれだけください」とカーネルにお願いして、47ビットの空間の一部と物理メモリの対応付けをしてもらいます。 現在は多くてもせいぜいギガバイト単位でメモリを使うプログラムがほとんどでしょう。 カーネルはこの要求されたサイズのみを、仮想メモリの仕組みを使って確保し、物理メモリと対応付けます。

ユーザーのメモリ空間は大きく3つの連続したメモリ領域に分かれます。 その間の空きスペース分はメモリの確保が行われません。 ユーザーのメモリ空間は図のようになっています。

若い番地のほうから、プログラム、プログラムの静的変数などが置かれます。 その先の番地に、カーネルから動的にもらったメモリ領域が置かれていきます。 中段には共有ライブラリがマッピングされて置かれます。

最上段にはカーネルがマッピングされ、通常はその下からアドレスが若くなる方向にスタックメモリが確保される、という説明をよく見かけますが、Go言語ではスタックメモリの管理は独自に行っているため、必ずしもこれとは一致しません。

空きスペースの領域はこれから説明するようなシステムコールで動的に割り当てたメモリとして使われます。

(3): システムコール

プロセスができると、隙間がなくフラットなプロセス固有のメモリ空間ができること、そして、仮想メモリ空間の中にプログラムが実際に利用するメモリのブロックができることを説明しました。 次は、このメモリブロックをOSに依頼してもらってくるためのシステムコールについて説明します。

POSIX系OSでメモリブロックの確保に使うシステムコールは、すでに何度か登場しているmmapです。 Go言語では、この連載の第12回「ファイルシステムと、その上のGo言語の関数たち(3)」でファイルをメモリ空間に配置するときに紹介した、syscall.Mmapを使います。 このシステムコールは、対象のファイルを指定せずにアノニマス(無名)フラグを付けて実行すると、ファイルを読み込まずに指定サイズのメモリブロックを確保します。 これによって実行時に必要な動的メモリを確保できます。 mmap以外にも、POSIX系OSには、ヒープサイズを拡張するbrkおよびsbrkという、割り当て済みのメモリのサイズを変更するシステムコールがありますが、これらはGo言語のランタイムでは使われていません。

Go言語のWindows実装では、mmap相当のCreateFileMappingではなく、アノニマスフラグを付けたときのmmapの挙動とほぼ同じVirtualAllocを使います。

なお、mmapシステムコール自体はマッピングしたメモリブロックを仮想メモリのどこのアドレスに配置したいかのヒント情報を指定できますが、これはメモリマップを管理している言語ランタイム向けの機能ですし、通常のアプリケーションで使う時は0を設定して自動設定にします。 Go言語のsyscall.Mmapでは最初から指定できません。

(4): ランタイム:ヒープメモリ

OSカーネルが物理メモリと仮想メモリのマッピングを管理しており、プロセスのメモリ空間へOSカーネルからシステムコールによってメモリを持ってくるところまで説明しました。 ここまでだと、まだメモリのかたまりが手に入っただけです。 ユーザーコードから扱いやすい形式で適切にメモリを確保したり解放したりするのはランタイムの仕事です。

それに、OS内部でのメモリ確保はコストのかかる可能性のある処理です。 物理メモリが足りなければ、優先度の低いメモリ領域を選んだり、HDDなどのストレージにスワップアウトしたり、それでも必要なメモリを確保できなければ他のプロセスを強制終了(LinuxのOOMキラー)させたりするといったことが裏で行われる可能性があります。

そこで、mmapなどのシステムコールを使って比較的大きめのメモリブロックをOSからもらっておき、細かいメモリのやりくりは効率よくユーザーランドの中で行って、不要になったらOSにまるごと返却したり、あるいは足りなければOSからメモリをもらったりします。 そのために使うメモリ領域としては、ヒープとスタックの2種類がありますが、まずはヒープから紹介します。

C言語でメモリ確保に使う有名な標準関数はmalloc()です。 これは必要な容量を指定するとそのサイズのメモリブロックが確保され、そのポインタを返す関数です。 mallocの仲間にはjemalloc、TCMallocなどがあり、Go言語ではTCMallocを採用しています。 TCMallocはGo言語と同じGoogle製です。

TCMallocは、マルチスレッド時代に合わせて設計されているmalloc実装で、主な特徴は2つあります。

  • 32キロバイト以下の小さなオブジェクトについては、スレッドごとにメモリブロックを管理する。これにより、ロックなどのスレッド競合によるパフォーマンス劣化を防ぐ
  • 32キロバイトよりも大きなオブジェクトについては、4キロバイト単位に丸め、共有の中央ページヒープで管理してメモリの無駄を減らす

32キロバイトより大きなオブジェクトは、中央ページヒープを直接利用してメモリが確保され、返されます。 中央ページヒープは、1ページあたり4キロバイト単位で、255ページまでのサイズごとの空きメモリブロックのリストになっています。 256ページを超えるオブジェクトはサイズごとに分類されずに1つのリストになっています。 たとえば、1001キロバイトのメモリが必要であれば、1004に丸めて251ページ長のリストを探しに行き、返却されている空きメモリがあればそれを返し、空きがなければ最低1メガバイト単位でOSからメモリをもらって、必要なサイズに切り出して返します。

小さなオブジェクトについても、同様に小さな単位の「クラス」という分類で空きメモリのリストを持っています。 これらはロックが必要がないため高速に処理できます。 大きなオブジェクトと同様に、リクエストされたサイズに近いクラスの空きリストがあればそこから返します。 なければ、スパンと呼ばれる空きメモリのストックから切り出して返します。 それもなければ、中央ページヒープからメモリをもらいます。

Go言語のヒープメモリの管理そのものの説明はネット上にもほとんどありませんが、下記の記事が参考になるでしょう。

(5): ランタイム:スタック

関数を呼ぶと、リターンアドレスや新しい関数のための作業メモリ領域(コンパイル時にサイズが分かるので固定量)を含む「スタックフレーム」と呼ばれるメモリブロックが確保されます。 このスタックフレームは、スレッドごとにあらかじめ確保されているメモリブロックに対して順番に追加したり削除したりされるだけなので、割当のコストはほぼゼロです。

デフォルトのスタックメモリのサイズは、Linuxではulimit -sで設定します。OSによって初期値が違うようですが、だいたい8MBぐらいが多いようです。 Windowsの場合はコンパイル時のフラグで設定され、32ビット、64ビット問わずデフォルトで1MBです。 ネイティブのOSスレッドは、作成時にこれだけの固定のメモリを確保する必要があるため、そのぶんだけ作成コストが上乗せされます。

これに対し、Go言語のgoroutineでは、最初は4キロバイトの小さなサイズのスタックを確保します。 OSスレッドと比べると極めて小さいサイズであり、goroutineの高速な起動にも貢献しています。 もし関数呼び出しで大きなサイズが必要なことがわかれば、別にスタックフレームを準備し、そちらに引数をコピーして、あたかもスタックがはじめて使われていたかのような状態で関数呼び出しを行います。 これにより、スタックサイズがギガバイトサイズになっても問題なく再帰ループが回せます。

なお、ランタイムのコードで次のコメントをよく見かけますが、これは、この関数の呼び出しではスタックの操作はしないという指示になります。

//go:nosplit

(6): ユーザーコード

ようやくユーザーコードにやってきました。 Go言語では、構造体やプリミティブの初期化の方法がいくつか提供されています。

// プリミティブのインスタンスを定義
var a int = 10
 
// 構造体のインスタンスをnewして作成
// 変数にはポインタを保存
var b *Struct = new(Struct)
 
// 構造体を{}でメンバーの初期値を与えて初期化
// 変数にはインスタンスを保存
var c Struct = Struct{"param"}
 
// 構造体を{}でメンバーの初期値を与えて初期化
// 変数にはポインタを保存
var d *Struct = &Struct{"param"}

配列やスライスの定義もいくつかあります。 varで定義するときは型を明示しますが、右辺で型が明確にわかるときは、:=と書くことで変数宣言と代入を同時に行えます。

// 固定長配列を定義
a := [4]int{1, 2, 3, 4}
 
// サイズ等を持ったスライスを定義
b := make([]int, 4, 8)
 
// バッファー無しのチャネル
c := make(chan string)
 
// バッファー有りのチャネル
d := make(chan string, 10)

これらのコードでは、「メモリを確保する」ことをコンパイラやランタイムに指示していることになります。

C/C++の場合は、ポインタを使わずにローカル変数として宣言するとスタックにメモリが確保され、newmalloc()を使うとヒープメモリにメモリが確保される、というシンプルな仕組みになっています。 Go言語の場合、どちらに置くかはコンパイラが自動的に判断します。newで作っても、その関数内でしか利用されなければスタックに確保されます。ローカル変数として宣言しても、そのポインタを他の関数に渡したり、関数の返り値として返すとヒープに置かれます。 そのため、C/C++で発生する「ローカル変数のポインタを関数の返り値として返すと、呼んだ側からアクセスしに行ったときにはもうスタックのフレームが巻き戻されて無効なメモリになっており、実行時エラーで落ちる」という問題は置きません。

メモリがスタックとヒープのどちらに確保されているかは、ビルド時に-gcflags -mを渡すと表示されます。

$ go build -gcflags -m sample.go ⏎
./sample.go:7: can inline sub
./sample.go:23: inlining call to sub
./sample.go:10: &b escapes to heap
./sample.go:9: moved to heap: b
./sample.go:17: b escapes to heap
./sample.go:16: make([]int, 10) escapes to heap
./sample.go:14: test_make make([]int, 10) does not escape
./sample.go:17: test_make ... argument does not escape
./sample.go:24: a escapes to heap
./sample.go:24: *b escapes to heap

スタックのほうが高速なので、デフォルトではスタックを選択しようとします。 しかし、外部の関数に渡したり返り値で使おうとしたりすると、宣言した関数のスコープよりも変数の寿命が長くなる可能性があるため、ヒープに逃がす(escape)ことがわかります。 ただし、fmt.Printlnのような表示しか行わずデータを変更しない関数でもヒープにされてしまいます。 データのサイズと使われ方次第ですが、参照専用で変更しない場合は、ポインタではなくて実体のコピーを受け取るような関数にしたほうがパフォーマンスが上がることもあります。 他の関数に渡しただけでスタックからヒープになってしまうのは、まだ最適化不足とも言えるので、今後のGoのコンパイラでは改善して欲しいところです。

Go言語のFAQにあるHow do I know whether a variable is allocated on the heap or the stack? (ヒープとスタックのどちらに変数が割り当てられているのか知る方法があるのか?)という項目には、Go言語ではコンパイラが安全側に倒しながら判断するため、どちらに置くかはプログラマーが意識する必要がない、と書かれています。 C/C++では、ヒープの場合はdeletefree()で明示的に削除したり、あるいはスマートポインタで削除されるように仕向ける必要があります。 「どちらか適切なほうに自動で割り当てられる」というGo言語の方針を実現するには、ヒープであってもスタックと同じように不要になったら自動で解放するガベージコレクタが不可欠になります。

Go言語のスライスは他の言語にはあまり見られない特殊なデータです。 実際には裏に配列があり、そこを参照するウインドウ(対象の配列、スタート位置、終了位置の3つの情報を持つ)のようなデータです。 この裏の配列が必要に応じて新しく割り当てられたりしますが、このあたりの話は日本語のウェブサイトの記事も多くあるため、割愛します。

OSの小話 – 仮想メモリが実現する高度な機能

仮想メモリには、フラグメント化されたメモリをフラットに見せかけるアドレス変換以外にも数多くの機能があります。 それらを組み合わせることで、物理メモリのサイズを超える量のメモリを扱うことができますし、メモリの無駄を減らして効率よくアプリケーションに配ることもできます。

たとえば、ページテーブルのエントリには、メモリの該当ページに対する読み書きが行われたときにカーネルに通知するためのフラグや、書き込みされたことを示すフラグが設定できます。 これらのフラグを利用することで、高度なメモリ管理が実現できます。 その一例として、デマンドページングと呼ばれる、「メモリを確保したと見せかけて、はじめてアクセスがあったときに実際に取得しにいく」という仕組みがあります。 デマンドページングでは、たとえばメモリを1GB取得したとしても、即座に物理メモリを1GB取得しに行かず、取得していない部分についてはアクセスがあったときにOSからメモリを受け取るようにします。 メモリを確保しても実際に使うまでにはタイムラグがあるので、デマンドページングによって初期化コストが削れ、使用メモリのピークが異なるプロセス同士でうまくメモリを融通しやすくなります。 デマンドページングは、初期化時だけではなく、優先度の低いメモリ領域をディスクに退避して(スワップ)必要なときに書き戻す際にも使われます。

また、第12回「ファイルシステムと、その上のGo言語の関数たち(3)」コピーオンライトというメモリ節約のテクニックが使われていることを紹介しましたが、これもページテーブルのフラグで実現されています。

仮想メモリには、複数のプロセスでシステムの同じ共有ライブラリをロードしている場合にメモリ消費を抑える仕組みもあります。 それぞれのプロセスの仮想メモリには同じライブラリが個別にロードされているように見えますが、ページテーブルを使って同じアドレスを参照することで、1つ分のメモリしか消費しないようにするのです。 このようなメモリの使われ方は、メモリの使用方法を観察するツールで確認できます。

Linuxのカーネルの書籍を読むと、メモリ管理のセクションには、ディスクの読み書きをメモリに保存しておくページキャッシュ、優先順位を決めてメモリの解放、同一サイズのオブジェクトを効率よく管理する仕組み(スラブアロケータ)、大きいバッファを管理するバディ・システムなど、ここで触れたもの以外にもいろいろな手法が登場します。さらに、macOSでは、メモリの圧縮を動的に行う(おそらく圧縮機構付きスワップ)といったトピックもあります。しかし、アプリケーションのメモリ管理からは少し離れるので、これらの機能については本連載では割愛します。

まとめ

今回は、OSのメモリ管理から、Go言語のプログラム側のメモリの取扱まで一通り紹介してきました。 比較的高速なコードを出力するGo言語にあって、パフォーマンス上の問題として取り上げられやすいのがメモリのアロケーションです。 高速をうたうライブラリはみな、ベンチーマークでメモリのアロケート回数とバイト数も出力し(-benchmemオプションを付与すれば出力される)、いかに回数が少ないかをアピールしています。 メモリ割り当て回数がゼロ(0 allocs/op)はGo言語界においては勲章のひとつのように扱われることすらあります。

「メモリ確保は裏でこれだけ頑張っている高価なオペレーションだ」というのが伝わったなら、今回の記事の役目は果たせたと思います。

次回は最終回になります。


カテゴリートップへ

この連載の記事

コメントを残す