👻

Go言語で非同期処理の結果を受け取る

2020/10/01に公開

Go言語にはgoroutineというものがあり、複数のタスクを並行(Concurrent)に実行したい場合に役立ちます。

またGo言語では、ライブラリなどのAPIは基本的に同期版を提供し、非同期で処理したい場合は呼び出し側がgoroutineで非同期化するのが一般的です。

そこで、goroutineを使って関数を呼び出し、その結果を得るための実装方法について、自分なりに考えてみたので、ここにまとめておきます。

戻り値がない

戻り値がなく、処理が終わっていればよい場合:

// boolでもよいが空struct
done := make(chan struct{}, 0)

go func() {
	// 何か処理をする

	close(done)
}()

// chanがcloseされるまでブロックする
<-done

値の受け渡しがない場合はシンプルです。

Tips: 空structについて

値の受け渡しが不要な場合のchannelには空structがよく使われます。サイズがゼロだからです。
参考: http://dave.cheney.net/2014/03/25/the-empty-struct

Tips: closeのdefer

起動されたgoroutine側で分岐でreturnする箇所がいくつかある場合など、closeを忘れないようにしたい場合はdeferします:

done := make(chan struct{}, 0)

go func() {
	// 関数を抜けるときにdeferでcloseが呼ばれる
	defer close(done)

	// 何か処理をする
}()

<-done

戻り値が1つ

intを受け取る場合:

intChan := make(chan int, 1)

go func() {
	// 何か処理をする

	intChan <- someValue
}()

value := <-intChan

errorを受け取る場合:

errChan := make(chan error, 1)

go func() {
	// 何か処理をする

	errChan <- err
}()

err := <-errChan

channelを用意して、そこで送受信するだけです。

Tips: channelのバッファ・サイズ

channelのバッファが1になっているのは、起動されたgoroutine側で値を送るときにブロックされないことを確実にするためです。

バッファ0の場合、メイン側が他の処理をしていてchannelの受信をするまでの間、goroutine側のchannelへの送信が待たされることになってしまいます。処理が完了したgoroutineは省リソースの観点からも早く終了すべきなので、channelへの送信がブロックされないようにしています。

戻り値が2つ

intChan := make(chan int, 1)
errChan := make(chan error, 1)

go func() {
	// 何か処理をする

	if err != nil {
		errChan <- err
	} else {
		intChan <- someValue
	}
}()

select {
case value := <- intChan:
	// 処理が成功した場合の処理
case err := <- errChan:
	// 処理が失敗した場合の処理
}

複数のchannelを待つ時はselectを使います。

この例では、intとerrorを返していて、成功した場合と失敗した場合に分かれているので比較的シンプルです。

Note: selectを使わない場合

なおselectではなく、下記のようなコードで結果を待とうとした場合、この例ではデッドロックします。

intChan := make(chan int, 1)
errChan := make(chan error, 1)

go func() {
	// 何か処理をする

	if err != nil {
		errChan <- err
	} else {
		intChan <- someValue
	}
}()

value := <- intChan
err := <- errChan
if err != nil {
	// 処理が失敗した場合の処理
} else {
	// 処理が成功した場合の処理
}

この例でのgoroutine側では条件分岐によってerrChanintChanのどちらかにしか値を送信していないので、両方のchannelから値を受け取ろうとすると、値が無く送信もされないのでずっと待ってしまうからです。

ここではかわりにgoroutine側で、両方のchannelに送信することでデッドロックはしなくなります。

go func() {
	// 何か処理をする

	errChan <- err
	intChan <- someValue
}()

が、この場合はchannelのバッファが1以上でないとデッドロックします。

channelの送受信はブロックが発生する可能性が常にありますので、ブロックやデッドロックを意識して使うようにしています。

戻り値が3つ

intChan := make(chan int, 1)
strChan := make(chan string, 1)
errChan := make(chan error, 1)

go func() {
	// 何か処理をする

	if err != nil {
		errChan <- err
	} else {
		intChan <- someInt
		strChan <- someStr
	}
}()

select {
case value := <- intChan:
	// 成功ルートなのでもうひとつの結果も受け取る
	strChan <- someStr

	// 処理が成功した場合の処理

case err := <- errChan:
	// 処理が失敗した場合の処理
}

そろそろ、読みにくくなってきました。

intChanで結果を受け取ったあとでstrChanからも結果を受け取らなくてはなりません。

もしchannelのバッファをうかりゼロにしてしまい、なおかつintChan, strChanの受け取り順序が逆だと、デッドロックしてしまいます。

結果用の構造体を使う

Goの哲学として「読みやすさ」は非常に重要なので、結果をひとまとめにした構造体を使ってシンプルにしてみます。

構造体:

type Foo struct {
	Bar int
	Baz string
	Err error
}

そして:

fooChan := make(chan Foo, 1)

go func() {
	// 何か処理をする

	var f Foo
	if err != nil {
		f.Err = err
	} else {
		f.Bar = someBar
		f.Baz = someBaz
	}
	fooChan <- f
}()

foo := <- fooChan
if foo.Err != nil {
	// 処理が失敗した場合の処理
} else {
	// 処理が成功した場合の処理
}

扱うchannelがひとつになったので、シンプルになりました。

戻り値が2つ以上の場合、このように結果用の構造体にまとめるとスッキリする場面が多いかと思います。

Tips: 値 or ポインタ

上記の例ではchannelの型は*FooではなくFooになっています。

これはメンバーが少なくサイズも小さいのでコピーのコストが小さいこと、値コピーなのでメモリの確保が発生しないことが理由です。

Fooのサイズは20バイトもしくは24バイト(int:4 or 8, string:8, error:8)です。これくらいのコピーであれば、メモリ確保より遥かに低コストのはずです。

関数内で確保した値のポインタを関数外に渡す場合、値をスタックに置けないのでヒープにメモリ確保されます。メモリ確保とGCはコストが高いので、意識するようにしています。

Note: 戻り値が3つ以上あるのが良くない?

戻り値が3つ以上ある時点で、そもそも関数の設計が良くない可能性があります。

以下の例では、X座標とY座標のふたつのfloatとerrorの3つの戻り値を返すのではなく、Point構造体とerrorの2つの戻り値を返すようにしています。

type Point struct {
	X float64
	Y float64
}
errChan := make(chan error, 1)
pointChan := make(chan Point, 1)

go func() {
	// 何か処理をする
	p, err := someFunc()
	errChan <- err
	pointChan <- p
}()

err := <- errChan
point := <- pointChan
if err != nil {
	// 処理が失敗した場合の処理
} else {
	// 処理が成功した場合の処理
}

戻り値が3つというのは、同期的に関数呼び出しする場合はそこまで汚くならないのですが、goroutineとchannelを介す場合は大変になります。

自分がライブラリや外部に提供する重い関数を実装するときは、非同期の場合にどうなるか意識しておくとよいと思います。


というわけで、いくつかのパターンでのコード例を見てきました。
こうしたらいいよ! などアイディアありましたら、ぜひコメントお願いします。

2014年 Go Advent Calendar お疲れ様でした。
2015年も楽しくGoを書きましょう!

この記事はQiitaの記事をエクスポートしたものです

Discussion