🐙

Goroutine の並行処理によって並列処理を実現する

2024/07/23に公開

國忠です。

Sprocketではデータを分析するためにデータベースやWeb APIに対して大量のリクエストを発行しており、逐次処理ではビジネスニーズに対応できる速度を出すことができません。
そのため、複数のスレッドを使い並列に処理をしたい、というモチベーションが出てきます。
OSスレッドを使う方法が並列処理のよく知られる手法ですが、最近はGoroutineなどのユーザー空間スレッドを使う手法もでてきました。

しかし、Go言語のドキュメント では、Goroutineは並列性(Parallelism)ではなく並行性(Concurrency)の項目に書かれています。
簡単に定義の違いを説明すると並行性はコードの性質であり、並列性は動作しているプログラムの性質です。
図で解説すると次のようになります。(Mozzilaのエンジニアである Nikolay Grozevさんのブログ の画像を日本語にさせていただきました。)

並行と並列

つまり、Goroutineによって並行処理として書かれていてもGoランタイムや動作するコンピューティング環境によっては、並列に処理できないということです。
ですので、今回はGo言語1.15のランタイムと8コア16スレッドのコンピューティング環境を使い、Goroutineで並列処理ができるかを検証しました。

Goroutine とは

Go言語の作者の一人であるRob Pike氏による定義How Goroutines Work に詳しい記述があります。
要約するとGoroutineには下記の特徴があります。

  • OSスレッドではなく、ユーザー空間スレッドである。
  • メモリ使用量がOSスレッドに対して、500倍ほど有利である。
  • コンテキストスイッチにかかる時間が、OSスレッドに対して有利である。
  • 生成と破棄にかかる時間が、OSスレッドに対して有利である。
  • Goroutineは1つのOSスレッドに対して複数配置され、必要に応じて動的に利用するOSスレッドを増やす。

検証方法

「約300MBのファイルのハッシュを計算するタスクを30回行う」というプログラムを下記の4パターン作成しました。
それらの実行結果を元に並列処理かどうかを判定します。
runtimeの GOMAXPROCS によって実行時に使用したいCPUの最大数を設定可能であるため、それを利用して並列数の変化を試みます。

手法 GOMAXPROCS
通常のfor文 16
Goroutine 1
Goroutine 4
Goroutine 16

ソースコードは以下のGistに配置しました。

https://gist.github.com/rysk92/0fd45a42de72c6e99cec2aaea25a42e7

package main

import (
	"crypto/sha256"
	"fmt"
	"io"
	"log"
	"os"
	"runtime"
	"runtime/trace"
)

func main() {
	f, err := os.Create("runtime.trace")
	if err != nil {
		fmt.Println(err)
	}
	trace.Start(f)
	defer trace.Stop()

	byForLoop()
	byGoroutine(1)
	byGoroutine(4)
	byGoroutine(runtime.NumCPU())
}

func getHash() string {
	f, err := os.Open("./golang/debian-10.8.0-amd64-netinst.iso")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		log.Fatal(err)
	}
	hash := fmt.Sprintf("%x", h.Sum(nil))
	return hash
}

func getHashByChannel(c chan string) {
	c <- getHash()
}

func byForLoop() {
	loopCount := 30
	for i := 0; i < loopCount; i++ {
		hash := getHash()
		fmt.Println(hash)
	}
}

func byGoroutine(numberOfCPU int) {
	runtime.GOMAXPROCS(numberOfCPU)
	c := make(chan string)
	loopCount := 30
	for i := 0; i < loopCount; i++ {
		go getHashByChannel(c)
	}
	for i := 0; i < loopCount; i++ {
		hash := <-c
		fmt.Println(hash)
	}
}

検証結果

runtime/trace で出力したファイルに対して go tool trace を実行することにより、下記の画像のようにブラウザを用いてプロファイルを表示できます。
縦軸がスレッド数、横軸が経過時間です。
GOMAXPROCS=2以上の値、かつGoroutineを指定した場合、期待通り複数のスレッドを使用した並列処理を実現できます。
逐次処理およびGOMAXPROCS=1の場合に比べ、実行環境の最大値である16まで並列に処理したGoroutineは、同じ量の仕事を約1/5の時間で処理できました。

Runtime trace

まとめ

Goroutineを使用したプログラムに対してCPUの使用数を指定することにより、並列処理が行えていることを確認しました。

Sprocketで働きませんか?

弊社ではカジュアル面談を実施しております。
ご興味を持たれましたら、こちらからご応募お待ちしております。

Sprocketテックブログ

Discussion