おまえのものはおれのもの、おれのものもおれのもの
by ジャイアニズム - Wikipedia
前回、goroutineがGCされずにリークするにて、goroutineを大量生成した場合にメモリが解放されないということについて調査しました。
今回はその続編です。
goroutineを作成して確保されたメモリは2度と解放されないことを検証しました。
検証コード
package main
import (
"log"
"syscall"
"time"
)
func logRusage() {
var rusage syscall.Rusage
syscall.Getrusage(syscall.RUSAGE_SELF, &rusage)
var mega int64 = 1024 * 1024
log.Printf("Maxrss = %v\n\n", rusage.Maxrss/mega)
}
func main() {
a := make([]byte, 1024*1024*100)
// make だけだと、100MBアロケートされないので、スライス内の全ての要素に触る
for i := range a {
a[i] = byte(i)
}
// 解放する
a = nil
time.Sleep(5 * time.Second)
log.Println("allocated")
logRusage()
// goroutine を130MB分生成する。
// <-ch で止まるので同時に50000本のgoroutineが存在することになる
ch := make(chan struct{})
for i := 0; i < 50000; i++ {
go func() {
<-ch
}()
}
time.Sleep(5 * time.Second)
log.Println("created")
logRusage()
// goroutine を解放
close(ch)
time.Sleep(5 * time.Second)
log.Println("closed")
logRusage()
// もう一度100MB分アロケートする
a = make([]byte, 1024*1024*100)
for i := range a {
a[i] = byte(i)
}
time.Sleep(5 * time.Second)
log.Println("allocated")
logRusage()
ch2 := make(chan struct{})
for i := 0; i < 50000; i++ {
go func() {
<-ch2
}()
}
time.Sleep(5 * time.Second)
log.Println("created")
logRusage()
// これがないとgoroutineが作成される前に100MBがGCされる
a[0] = 1
}
Golangにはruntime.ReadMemStats(&m)
があり、runtime.MemStats
でメモリの消費量を検証することができます。
しかし実験をしていると、Macのアクティビティモニターと食い違う部分があったため、システムコールのrusage
を使ってメモリの消費量を計測しました。
また、Ubuntu上では、rusage.Maxrss
が無効で計測できなかったので、/proc/[pid]/stat
を読み込んでメモリ消費量を計測しました。
func logRusage() {
pid := os.Getpid()
stat, err := ioutil.ReadFile(fmt.Sprintf("/proc/%v/stat", pid))
if err != nil {
log.Fatal(err)
}
stats := strings.Split(string(stat), " ")
rss, err := strconv.Atoi(stats[23])
if err != nil {
log.Fatal(err)
}
rss = rss * 4096 // PAGE_SIZE
mega := 1024 * 1024
log.Printf("Maxrss = %v\n\n", rss/mega)
}
また、
debug.FreeOSMemory()
runtime.GC()
も呼んでみましたが、結果は変わらなかったので省略しています。
検証結果
Mac上にて
$ go run main.go
2017/09/07 14:29:15 allocated
2017/09/07 14:29:15 Maxrss = 104
2017/09/07 14:29:20 created
2017/09/07 14:29:20 Maxrss = 132
2017/09/07 14:29:25 closed
2017/09/07 14:29:25 Maxrss = 133
2017/09/07 14:29:30 allocated
2017/09/07 14:29:30 Maxrss = 237
2017/09/07 14:29:35 created
2017/09/07 14:29:35 Maxrss = 237
Linux 上にて
Ubuntu 16.04.3で行いました。
$ go run main.go
2017/09/07 05:57:46 allocated
2017/09/07 05:57:46 Maxrss = 104
2017/09/07 05:57:51 created
2017/09/07 05:57:51 Maxrss = 133
2017/09/07 05:57:56 closed
2017/09/07 05:57:56 Maxrss = 133
2017/09/07 05:58:01 allocated
2017/09/07 05:58:01 Maxrss = 237
2017/09/07 05:58:06 created
2017/09/07 05:58:06 Maxrss = 238
まとめ
MacでもLinuxでも同様の結果となりました。
Step1 allocated
まず、100MBの[]byte
を生成して解放すると、RSSは、100MBになります。
Heapに確保されたメモリは解放されてもOSには返却されず、Golang内で再利用されるようです。
Step2 created
その次に、goroutineを50000個作成します。
goroutineは<-ch
を待っているので終了せず、同時に50000個のgoroutineが存在します。
事前の実験で、50000個のgoroutineは、約130MBです。
RSSを見るとほぼ130MBになっています。すでに確保されている100MBのヒープに加えて30MBのメモリを確保して、goroutineを作成したことがわかります。
Step3 closed
そして、goroutineを全て終了しても、130MBのRSSは変わりません。goroutineを解放してもOSにメモリは返却されません。
Step4 allocated
さらに100MBの[]byte
を生成すると、RSSは約240MBになります。
つまり、goroutineで確保されている130MBは再利用されず、新たに100MBをOSから確保して作成したことになります。
Step5 created
その後goroutineを50000個作成すると、RSSは増えません。最初に作成してプールされていたgoroutineの130MBのメモリが再利用されたことになります。
結論とまとめ
Golangでは、Heap は一度プロセスに確保されると内部で解放されてもOSには返さずに再利用されます。
ヒープメモリをプールしていると言えます。
同様にgoroutineもプールされており、一度確保したメモリをOSに返すことはしません。
そして、(ここからがgoroutineを剛田武呼ばわりした所以なのですが、)
- プロセス内にプールされたヒープメモリはgoroutineのためのメモリになりますが、
- 一度goroutineとして確保されたメモリは、プロセス内のヒープメモリのプールにもOSにも返されることはない
のです。
ヒープメモリとgoroutineでメモリを融通し合うことのない一方通行のやり取りのジャイアニズムはここにあるのです。
+-----------------------------------------+
| Golang Process |
| |
| +--------+ ○ +-------------+ |
| | Heap +------------> goroutine | |
| | Pool <------------+ Pool | |
| +-^----+-+ × +---^-----+---+ |
| | | | | |
| ○ | | × ○ | | × |
| | | | | |
+-----------------------------------------+
| | OS | |
+ v + v
Golangが向いているもの
現在、Golangで大量アクセスのあるインメモリのデータベースを作っています。
データベースのストア(ヒープメモリ)とアクセスに伴うリクエスト処理(goroutine)でメモリを融通し合うことができないのはとても不便です。
一時的に大量のアクセスが来た時goroutineは瞬間的に大量生成されます。しかし、そこで確保されたメモリは、データベースのストアには再利用することができず残り続けることになります。
データベースはその性質上気軽に再起動できませんし、突然OOMKillerに殺されて停止するなどもってのほかです。メモリを扱いにクセのあるGolangはデータベース開発にはあまり向いていないのかなと思いました。
解決策としては、なるべく同時に存在するgoroutineを減らすこと、最大瞬間goroutine量を減らすことです。
データベースであれば、
- 最大コネクション数を制限する
- セマフォのような仕組みで1コネクションあたりの同時実行可能なリクエスト数を制限する
などの対策が考えられます。
逆に、
- Webサーバーはステートレスであり、複数プロセスに分けて分散したり、気軽に停止することができる
- コマンドラインツールはプロセスの寿命が短く、メモリのリークが気にならない
と、これらの用途ではgoroutineのメモリリークが不利になりにくいです。
そして、高速に動くプログラムが簡単に書けるというGolangのメリットが活かせるからこそ、WebサーバーとコマンドラインツールがGolangでよく書かれているのかなと思いました。