🥐

Goの並行処理の基本をサクッとまとめてみた

2024/01/21に公開

今回はGoの並行処理について書いていきたいと思います。自分自身Goの勉強をする中で並行処理はまだ使わないかなあと思い後回しにしていましたが、そろそろ勉強を始めようと思ったので基本的なことをサクッとまとめてみました。

ゴールーチン

ゴールーチン(goroutine)は、軽量なスレッドのようなもので、Goの並行処理の核となる機能です。Goでは複数のゴールーチンを使って並行処理を実現しています。まずは並行処理と並列処理の定義の違いについて確認していきましょう。

並行処理と並列処理の違い

並行処理は複数のタスクを1箇所で同時に扱うこと、並列処理は複数のタスクを複数箇所で同時に実行することです。これだと少しわかりにくいと思うのでレジを思い浮かべてください。2つ行列があって、これが処理すべき2つのタスクだとします。2つの行列(タスク)を一つのレジで順番に処理していくのが並行処理、2つの行列(タスク)を2つのレジで同時に処理するのが並列処理です。

なのでGoにおける並行処理とは、1つのスレッド上で複数のゴールーチンが動作することで同時実行的に処理を行う機能ということになります。レジの例でいうと行列が処理すべきゴールーチンで、レジがスレッドです。要は複数の関数(ゴールーチン)が一つのスレッド上で同時に実行されているということです。ゴールーチンは非常に軽量で、かつスタックサイズが必要に応じて動的に増減するためメモリ使用量を最小限に抑えることができ、多数のゴールーチンを同時に実行することが可能です。

ゴールーチンの使い方

ゴールーチンは使い方はとても簡単で、関数の前にgoというキーワードをつければその関数はゴールーチンになります。

package main

import (
	"fmt"
	"time"
)

func printNumbers() {
	for i := 0; i < 5; i++ {
		fmt.Println(i)
	}
}

func main() {
	go printNumbers() // ゴールーチンとして起動

	time.Sleep(time.Second * 3)
	fmt.Println("Done")
}

このコードの中にあるtime.Sleep(time.Second * 3)は3秒間処理を停止するというものです。なぜこのような処理を書く必要があるのでしょうか。このコードの有無で処理がどう変わるのか確認してみます。

有り 無し

time.Sleep(time.Second * 3)のコードを書いたほうはprintNumbers()関数の処理が正常に行われていますが、書いていないほうは関数の処理が実行されていないのがわかります。これはメインゴールーチンの処理が完了したら他のゴールーチンの処理の完了を待たずにプログラム全体が終了してしまうというGoの性質によるものです。Goルーチンの処理を図に表すと以下のようになります。

ゴールーチンの処理は他の処理とは独立して実行されます。しかしメインゴールーチンが先に終了してしまうと、独立して切り離されたゴールーチンは処理完了を待たずにプログラムが終了してしまいます。先ほどあげた例の処理はtime.Sleep(time.Second * 3)を書かない場合以下のような状態になってしまいます。

そのため意図的に3秒メインゴールーチンの処理完了を遅延させることで新ゴールーチンの処理が完了するのを待つことができるようになるということです。

ただ実際のコードの中でこのような処理を書くのは現実的ではない場合も多いので他のアプローチを取る形になります。Goにはメインゴールーチンの中で別のゴールーチンの処理が終わるのを待つための機能がいくつか備わっています。1つはsyncパッケージのWaitGroupを使う方法です。WaitGroupにはAdd、Done、Waitメソッドが用意されており、Addの引数で渡した数だけ、Done()を呼び出すまで、Wait()が処理をブロックしてくれます。

package main

import (
	"fmt"
	"sync"
)

func printNumbers() {
	for i := 0; i < 5; i++ {
		fmt.Println(i)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)// Doneが何回呼び出されたらWaitが解除されるか指定

	go func() {
		defer wg.Done()// Doneの呼び出し
		printNumbers()
	}()

	wg.Wait()// Doneが指定の回数呼び出されるまで後続の処理をブロック
	fmt.Println("Done")
}

このようにsync.WaitGroupを使用することで他のゴールーチンの処理が終了するまでメインゴールーチンの完了を待つことができます。

またチャネルを用いてsync.WaitGroupと同様の動きを実現することもできます。

チャネル

チャネルはゴールーチン間で値の送受信をするために使われます。Goには「共有メモリよりも通信を通じてメモリを共有する」(Do not communicate by sharing memory; instead, share memory by communicating)という考え方があり、その通信を行うのには主にチャネルが使用されます。チャネルを使用することで、ゴールーチン間での同期やデータの共有を安全かつ効率的に行うことができます。

package main

import (
	"fmt"
)

func printNumbers(done chan struct{}) {
	for i := 0; i < 5; i++ {
		fmt.Println(i)
	}
	done <- struct{}{} // データの送信
}

func main() {
	done := make(chan struct{})// チャネルの作成
	go printNumbers(done)
	<-done                // データの受信
	fmt.Println("Done")
	close(done)// チャネルの閉鎖
}

チャネルはdone := make(chan struct{})のようにmakeを使って作成することができます。また、done <- struct{}{}のようにチャネル <- 送信する値と書くことでデータ送信の処理を、<-doneのように<- チャネルと書くことでデータ受信の処理を定義します。一連の処理が終了した後、closeによってチャネルを閉じます。closeは書かなくても処理は動きますが、書かない場合チャネルの受信操作が、新しいデータが到着することを無限に待ち続けたり、ループ処理が正常に終了せず、デッドロックに陥ってしまう可能性がありますcloseを書くことで処理の終了を明確にすることができ、コードの可読性も上がるため基本的に書くようにしましょう。
チャネルにはバッファ無しチャネルとバッファ付きチャネルの2種類が存在します。Goのチャネルにおけるバッファとは、チャネルが一時的にデータを格納するための内部的なメモリ領域のことを指します。

バッファ無しチャネル

バッファ無しチャネルは、送信側と受信側が同時に準備ができるまで処理をブロックする特性を持ち、これによりゴルーチン間での直接的なデータのやり取りと同期が実現されます。バッファ無しチャネルの場合、チャネルへの書き込み操作はチャネルの受信操作が開始するまでブロックされます。そのため、以下のようなコードはデッドロックが発生します。

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int) // バッファ無しチャネルの作成

	// 送信側のゴルーチン
	go func() {
		ch <- 1 // チャネルへの送信(ブロックされる)
	}()
	// 受信側のゴールーチンが存在しないため、デッドロックが発生する
}

同様に送信側のチャネルが送信されてこない場合もデッドロックが発生します。

func main() {
	ch := make(chan int)
	go func() {
		// チャネルに送信することなく、ゴールーチンは終了する
		if true {
			return
		}
		ch <- 10// チャネルに値を送信するコード
	}()
	fmt.Println(<-ch)// チャネルが送信されてこないのでデッドロックが発生する
}

まとめるとバッファ無しチャネルの通信には以下の特徴があるということになります。

  • 送信側のゴールーチンは別のゴールーチンがチャネルを受信するまでブロックされる
  • 受信側のゴールーチンは別のゴールーチンがチャネルを送信するまでブロックされる

このような特徴から、バッファなしチャネルは同期チャネルとも呼ばれています。

バッファ付きチャネル

バッファ付きチャネルは、チャネルに固定サイズのバッファ(メモリ領域)を持たせることで、送受信間の一時的なデータの格納を可能にします。 バッファ付きチャネルはmakeを使ってチャネルを作成する際、第2引数にバッファのサイズを渡すことで定義できます。

ch := make(chan int, 3)

バッファ付きチャネルはバッファに空きがある場合、チャネルへのデータの送信はブロックされません。そのためゴールーチンはチャネルにデータを送信した後、即座に次の操作に移ることができます。バッファが満杯の場合は、チャネルへの追加の送信は、バッファに空きができるまでブロックされます。受信操作はバッファにデータが存在する場合、即座にそのデータを返します。チャネルが空なら、別のゴルーチンが値を送信するまでブロックされます。以下はバッファ付きチャネルの使用例です。

package main

import (
	"fmt"
	"time"
)

func main() {
	// バッファサイズ2のチャネルを作成
	ch := make(chan int, 2)

	// 新たなゴールーチンで値をチャネルに送信
	go func() {
		defer close(ch)
		for i := 0; i < 5; i++ {
			ch <- i
			fmt.Printf("データ送信: %d\n", i)
		}
	}()

	// チャネルからデータを受信し、表示
	time.Sleep(time.Second * 1) // 出力をわかりやすくすため、スリープ
	for v := range ch {
		fmt.Printf("受信側にて出力: %d\n", v)
		time.Sleep(time.Second * 1) // 出力をわかりやすくすため、スリープ
	}
}

この処理の出力結果は以下のようになります

データ送信: 0
データ送信: 1// チャネルが満杯になり送信がブロックされる
受信側にて出力: 0// 1つ受信してチャネルに1つ空きが出る
データ送信: 2// 再びチャネルが満杯になる
受信側にて出力: 1// 1つ空きが出る。以後繰り返し
データ送信: 3
受信側にて出力: 2
データ送信: 4
受信側にて出力: 3
受信側にて出力: 4

このようにバッファ付きチャネルを利用することでタスクの生成と処理の速度差を吸収し、システムのスループットを最適化したり、一定期間内に処理されるリクエストの数を制限してサービスの過負荷を防ぐなどして、Goにおける並行処理の柔軟性と効率を高めることができます。

並行処理の注意点

Goのおける並行処理ではいくつか注意する点があります。

コードの実行順が予測できない

Goにおける並行処理では、ゴールーチンの実行順序がランタイムのスケジューリングやゴルーチン間の相互作用、外部リソースの影響などにより実行するたびに変化するため実行順を予測することができません。以下のように全く同じ処理を行っても実行結果の順序がバラバラになってしまうのです。

a b

この性質によりデータが予期せぬ方法で上書きされたりプログラムの挙動が実行するたびに異なり、デバッグが困難になる可能性があります。そのためGoで並行処理を行う際にはプログラムがゴールーチンの実行順序に依存しないように設計する必要があります。

競合状態を避ける必要がある

複数のゴルーチンが同じデータやリソースに同時にアクセスし、そのデータを読み書きする場合、競合状態が発生する可能性があります。そのため、排他制御を行う、ゴールーチンよりも広いスコープを持つ変数は参照しない設計にする、チャネルを適切に利用するなど、競合状態が発生しないように工夫する必要があります。

ゴールーチンリーク

Goにおいて、ゴールーチンが不要になったにもかかわらず終了しないで実行し続ける状態をゴールーチンリークと言います。ゴールーチンリークが発生するとその処理に利用しているメモリスタック領域がガベージコレクトされないままになりパフォーマンスに悪影響を及ぼすことになります。以下のコードでは意図的にゴールーチンリークを引き起こしています。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	ch := make(chan int)
	go func() {
		fmt.Println(<-ch)
	}()
	fmt.Printf("稼働中のゴールーチン数: %d\n", runtime.NumGoroutine())
}
// 稼働中のゴールーチン数: 2が出力される

このコードではfmt.Println(<-ch)の箇所でチャネルの受信操作を行っていますが、チャネルの送信操作を定義していないのでゴールーチンが稼働し続けており、稼働中のゴールーチン数: 2が出力されます(本来であればメインゴールーチンしか動いていないはずなので1が出力されるのが正常)。ゴールーチンリークはプログラムのリソース(特にメモリとCPU時間)を浪費し、パフォーマンス低下に繋がるだけでなく、アプリケーションのクラッシュを引き起こす可能性もあるので適切に対処する必要があります。ゴールーチンリークが発生していないか検知してくれる外部パッケージもあるので、このようなツールを活用して対策をとるのも一つの手です。

https://github.com/uber-go/goleak

実行時間が速くなるとは限らない

  • 並行処理では、ゴールーチンの作成やスケジューリング、同期メカニズムの使用など追加的な処理が発生します。これらの追加的な処理の影響が大きい場合、並行処理による性能向上が相殺される可能性があります
  • 先ほど並行処理の注意点に書いたように、並行処理は適切に使用しないと共有リソースへの同時アクセスやスレッドセーフでない操作により、競合状態が発生する可能性があります。これを防ぐための処理がボトルネックになり、ここでも並行処理の性能が相殺されてしまう可能性が出てきます。
  • 並行処理が有効なのは、タスクが独立しており、分割可能である場合です。しかし、タスクの特性によっては、並行処理に適さない場合もあります。例えば、タスクAの後にタスクB、その後にタスクCのように順を追って処理を行う必要がある場合、並行処理による性能向上は限られる可能性があります。

並行処理はGoを扱う上で非常に強力なツールですが、その挙動と特性を理解し、適切に使用しなければあまり意味がなく、むしろ悪影響を及ぼす可能性もあるので丁寧に理解した上で使っていくべきだと言えるでしょう。

最後に

今回はGo言語における並行処理について簡単にまとめてみました。まだ自分自身あまり並行処理を利用したコードを書いたことがないので、今後色々試してみて並行処理の適切な使用方法を身に付けていきたいです。最後までご覧いただきありがとうございました。

参考

https://zenn.dev/hsaki/books/golang-concurrency
https://free-engineer.life/golang-channel/

Discussion