こんにちは。KOUKIです。
Go言語を使って、Webシステム開発をしてます。
Go言語マスターを目指して日々精進していますが、そのために確実にマスターしたい技術がgoroutineです。皆さんは、goroutineを使いこなせているでしょうか?
goroutineは、並行処理を簡単に実装できるGo言語の特筆すべき機能の一つです。
本記事では、goroutineの使い所の一つをサンプルコードとともに紹介します。
めちゃくちゃ時間のかかる検索処理に使おう!
プログラムの処理内で、めちゃくちゃ時間のかかる、かつ並行で走らせても結果に影響が出ない処理の場合、goroutineで並行処理した方が早い場合があります。
例えば、データ検索が該当します。
特定のキーワードを元に、データベースにアクセスし、その結果を取得するような処理です。
逐次処理の場合
最初に逐次処理の場合を確認してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
package main import ( "fmt" "sync" "time" ) var bookDatabase map[int]string func main() { bookDatabase = make(map[int]string, 50) // 本の情報を格納 for i := 0; i < 50; i++ { bookDatabase[i] = fmt.Sprintf("Book %d", i) } start := time.Now() fmt.Println(start) getBookTitleNormal() fmt.Println(time.Now()) end := time.Since(start) fmt.Println(end) } func getBookTitleNormal() []string { result := make([]string, 0, 100) for id := 0; id < 30; id++ { res, ok := find(id) if ok { result = append(result, res) } } return result } func find(id int) (string, bool) { // 時間がかかる処理 time.Sleep(time.Second) searchResult, ok := bookDatabase[id] return searchResult, ok } |
ここでは実際のDBではなく、メモリに保存した変数に対して、検索をかけるプログラムを作成しました。
find関数内で引数に指定されたIDを元に情報を取得しますが、かなり時間がかかる処理であることトレースするために、time.Sleepにて1秒間スリープさせています。
getBookTitleNormalでは、1~30までのIDの情報を検索し、その結果を呼び出し元に返す処理を実装してます。
このプログラムを実行すると、完了までに30秒かかります。
1 2 3 4 |
$ go run main.go 2020-12-08 10:40:47.083254 +0900 JST m=+0.000118097 2020-12-08 10:41:17.187718 +0900 JST m=+30.104370860 30.104462629s |
並行処理の場合
さて、この遅すぎるプログラムを改善するにはどうしたらいいでしょうか?
そうです! goroutineを使って並行で処理すればいいのです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
func main() { ... getBookTitleParallel() // getBookTitleNormal() ... } func getBookTitleParallel() []string { result := make([]string, 0, 100) // テクニック1: 同時接続数を10に設定 semaphore := make(chan struct{}, 10) var wg sync.WaitGroup var mu sync.Mutex for id := 0; id < 30; id++ { wg.Add(1) semaphore <- struct{}{} go func(id int) { // テクニック2: 無名関数内でDoneする defer func() { wg.Done() <-semaphore }() res, ok := find(id) // テクニック3: 排他制御する mu.Lock() defer mu.Unlock() if ok { result = append(result, res) } }(id) } wg.Wait() return result } |
getBookTitleParallelは簡単な並行処理の実装ですが、テクニックが一杯詰まっています。
まずは、このプログラムを実行してみましょう。
1 2 3 4 |
$ go run main.go 2020-12-08 11:06:47.590425 +0900 JST m=+0.000100202 2020-12-08 11:06:50.59717 +0900 JST m=+3.006825743 3.006895168s |
今度は3秒で完了しました。約1/10の短縮です。
ここで使ったテクニックを3つほど紹介します。
テクニック1 – goroutineの最大数を制御する
goroutineを扱う際に留意すべきことの一つが、goroutineを作りすぎてしまうと逆にパフォーマンスが悪くなるという事実です。
このプログラムの規模ではgoroutineを作りすぎてもあまり影響はありませんが、goroutineの増加に伴って、パフォーマンスが悪くなるのを現場でみました。goroutineを作るのにもある程度コストがかかるんですかね?
そのため、goroutineの最大数を制御するsemapthore Channelを作成しています(名前は任意です)。
1 2 |
// テクニック1: 同時接続数を10に設定 semaphore := make(chan struct{}, 10) |
上記の設定によりチャネルには最大10個の値しか格納することができません。for文の中で書き込み(
を行なっていますが、容量に空きがない場合は、プログラムのどこかで読み込み処理が行われるまで、ここでブロックします。これにより処理が一時停止するので、goroutineが最大10個までしか作られなくなります。semaphore <- struct{}{})
テクニック2 – 無名関数でクリーンアップ
WaitGroupやChannelの読み込みを確実に実行したい場合は、無名関数にdeferキーワードをつけて実装するとプログラムが異常終了しても確実に実行されるので便利です。
1 2 3 4 5 |
// テクニック2: 無名関数内でDoneする defer func() { wg.Done() <-semaphore }() |
特にsemaphore Channelはここで確実にブロック解除(読み込み)しておきたいので、このように記述しておくといいと思います。※解除できなかった場合は、影響にブロックされ続けます
テクニック3 – 排他制御する
排他制御をしていない場合、メモリへの同時書き込みが発生する可能性があるのが、並行処理プログラミングの怖いところです。
しかし、Go言語には、Mutexという排他制御するための便利な機能がデフォルトで備わっているので、これを活用しましょう。
1 2 3 4 5 6 |
// テクニック3: 排他制御する mu.Lock() defer mu.Unlock() if ok { result = append(result, res) } |
Lockにて排他制御をかけ、UnLockで解除できます。deferを使っているので、関数の処理が完了した時に排他制御を解除してくれます。
おわりに
いかがだったでしょうか。
並行処理プログラミングは難しいですが、Go言語だとgoroutineがあるため、参入障壁がめちゃくちゃ低いです。
この機会にぜひ学習されることをオススメします!
最近のコメント