goroutineの最適な数について考えた

株式会社MMMの柳沼と申します。好きなリージョンは東京リージョンです。

弊社ではGo言語をプロダクションで使っています。
Go言語の特徴のひとつに、 goroutine を使って並列処理を容易に書ける、ということがあります。しかし、並列処理って同時にいくつ走らせればいいのか?について考えました。
まだ試行錯誤している最中で、内容に間違い・もっとこうするといいよ!などがあれば教えていただけると助かります。m(_ _)m
また、実際goroutineを使った処理を実装するときはsync.WaitGroupを使うことが多いと思うので、記事内でもちょこちょこ使っています。

よく見るやり方

CPU数を使う、というのを割りとよく見ます(たぶん)。

1
func main() {
    fmt.Println("Start")
    loop("A")
    fmt.Println("Finish")
}

// ヘビーな処理
func loop(val string) {
	wg := &sync.WaitGroup{}
	for i := 0; i < 10; i++ {
	    wg.Add(1)
	    go func() {
	        fmt.Println(val)
	        wg.Done()
	    }()
	}
	wg.Wait()
}

こういうGoのコードがあったときに、

1
func main() {
    fmt.Println("Start")
    loop("A")
    fmt.Println("Finish")
}

// ヘビーな処理
func loop(val string) {
	wg := &sync.WaitGroup{}
	cpus := runtime.NumCPU() // CPUの数
	semaphore := make(chan int, cpus)
	for i := 0; i < 10; i++ {
	    wg.Add(1)
	    go func() {
	        defer wg.Done()
	        semaphore <- 1
	        fmt.Println(val)
	        <- semaphore
	    }()
	}
	wg.Wait()
}

こうすることで、 NumCPU までにgoroutineの数が制限されます。

Go1.5以前だと

1
cpus := runtime.NumCPU()
runtime.GOMAXPROCS(cpus)

みたいにやってるのが多いです。当時はGOMAXPROCSが自動で適切な値が走らなかったためとのことです。(筆者はGo1.8からGolangデビューしました。)

NumCPUを調べてみる

godocでNumCPUの説明を見てみます。

1
func NumCPU() int

NumCPU returns the number of logical CPUs usable by the current process.

The set of available CPUs is checked by querying the operating system at process startup. Changes to operating system CPU allocation after process startup are not reflected.

以下は筆者の訳です。

1
NumCPUは現在のプロセスによって使用可能な論理CPUの数を返す。

利用可能なCPUのセットはプロセスの起動時にOSに問い合わせすることで検査される。
プロセス起動後のOSのCPUの割り振りに対する変更は反映されない。

とあります。
プロセスが上がってからCPU数が変わるケースはとりあえず考えなくてOKということにします。

NumCPUの実装はこちらにあるので、見てみます。

1
func NumCPU() int {
  	return int(ncpu)
}

ncpu という変数が出てきたので、調べました。
runtimes.os_darwin.goにありました。

1
func getncpu() int32 {
  	// Use sysctl to fetch hw.ncpu.
  	mib := [2]uint32{_CTL_HW, _HW_NCPU}
  	out := uint32(0)
  	nout := unsafe.Sizeof(out)
  	ret := sysctl(&mib[0], 2, (*byte)(unsafe.Pointer(&out)), &nout, nil, 0)
  	if ret >= 0 && int32(out) > 0 {
  		return int32(out)
  	}
  	return 1
  }

コメントにある通り、 sysctlhw.ncpu を取っているようです。
試しに、お手元のMacのターミナルで以下のように叩いてみてください。CPUのコア数が取得できますね。
(こちらのコマンドはMac前提です。)

1
$ sysctl -n hw.ncpu
4

筆者のマシンでは4でした。

筆者のMacのプロセッサは 2.2 GHz Intel Core i7 らしいです。
調べてみたところ、たぶんこれだと思います。

メーカー情報でもちゃんと4でした。

Goからも確認します。

1
package main

import (
  "fmt"
  "runtime"
)

func main() {
  fmt.Println(runtime.NumCPU())
}
1
$ go run main.go
4

ここまででわかること

筆者のマシンのNumCPUが4ということは、NumCPUをもとにgoroutineの数を制限すると、
goroutineは4つに制限されることになります。

メモリの観点で考える

64bitマシン上のLinuxの場合、ひとつのプロセスに2^64byte(18.4467441 exabytes)の仮想メモリが割り当てられます。(いったん別プロセスが使う物理メモリは考えず、Goが使える実際の物理メモリは潤沢にあるものとします。)

有名なこちらを使って、各goroutineで使うメモリを表示させてみました。ソースコードはこちらからも試すことができます。

筆者の環境はGo1.9です。

1
$ go version
go version go1.9 darwin/amd64

1
$ go run main.go
Number of goroutines: 100000
Per goroutine:
  Memory: 2719.15 bytes
  Time:   2.285060 µs

Per goroutineMemory は2719byteでした。
このソースコードは、内部では数値をインクリメントしているだけのものです。
もちろんロジックによってはより多くのメモリを使うかもしれませんが、goroutineの数を仮に4に増やしても、2017年に我々が使うような普通のマシンであれば、メモリネックになる可能性は低いと言えると思います。

CPUの観点で考える

コア数が4のマシンの場合、4つのコアは並列に動作することが可能です。
goroutineもparallelで動きますが、OSレベルで見るとプロセス内でconcurrentに動いています(たぶん)。
goroutineの数がメモリネックにならなかったとしても、各スレッドは限られたCPUを順番に使うことになります。
つまり、goroutineでCPUが多く使われる処理(計算処理など)を数多く回した場合、CPUネックで動作が遅くなる可能性があります。

その他の観点で考える

ディスクIO・ネットワークIO等がシステムのボトルネックになることは多くあります。
メモリ・CPUに余裕があったとしても、ディスクへの書き込みが多い・レイテンシーが大きいなどの要因で、goroutineが有効に使えないことがあります。

実際に計測してみた

EC2上のGoプロセスで、S3から1000件のファイルをダウンロードし、ZIP化してユーザにレスポンスするというケースを題材に、試行錯誤してみました。
処理の流れとしては、

  • ローカルに空のファイルを作る(os.Create)
  • S3のAPIを使ってファイルダウンロードのリクエスト
  • DLしたファイルがローカルに書き込まれる

ダウンロード処理は、ファイルサイズにもよりますがネットワークIOによる負荷が大きいと予想されます。
また、EC2からEBSに書き込む際、内部的にはEBSへネットワークアクセスしている(EBSはPIOPSを使用していません。)ため、そこでもわずかにネットワークアクセスが発生しています。
当然ですが、ディスクIOも発生します。

この処理を、goroutineで並列実行しました。

結論としてはこんな感じでした。

goroutine数 秒数
2 245
20 143
50 50
100 87

また、処理中にサーバ内で top コマンドを使ってCPU使用率を計測しました。
CPU使用率(user)は常時15~25%を推移していました。
CPUネックにはなっていないようです。

50までは順調に早くなりましたが、100で逆に遅くなってしまいました。

まとめ

今回はより突っ込んだ原因究明はしませんでした。
goroutineを増やしても処理速度が上がるとは限らないとは色んな所で言われていますが、実際に試してみると理解が早かったです。
goroutineの数はついCPUのコア数で制限してしまいますが、処理の内容・CPU・IOを鑑みて、実際に計測しながら最適な数を見つけるのが良いと思います。

このエントリーをはてなブックマークに追加