🎉

【Go】このコードの意味が分かれば、ゴルーチンの基本は大丈夫

2023/01/31に公開

はじめに

こんにちは、FarStep です。
「Go 言語を学び始めて ゴルーチン について学習したけど、イマイチ理解できているか不安」、そんな方に向けて本記事を送ります。本記事で紹介するコードが理解できれば、ゴルーチンの基本は大丈夫でしょう。

本記事で扱う内容は下記の通りです。

  • ゴルーチン(goroutine)
  • チャネル(channel)
  • sync.WaitGroup
  • 参照渡し

それでは、始めます 🚀

同期的な処理を行うコード

まずは、ゴルーチンを使わない簡単なコードを書きます。
ある投稿に紐づいたいいね数とコメントを取得する という場合を想定してください。

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	post := fetchPost()
	likes := fetchPostLikes(post)
	comments := fetchPostComments(post)

	fmt.Println("likes: ", likes)
	fmt.Println("comments: ", comments)
}

// 投稿を一件取得する関数
func fetchPost() string {
	time.Sleep(time.Millisecond * 50)

	return "What programming languages do you prefer?"
}

// 投稿に紐づいたいいね数を取得する関数
func fetchPostLikes(post string) int {
	time.Sleep(time.Millisecond * 50)

	return 10
}

// 投稿に紐づいたコメントを全て取得する関数
func fetchPostComments(post string) []string {
	time.Sleep(time.Millisecond * 100)

	return []string{"Golang", "Java", "Rust"}
}

非常にシンプルなコードです。
一つだけ着目していただきたいのは、fetchPostfetchPostLikesfetchPostComments の全ての関数の中で、time.Sleep というスリープ関数を使用している点です。
fetchPost は 50 msec、fetchPostLikes は 50 msec、fetchPostComments は 100 msec かかるとしています。

上記のコードを実行すると、開始から終了までどのくらいの時間を要するでしょうか。
もちろん、上記のコードでは関数が 同期的に 実行されるため、合計で 200 msec かかるはずです。

関数 実行時間(msec) 合計(msec)
fetchPost 50 50
fetchPostLikes 50 100
fetchPostComments 100 200

時間が計測できるように main 関数の中身を編集しましょう。

main.go
func main() {
+	start := time.Now()
	post := fetchPost()
	likes := fetchPostLikes(post)
	comments := fetchPostComments(post)

	fmt.Println("likes: ", likes)
	fmt.Println("comments: ", comments)
+	fmt.Println("took: ", time.Since(start))
}

それでは、コードを実行してみます。

$ go run main.go
likes:  10
comments:  [Golang Java Rust]
took:  200.593276ms

上記のように

  • いいね数
  • コメント
  • 実行時間

がログに表示されれば OK です。想定した動きになっていますね。

非同期的な処理を行うコード

先ほど実行したコードは関数が 同期的に 実行されていたため、全ての関数が実行されるまで約 200 msec かかっていました。実行時間を短くすることは可能でしょうか。

はい、可能です。
なぜなら fetchPostComments 関数は、fetchPostLikes 関数の実行を待つ必要がないからです。fetchPost 関数の実行が完了したところで、fetchPostComments 関数と fetchPostLikes 関数を 非同期的に 実行することで、全ての関数が実行されるまでの時間を短縮することができます。これらを図解すると下記のようになります。

fetchPostComments 関数と fetchPostLikes 関数を非同期的に実行することで、全ての関数の実行が完了するまでに要する時間が 150 msec となり、同期的に実行した場合と比べて 50 msec 短縮することができます。

ゴルーチンの導入

関数を非同期的に実行するために、他のコードに対し並行に実行する関数である ゴルーチン を導入しましょう。先ほどのコードを下記のように編集してください。

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()
	post := fetchPost()

+	// channel の初期化
+	// 2個のバッファを持った channel を作成
+	resChan := make(chan any, 2)

+	go fetchPostLikes(post, resChan)
+	go fetchPostComments(post, resChan)

+	// resChan channel への送信を終了し channel を閉じる
+	close(resChan)

+	// channel が閉じられるまでループする
+	for res := range resChan {
+		fmt.Println("res: ", res)
+	}

	fmt.Println("took: ", time.Since(start))
}

// 投稿を一件取得する関数
func fetchPost() string {
	time.Sleep(time.Millisecond * 50)

	return "What programming languages do you prefer?"
}

// 投稿に紐づいたいいね数を取得する関数
+ func fetchPostLikes(post string, reschan chan any) {
	time.Sleep(time.Millisecond * 50)

+	reschan <- 10
}

// 投稿に紐づいたコメントを全て取得する関数
+ func fetchPostComments(post string, reschan chan any) {
	time.Sleep(time.Millisecond * 100)

+	reschan <- []string{"Golang", "Java", "Rust"}
}

さて、コードが一変しました 😳
一つ一つ解説していきます。

まず、ゴルーチンを導入するにあたって、チャネルが登場しました。

resChan := make(chan any, 2)

なぜチャネルが必要になるかというと、並行実行されるゴルーチン間で値を送受信するため です。今回は、メインゴルーチン(プロセスが開始する際に自動的に生成され起動されるゴルーチン)と fetchPostLikesfetchPostComments 実行するために起動したゴルーチン間で値を送受信します。

画像:What are channels in Golang?

また、今回はいいね数(int 型)とコメント([]string 型)をチャネルに送信するため、送受信するデータ型は any、バッファサイズは 2 としました。

作成したチャネルを fetchPostLikesfetchPostComments に渡して、それぞれの関数で、値をチャネルに送信しています。

reschan <- 10
reschan <- []string{"Golang", "Java", "Rust"}

チャネルへの送信が済んだらチャネルを閉じてあげましょう。

close(resChan)

最後に、閉じられたチャネルに格納された値を for 文を用いて一つずつ表示する処理を行なっています。

for res := range resChan {
	fmt.Println("res: ", res)
}

go というキーワードを、二つの関数 fetchPostLikesfetchPostComments の呼び出しの前に置くことで、メインゴルーチンに対し並行に実行されます。

go fetchPostLikes(post, resChan)
go fetchPostComments(post, resChan)

これで、全ての関数の実行時間は 150 msec になるでしょうか。
早速コードを実行してみましょう。

$ go run main.go
took:  51.219986ms

おかしいですね...🤨
いいね数とコメントが表示されないだけでなく、実行時間が 約 50 msec となっています。
一体何が起きているのでしょうか。

sync パッケージの導入

先ほどのコードはなぜ我々の意図した結果を返してくれなかったのでしょうか。
これは、Go 言語に メインゴルーチンが終了したタイミングで、プログラム全体を終了させる という特性があるからです。関数の手前に go を置くと、たしかにゴルーチンが生成されて Go のランタイムにスケジュールされますが、メインゴルーチンが終了するまでに実行の機会があるかどうかは保証されていません。

今回の場合、メインゴルーチンの中で fetchPost が実行された後、すぐにプログラムが終了してしまうため、Go のランタイムにスケジュールされた fetchPostLikesfetchPostComments は実行されません。図解すると下記のようになります。

fetchPost のみが実行されてプログラムが終了してしまうため、いいね数とコメントが表示されず、実行時間が 50 msec だったんですね。

上記の図によれば、新たにゴルーチンを生成した後に time.Sleep を使ってメインゴルーチンの終了を遅延させることで、fetchPostLikesfetchPostComments を実行することができそうです。

たしかに time.Sleep を使えばプログラムの終了前に、ゴルーチンが起動する確率を上げることはできます。しかし、それを保証するものではありません。ゴルーチンの起動を確実に保証するためには、メインゴルーチンと fetchPostLikes のゴルーチン・fetchPostComments のゴルーチンを 同期 させる必要があります。ここで登場するのが、sync パッケージの sync.WaitGroup です。

sync.WaitGroup とは、複数のゴルーチンの完了を待つためのものです。
内部に数値(初期値は 0)を持っており、メソッドの Wait() を呼ぶと、その数値が 0 になるまで待つことになります
したがって、別のゴルーチンを呼び出す数だけ内部の数値をインクリメントしてあげて、ゴルーチンの処理が終わるたびにデクリメントしてあげれば、Wait() を呼んだメインゴルーチンは全ての並行処理が終わるまで待ってくれるというわけです。

それでは、先ほどのコードを下記のように編集してください。

main.go
package main

import (
	"fmt"
+	"sync"
	"time"
)

func main() {
	start := time.Now()
	post := fetchPost()

	// channel の初期化
	// 2個のバッファを持った channel を作成
	resChan := make(chan any, 2)
	
+	var wg sync.WaitGroup
+	wg.Add(2)

+	go fetchPostLikes(post, resChan, &wg)
+	go fetchPostComments(post, resChan, &wg)

+	wg.Wait()

	// resChan channel への送信を終了し channel を閉じる
	close(resChan)

	// channel が閉じられるまでループする
	for res := range resChan {
		fmt.Println("res: ", res)
	}

	fmt.Println("took: ", time.Since(start))
}

// 投稿を一件取得する関数
func fetchPost() string {
	time.Sleep(time.Millisecond * 50)

	return "What programming languages do you prefer?"
}

// 投稿に紐づいたいいね数を取得する関数
+ func fetchPostLikes(post string, reschan chan any, wg *sync.WaitGroup) {
	time.Sleep(time.Millisecond * 50)

	reschan <- 10
+	wg.Done()
}

// 投稿に紐づいたコメントを全て取得する関数
+ func fetchPostComments(post string, reschan chan any, wg *sync.WaitGroup) {
	time.Sleep(time.Millisecond * 100)

	reschan <- []string{"Golang", "Java", "Rust"}
+	wg.Done()
}

一つ一つ解説していきます。
まずは、下記コードで sync.WaitGroup の生成を行ない、カウンタをインクリメントしています。
今回新たに生成するゴルーチンは二つですので、Add() に渡すのは 2 です。

var wg sync.WaitGroup
wg.Add(2)

そして、下記コードで wg の参照渡しを行なっています。
sync.WaitGroup 型の変数 wg&wg とすると、sync.WaitGroup へのポインタである *sync.WaitGroup 型の値を生み出すことができます。&wgwg へのアドレスです。

go fetchPostLikes(post, resChan, &wg)
go fetchPostComments(post, resChan, &wg)

そして fetchPostLikesfetchPostComments それぞれの関数内で、チャネルへの値の送信が済んだら、wg.Done() として WaitGroup 内部の数値をデクリメントしています。fetchPostComments 内で wg.Done() が実行されると、WaitGroup 内部の数値が 0 となります。

func fetchPostLikes(post string, reschan chan any, wg *sync.WaitGroup) {
	time.Sleep(time.Millisecond * 50)

	reschan <- 10
	wg.Done()
}

func fetchPostComments(post string, reschan chan any, wg *sync.WaitGroup) {
	time.Sleep(time.Millisecond * 100)

	reschan <- []string{"Golang", "Java", "Rust"}
	wg.Done()
}

そして WaitGroup 内部の数値が 0 となるまで待つという命令を下記コードで行なっています。
ゴルーチンの下にこのコードを記述することで、メインゴルーチンは fetchPostLikes のゴルーチン・fetchPostComments のゴルーチンの終了を待つことができます。これを図解すると下記のようになります。

wg.Wait()

wg.Done() が二回実行されて、WaitGroup 内部の数値が 0 になるまでメインゴルーチンが待機することで、fetchPostLikes のゴルーチン・fetchPostComments のゴルーチンが実行されることが 保証 されます。

それでは、最後にコードを実行してみましょう。

$ go run main.go
res:  10
res:  [Golang Java Rust]
took:  150.649422ms

上記のように

  • いいね数
  • コメント
  • 実行時間

がログに表示されれば OK です。全ての関数の実行が完了するまでが 150 msec となりましたね。最初に示した下記のような非同期処理がゴルーチン・チャネル・sync.WaitGroup によって実現されました 🎉

おわりに

いかがだったでしょうか。
最後に示したコードについて理解できたでしょうか。
適切な非同期処理を行うために、ゴルーチン・チャネル・sync.WaitGroup・参照渡しなど、たくさんの技術を使いました。
ゴルーチンの理解の助けになれば幸いです。

参考文献

https://www.oreilly.co.jp/books/9784873118468/

今回使用したコードは、下記 URL に残しておきます。

  1. 全ての関数を同期的に実行するコード
    https://go.dev/play/p/QuFd5CmxFsi

  2. ゴルーチンを使って関数を非同期的に実行するコード(sync.WaitGroup なし)
    https://go.dev/play/p/IElX6RQWAXb

  3. ゴルーチンを使って関数を非同期的に実行するコード(sync.WaitGroup あり)
    https://go.dev/play/p/N3G-FDtzD8s

Discussion