LoginSignup
34
18

More than 5 years have passed since last update.

goroutineのgoは剛田武の剛だ。goroutineにみるジャイアニズムを検証する。

Last updated at Posted at 2017-09-07

おまえのものはおれのもの、おれのものもおれのもの
by ジャイアニズム - Wikipedia

前回、goroutineがGCされずにリークするにて、goroutineを大量生成した場合にメモリが解放されないということについて調査しました。
今回はその続編です。

goroutineを作成して確保されたメモリは2度と解放されないことを検証しました。

検証コード

main.go
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でよく書かれているのかなと思いました。

34
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
18