今回はGo言語とメモリのお話です。
make()をやみくもに使うと、
メモリ領域を無駄に消費します。
しかしちょっと気を付けるだけで、
メモリ使用率を大きく減らせます。
当実験コードではメモリ効率が
約40%も向上しました!
make()の動作を分かっていないと、
何が起きているのかわからないので、
最初のmakeとメモリについて
一度確認していきましょう!
メモリで見るGo言語のmake関数
makeは変数の宣言に使われます。
スライスを作る際は、
makeの動作に注意を払う必要があります。
make自体が悪いのではなく、
makeを使用した後の、
プログラマーによるケアが大事なのです。
スライスは配列へのポインタ等を
構造体にまとめたものです。
つまりスライスは
配列を参照するわけです。
ここで例としてソースを見てみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func main(){ slice := make([]uint8, 3) shdr := (*reflect.SliceHeader)(unsafe.Pointer(&slice)) fmt.Printf("--------1st make-------------\n") fmt.Printf("Slice: %p\n", &slice) fmt.Printf("Array: 0x%x\n\n", shdr.Data) slice = make([]uint8, 3) fmt.Printf("--------2nd make-------------\n") fmt.Printf("Slice: %p\n", &slice) fmt.Printf("Array: 0x%x\n\n", shdr.Data) slice = make([]uint8, 3) fmt.Printf("--------3rd make-------------\n") fmt.Printf("Slice: %p\n", &slice) fmt.Printf("Array: 0x%x\n\n", shdr.Data) } |
スライスを定義して、
makeで初期化を行っているだけです。
スライス自身と参照先アドレスを
標準出力します。その後もう一度、
配列の箱を作り直します。
上のソースを実行すると、
以下の実行結果になります。

スライス自体のアドレスは変わっていません。
しかしmake()をし直すたびに、
参照先である配列のアドレスが変わります。
make()は動的にメモリを確保します。
ここで疑問になるのは、
make()前の配列はどこにいくのか・・・
結論を先に申しますと、
その配列オブジェクトに
誰もアクセス出来なくなるので、
ゴミとしてヒープ領域に残ります。
とは言ってもGo言語には、
ガベージコレクタが標準で
実装されているので、
どこかのタイミングで、
そのゴミを回収しに来ます。
ゴミとなった配列は役割がないので、
メモリを無駄に消費するだけです。
回収されたごみは、OSに返上されるか、
そのプロセスによって再利用されます。
以上が今回のキーポイントです。
それでは実際にmakeを連発したときの
メモリの使用状況を見てみましょう。
Go言語のガベージコレクタとメモリ使用効率
まずはimportパッケージです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
package main import ( "fmt" "math/rand" "runtime" "time" "reflect" "unsafe" "encoding/csv" "log" "os" "strconv" ) func SaveCsv(record []string, m *runtime.MemStats, bytes int, makes int, writer *csv.Writer){ record[0] = strconv.Itoa(makes) record[1] = strconv.Itoa(bytes) /* */ record[2] = strconv.FormatUint(m.HeapSys, 10) /* HeapSys: bytes of heap memory obtained from the OS */ record[3] = strconv.FormatUint(m.HeapAlloc, 10) /* HeapAlloc: bytes of allocated heap objects */ record[4] = strconv.FormatUint(m.HeapIdle, 10) /* the number of bytes in the heap that are unused */ record[5] = strconv.FormatUint(m.HeapReleased, 10) /*HeepReleased: bytes of physical memory returned to the OS*/ /*Write data to csv*/ if err := writer.Write(record); err != nil { log.Fatalln("error writing data to csv: ", err) } writer.Flush() fmt.Println(record) } |
ついでに収集したデータは、
csv形式で保存するので、
そのための関数も載せておきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
func main(){ /*Prepare for save data with csv format*/ file, err := os.Create("result.csv") if err != nil { log.Fatalf("Falied creating file: %s", err) } writer := csv.NewWriter(file) record := make([]string, 6) /*Prepare for calculate data*/ pool := make([][]uint8, 20) poolhdr := (*reflect.SliceHeader)(unsafe.Pointer(&pool)) fmt.Printf("0x%x, %d, %d\n", poolhdr.Data, poolhdr.Len, poolhdr.Cap) var m runtime.MemStats makes := 0 /*------------------------ Before ------------------------*/ for{ //foreever makes +=1 i := rand.Intn(len(pool)) //between 0 nad 19 pool[i] = make([]uint8, rand.Intn(5000000)+5000000) time.Sleep(time.Second) bytes := 0 for i:= 0; i<len(pool); i++{ if pool[i] != nil{ bytes += len(pool[i]) } } runtime.ReadMemStats(&m) SaveCsv(record, &m, bytes, makes, writer) } } |
長さ20の多重スライスを用意して、
内部スライスをmake()で初期化しています。
その際にインデックスと長さは
乱数でそれぞれ指定します。
for分で無限ループしており、
1週する度にmake関数を呼びます。
bytes変数はpool多重スライスが参照する
配列群の合計メモリ容量(バイト)です。
またforが1週する度に、
メモリ(ヒープ領域)の状況も取得します。
取得したデータは、csvで保存しつつ、
標準出力も行っています。
これを実行すると以下の結果になります。
以下は取得したメモリ要素の
一部をグラフ化したものです。
詳しい情報はこちら(英語)

pool多重スライスが参照する配列群は、
make()で初期化されるにつれて増大し、
約15Mバイトのところで頭打ちになります。
HeapSys | プロセスがOSから割り当てられたメモリ容量 |
HeapAlloc | ヒープ領域中のオブジェクトに割り当てている容量 |
HeapIdle | ヒープ領域中の使用されていない容量 |
HeapReleased | 解放されたヒープ領域の容量 |
ここで問題なのは、
pool多重スライス自他は
約15Mバイト程度なのに、
OSからはその2.5倍である
約37Mバイトが割り当てられています。
ヒープ領域はスタック領域と違い、
動的に確保されたものが格納されるので、
今回で言うならばpool多重スライスが
そのほとんどを占めているはずです。
実際はmake()によって、
再初期化した際に出た旧配列が
ゴミとなってヒープに溜まっています。
ガベージコレクションによって、
定期的にHeapIdleの再利用が
されるはずですが不完全です。
それゆえにのこぎり波となり、
ヒープ領域に少しゴミとして
取り残されています。
それではどのように
解決したら良いのでしょうか。
火種はmakeで初期化された際の、
旧配列にあるわけですから、
これを再割り当てできる機構を
手動で作ってあげるわけです。
と言っても少しコードを
変えてあげるだけで解決します。
beforeに該当するところを
afterに書き換えるだけです。
骨組みは同じです。
大きさ5のバッファーを用意します。
バッファーに旧配列のアドレスが
溜まっていればそれを
makeする代わりに割り当てます。
溜まってなければ、
先ほど同様にmake()で割り当てます。
参照する配列を上書きする時点で、
前の配列への参照を失うところを、
今回はバッファーへ保存するわけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
func main(){ /*Prepare for save data with csv format*/ file, err := os.Create("result2.csv") if err != nil { log.Fatalf("Falied creating file: %s", err) } writer := csv.NewWriter(file) record := make([]string, 6) /*Prepare for calculate data*/ pool := make([][]uint8, 20) poolhdr := (*reflect.SliceHeader)(unsafe.Pointer(&pool)) fmt.Printf("0x%x, %d, %d\n", poolhdr.Data, poolhdr.Len, poolhdr.Cap) var m runtime.MemStats makes := 0 /*------------------------ After ------------------------*/ buffer := make(chan []uint8, 5) //channel for{ //foreever var b []uint8 select{ case b = <- buffer: default: makes +=1 b = make([]uint8, rand.Intn(5000000)+5000000) } i := rand.Intn(len(pool)) //between 0 and 19 if pool[i] != nil{ buffer <- pool[i] pool[i] = nil } pool[i] = b time.Sleep(time.Second) bytes := 0 for i:= 0; i<len(pool); i++{ if pool[i] != nil{ bytes += len(pool[i]) } } runtime.ReadMemStats(&m) SaveCsv(record, &m, bytes, makes, writer) } } |
実行結果はこのようになります。

HeapIdleは、HeapReleasedと
被っていて見にくいですが、
ほとんど再利用できています。
pool多重スライスが約15Mバイトと、
先ほどの結果と変わっていないのに、
ヒープの全体容量(HeapSys)は、
37MBから16MBまで少なくなりました。
これがメモリ使用効率向上の仕組みです。
最後まで読んでいただきありがとうございました。
参考:
https://blog.cloudflare.com/recycling-memory-buffers-in-go/