yikegaya’s blog

仕事関連(Webエンジニア)と資産運用について書いてます

「Goならわかるシステムプログラミング」の15章を読んだ(Go言語のメモリ管理)

「Goならわかるシステムプログラミング」を前章に続いて気になったところ抜粋+コード写経しつつ、15章を読んだ

物理メモリと仮想メモリ

今、メモリが8GBのマシンがあり、そのうちの1GBをOSが消費しているとします。 残り7GBを使って、1GBずつのメモリを消費する7個のプロセスを順番に起動し、奇数番めに起動したプロセスを終了しました。残りのメモリは、1GBずつ細切れになった4GBです。この状態で、4GBのメモリを消費するプロセスを追加で起動するとどう なるでしょうか? 現代のOSでは、この追加のプロセスを問題なく起動できます。その秘密は、CPUに内蔵されているメモリ管理ユニット(MMU)と仮想メモリの仕組みです。プロセスはメモリを読み書きするのに物理的なアドレスを直接使っているわけではなく、プロセスごとに仮想的なメモリアドレス空間があり、それを使ってメモリにアクセスしているのです。

OSカーネルがプロセスのメモリを確保するまで

ユーザーのメモリ空間の3つの領域のうち、若い番地(小さいアドレス)には、プログラムとプログラムの静的変数などが置かれます。その先の番地の空いている領域には、カーネルから動的にもらうヒープと呼ばれるメモリが置かれていきます。中段には共有ライブラリがマッピングされて置かれます。最上段は、スタックと呼ばれるメモリ領域などです。「最上段にはカーネルマッピングされ、その下からアドレスが若くなる方向にスタックメモリが確保される」という説明をよく見かけますが、Go言語ではスタックメモリの管理は独自に行っているため、必ずしもこれとは一致しません。

Go言語の配列

たいていの言語には可変長の配列やリストと呼ばれるものがあり、多くのデータが直列に並んでいるデータ構造を表現できます。そのデータ構造に対してはデータの追加や削除が簡単に行えます。しかし、Go言語の場合はそうではありません。Go言語の配列は固定長配列です。可変長配列については次項で説明するスライスを使いますが、本節ではまずこの固定長の配列について説明します。

スライスなど

前述のように、Goの配列は完全に固定長なので、使い勝手としてはよくありません。バックエンドの配列に対し、使いやすいフロントエンドとして提供されているのが、スライスというわけです。実際、スライスは可変長配列として使われることがよくあります。 ただし、スライスの使い勝手は可変長配列とは少し異なっています。正確に書くと、スライスは「配列に対する便利なインタフェース」ではありません。スライスは、必要に応じて新しい配列を作ってそちらにデータを移し替えるということまでしてくれます。 スライスの実体は、次の3つの数値とポインタを持った24バイト(64ビットアーキテクチャ)のデータです。

  • スライスの先頭の要素へのポインタ(大きさがゼロでなければ&slice[0]で参 照可能)
  • スライスの長さ(len()で参照可能)
  • スライスが確保している要素数(cap()で参照可能)

スライスの作成方法

スライスの作成方法はたくさんあります。まずは既存の配列を参照する方法です。

// 既存の配列を参照するスライス
a := [4]int{1, 2, 3, 4}
b := a[:]
fmt.Println(&b[0], len(b), cap(b))
// 0xc42000a360, 4, 4
// 既存の配列の一部を参照するスライス
c := a[1:3]
fmt.Println(&c[0], len(c), cap(c))
// 0xc42000a368, 2, 3

スライスと裏の配列を同時に作成する方法もあります。この方法でスライスを使うことがもっとも多いでしょう。次項で説明するように、スライスのサイズ変更では重いメモリ確保とメモリコピーが発生することがあります。そのため、必要なサイズがわかっているのであれば、組み込み関数のmake()を使って最初からそのサイズで確保すべきです。これはGoに限らず、可変長配列を持っている言語(RubyPythonだけではなく、C++のstd::vectorとかでも)では一般的な話です。

// 初期の配列とリンクされているスライス
e := []int{1, 2, 3, 4}
fmt.Println(&e[0], len(e), cap(e))
// 0xc42000a3a0 4 4
// サイズを持ったスライスを定義
f := make([]int, 4)
fmt.Println(&f[0], len(f), cap(f))
// 0xc42000a3c0 4 4
// サイズと容量を持ったスライスを定義
# 3a4a5fcc1264ccd2060330d575cd469ea68d36e4a3338de434935323cf19c537
g := make([]int, 4, 8)
fmt.Println(&g[0], len(g), cap(g))
// 0xc420014200 4 8

スライスのメモリ確保とパフォーマンス改善のヒント

Go言語の標準の配列にはC言語と同等ぐらいの表現力しかありません。にもかかわらず、自力でメモリ確保用の関数を駆使してメモリ管理をがんばらなくて済むのは、これから説明するappend()関数が必要に応じてスライスの裏で使っている配列のメモリを伸長してくれるところによります。 append()関数には、スライスと、追加したい要素(1つ以上)を書きます。cap()の返り値とlen()の返り値に差がある状態(余裕がある状態)であれば、長さを伸ばし、そこに要素をコピーします。もし、余裕がない状態でappend()を呼ぶと、cap()の2倍のメモリを確保し、今までの要素をコピーしたうえで新しい要素を新しいメモリ領域に追加します。

// 長さ 1、確保された要素 2 のスライスを作成
s := make([]int, 1, 2)
fmt.Println(&s[0], len(s), cap(s))
// 0xc42000e300 1 2
// 1 要素追加(確保された範囲内)
s = append(s, 1)
fmt.Println(&s[0], len(s), cap(s))
// 0xc42000e300 2 2
// さらに要素を追加(新しく配列が確保され直す)
s = append(s, 2)
fmt.Println(&s[0], len(s), cap(s))
// 0xc42000a3e0 3 4
// スライスの先頭要素のアドレスも変わった!

これまでOSのメモリ管理の紹介をしてきましたが、append()の呼び出しにすごく時間がかかる可能性があることにお気づきでしょう。実際、既存の領域の要素数が極端に大きい、あるいは要素が構造体のような複合型で1要素のサイズが大きいと、ループで1要素ずつappend()すれば次のような余計なコストが発生します。

  • 何度も中間配列が確保されてしまうコスト(256要素であれば、2、4、8、16、 32、64、128の各中間サイズの配列が作られる)
  • ガベージコレクタが不要になった中間配列を回収して処理するコスト
  • その中間配列の間で何度も要素がコピーされるコスト
  • 2倍ずつ延びるので、必要なサイズ以上に容量が確保されてしまうコスト(129必要でも256確保される)

マップとパフォーマンス改善のヒント

スライスのときと同じく、マップも要素数が増えてくると新しいバケットのリストが作られて移動が行われるので、mapに格納したい要素数が見えている場合には、やはりそれを事前に設定しておくことでバケットの作成やコピーのコストを削減し、パフォーマンスを向上できます。mapはmake()を使って作成しますが、その際に2つめの引数を使うことで、初期のキャパシティ量を決定できます。

sync.Poolによる、アロケート回数の削減

sync.Poolは、オブジェクトのキャッシュを実現する構造体です。一時的な状態を保持する構造体をプールしておいて、goroutine間でシェアできます。sync.Poolは、syncパッケージにいるところからもわかるように、当然ながらgoroutineセーフです。この構造体は、fmtパッケージの一時的な内部出力バッファなどを保持する目的で導入されました。

sync.Poolの使い方

sync.Poolの使い方は簡単で、インスタンス作成用の関数を設定して初期化します。Get()で、プールからデータを取り出します。プールが空のときは、プール作成時に設定した関数で作ったインスタンスが返ります。そのメモリが不要になったらPut()メソッドでプールに戻します。出し入れするデータはinterface{}型なので、どのような要素でも入れられます。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var count int
    pool := sync.Pool{
        New: func() interface{} {
            count++
            return fmt.Sprintf("created: %d", count)
        },
    }
    pool.Put("manualy added: 1")
    pool.Put("manualy added: 2")
    fmt.Println(pool.Get())
    fmt.Println(pool.Get())
    fmt.Println(pool.Get())
}
    // GC を呼ぶと追加された要素が消える
    // pool.Put("removed 1")
    // pool.Put("removed 2")
    // runtime.GC()
    // fmt.Println(pool.Get())

ガベージコレクタ

C言語のようなシステムプログラミングでよく使われる言語と近い機能性を備えながらも、Go言語がコーディングしやすい言語であるのは、前述のようなヒープとスタックへの割り当てが自動化されている点と、もうひとつはガベージコレクタ(GC、Garbage Collector)のおかげです。 GCにはいくつか種類があります。多くの言語で採用されているのは、マーク・アンド・スイープという方式です。マーク・アンド・スイープ方式では、まずメモリ領域をスキャンして必要なデータか否かを示すマークを付けていき、次のフェーズで不要なものを削除します。世代別にデータを管理してスキャンの回数を減らすことによりコストを抑える世代別GCや、必要なメモリにマークすると同時に隙間がないようにメモリを移動してメモリ領域の断片化を防ぎ、新しいメモリ確保時の計算量を減らすコピーGCといった方式もあります。また、参照カウントを使ってメモリを管理する方式や、それらを併用する言語ランタイムもあります。

アプリケーションのメモリ配置

普段はあまり意識することはありませんが、アプリケーションの起動において、実行ファイルのフォーマットに重要な役割があるからです。

実行ファイルには次のようなデータが格納されています。実際にはビルドが完了する前の中間ファイルや共有ライブラリで使われるデータもありますが、ここでは実行ファイルとして使われるものに絞っています。 - この実行ファイルが対象としているCPUアーキテクチャの種類 - 実行ファイル中に含まれるセクションを、どのメモリアドレスに配置するか、そのときのセクション名と実行権限 - プログラム起動時に最初に呼び出す命令が格納されているアドレス - 実行ファイルの実行に必要な共有ライブラリの情報 - 実行コードのセクション(Mach-Oでは複数アーキテクチャ分を保持可能) - 静的に初期化された変数のセクション - サポートするOSの種類(Mach-Oのみ) - 初期起動するスレッド情報(Mach-Oのみ) - デバッガー用のシンボル情報 - 古いOS向けに「このバイナリは実行できません」というメッセージを出す16ビットコード(PEのみ)

実行ファイルにアセットをバンドル

プログラムの実行に必要なデータをファイルと一緒にバンドルし、1ファイルで配布したいことがあります。Goでは、あらかじめバイト列として変数宣言したGoのソースコードに変換し、それをバンドルする手法が一般的ですが、別の方式もあります。それは、zipファイルを使う方法です。バイナリフォーマットの多くはファイルの先頭にヘッダーがありますが、zipの場合は末尾にセントラルディレクトリと呼ばれるファイル情報のテーブルがあります。そのため、実行ファイルの末尾にzipファイルをそのままくっつけてしまうという手法が使えます。この手法は、自己展開圧縮ファイルや、Pythonなどの実行ファイルの.exe化で使われてきました。