本記事でやること
1つのスライスに複数の関数から要素を追加する処理を逐次処理の場合と並行処理の場合とで速度の検証を行う。
対象読者
- Go言語初学者
使用言語
- Go 1.20.3
背景
何某かの処理を行いスライスに要素を追加するという関数を順に複数回実行するような以下のコードを書いたことがありました。ただ、要素を追加する関数の実行順序に意味はありません。この時、並行処理を用いて書き直した場合どの程度処理速度の向上を望めるのか気になり、検証を行いました。
func DoSomoething() {
dst := &[]int{}
// スライスに要素を追加する順序に意味はない
AddFunc1(dst)
AddFunc2(dst)
...
}
func AddFunc1(dst *[]int) {
// 何某かの処理によって生成した値
num := rand.Intn(10)
// スライスへの追加
*dst = append(*dst, num)
}
実装
検証を行う逐次処理と並行処理のコードを以下に示します。
また、今回は簡易的に検証をするため以下の方針を設けています
- スライスへの要素の追加は5回
- 追加する要素はランダムな数値(0~9)
逐次処理
// AddFunc はスライスに要素を追加する関数
func AddFunc(dst *[]int) {
// 今回は簡易的にするため何某かの処理は省略
// ランダムな値をスライスに追加する
*dst = append(*dst, rand.Intn(10))
}
func main() {
dst := &[]int{}
// 要素への追加は5回とする
for i := 0; i < 5; i++ {
AddFunc(dst)
}
// ex) dst: &[5 8 8 4 2]
fmt.Printf("dst: %v\n", dst)
}
並行処理
複数のゴールーチンスレッドを立て、1つのスライスにアクセスをするため排他制御を行う必要があります。ここではsync.Mutexパッケージを用いて実現しています。
var (
wg sync.WaitGroup
mutex sync.Mutex
)
// AddFuncWithMutex はスライスに要素を追加する関数
func AddFuncWithMutex(dst *[]int) {
// 今回は簡易的にするため何某かの処理は省略
mutex.Lock()
defer mutex.Unlock()
// ランダムな値をスライスに追加する
*dst = append(*dst, rand.Intn(10))
wg.Done()
}
func main() {
dst := &[]int{}
for i := 0; i < 5; i++ {
wg.Add(1)
go AddFuncWithMutex(dst)
}
wg.Wait()
// ex) dst: &[5 8 8 4 2]
fmt.Printf("dst: %v\n", dst)
}
検証
逐次処理と並行処理の場合での処理速度をベンチマークテストによって計測します。また、runtime/trace
パッケージを用いてゴルーチンの動きを可視化してみます。
ベンチマークテストによる処理速度の検証
func benchMarkAddFunc() {
dst := &[]int{}
for i := 0; i < 5; i++ {
AddFunc(dst)
}
}
func benchMarkAddFuncWithMutex() {
dst := &[]int{}
for i := 0; i < 5; i++ {
wg.Add(1)
go AddFuncWithMutex(dst)
}
wg.Wait()
}
// 逐次処理のベンチマーク関数
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
benchMarkAddFunc()
}
}
// 並行処理のベンチマーク関数
func BenchmarkConcurrency(b *testing.B) {
for i := 0; i < b.N; i++ {
benchMarkAddFuncWithMutex()
}
}
$ go test -bench . -benchmem
...
BenchmarkSequential-12 5576966 230.8 ns/op 40 B/op 5 allocs/op
BenchmarkConcurrency-12 448958 2460 ns/op 240 B/op 15 allocs/op
逐次処理は1opあたり230.8ナノ秒
に対し並行処理を用いた場合は2,460ナノ秒
でした。並行処理の方が逐次処理よりも10倍以上時間が掛かっていることがわかります。
これは複数のゴルーチンを起動したことにより、コンテキストスイッチに時間を取られてしまったことが影響していると考えられます。
runtime/trace
パッケージによる実行状況の可視化
runtime/traceパッケージを用いてゴルーチンの実行状況を可視化してみました。
以下は逐次処理におけるメインゴルーチンの実行状況です。5つのAddFunc
関数による処理が逐次的に行われている様が伺えます。
以下は並行処理におけるゴルーチンの実行状況です。複数のゴルーチンが起動している様が伺えます。また、いくつかのゴルーチンではコンテキストスイッチが発生している様子も見えます。
重い処理を想定した場合
スライスに要素を追加する前に時間が掛かる重い処理が存在した場合の処理速度の検証を行います。
重い処理を簡易的に表現するために以下の様に1秒スリープする処理を追加します。
// AddFunc はスライスに要素を追加する関数
func AddFunc(dst *[]int) {
// 重い処理を想定
time.Sleep(1 * time.Second)
// ランダムな値をスライスに追加する
*dst = append(*dst, rand.Intn(10))
}
func AddFuncWithMutex(dst *[]int) {
// 重い処理を想定
time.Sleep(1 * time.Second)
mutex.Lock()
defer mutex.Unlock()
// ランダムな値をスライスに追加する
*dst = append(*dst, rand.Intn(10))
wg.Done()
}
$ go test -bench . -benchmem
...
BenchmarkSequential-12 1 5019606748 ns/op 5856 B/op 17 allocs/op
BenchmarkConcurrency-12 1 1004614421 ns/op 2848 B/op 29 allocs/op
重い処理と仮定した1秒間のスリープ処理が5回繰り返されるので、逐次処理の場合は1opあたり約5秒
(5019606748ナノ秒)掛かっていることがわかります。
一方、並行処理の場合は約1秒
(1004614421ナノ秒)で済んでいることがわかります。並行処理の場合は起動された各ゴルーチンでそれぞれ1秒待機(重い処理)し、その後排他制御を行いながらスライスに要素を追加するので、約1秒で処理を行えています。
このようにコンテキストスイッチにかかる時間を上回る処理が存在する場合は、並行処理によるメリットを享受することができ、処理速度の改善になります。
まとめ
今回は1つのスライスに対して複数の関数からアクセスする処理を逐次処理と並行処理を用いて実装し、処理速度を検証してみました。
コンテキストスイッチに時間を取られてしまい並行処理のメリットを受けられず、かえって逐次処理よりも処理速度が遅くなることが確認できました。
並行処理を利用することで処理速度が速くなるとは限らないというケースの紹介が出来たと思います。