My External Storage

Mar 15, 2019 - 4 minute read - Comments - go

[Go] selectのcase文中でch <- <- chやch <- f()をしない方が良い #golangjp #横浜go読書会

簡潔にまとめられなかったので式表現をそのままタイトルに書いてしまったが、分かっていないとエンバグしそうな挙動を見つけたのでメモしておく。

TL;DR

  • チャネルを使ったselectのcase文中で、関数を呼び出した結果でチャネルへの送信をしないほうがよい
  • チャネルを使ったselectのcase文中で、別のチャネルから受信した結果でチャネルへの送信をしないほうがよい

文章で書くとわかりにくいのだが、コードで書くと以下のような処理を書くのは避けたほうがよい、というのが主題だ。

 select {
 case ch1 <- f(): // チャネルへ関数を呼び出した結果を送信する
 case ch1 <- <- ch2: // チャネルへ別のチャネルからの受信結果を送信する
 }

なお、検証環境は以下。

チャネルを使ったselectのcase文中で、関数を呼び出した結果でチャネルへの送信をしないほうがよい

まずselect文のcaseで関数呼び出しをしているときを検証した。
以下の処理はdoneチャネルに通知が入るまで無限ループが行われるコードだ。
ch1ch2の準備ができているときはf関数の戻り値を送信する。このプログラムを実行したとき、called fという文字列を何回出力するだろうか?(f関数は何回実行されるだろうか?)

package main

import (
	"fmt"
)

func f() int {
	fmt.Println("called f")
	return 1
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	done := make(chan struct{})

	go func() {
		fmt.Printf("f return = %d\n", <-ch1)
		done <- struct{}{}
	}()

	for {
		select {
		case <-done:
			fmt.Println("done")
			return
		case ch1 <- f():
		case ch2 <- f():
		}
	}
}

私はfmt.Printf("f return = %d\n", <-ch1)の時にch1から値を受け取るときだけf関数が呼び出されると思ったので1回と予想していた。
だが、答えは4回だ。

called f
called f
f return = 1
called f
called f
done

どうやら**select文に突入した時点でチャネルへの送信文(channel <- XXXX)の右辺(XXXX)が実行されなかったcaseも含めて確定的に行われてしまう**らしい。
この挙動はselectのcase文でチャネルからの受信式(<-channel)を使っても同様に発生する。

チャネルを使ったselectのcase文中で、別のチャネルから受信した結果でチャネルへの送信をしないほうがよい

以下のコードは別のチャネル(fchX)から送信されたintを受信するチャネル(chX)を複数select文で並べて待機するコードだ。

package main

import (
	"fmt"
)

func main() {
	sample3()
}

func sample3() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	done := make(chan struct{})

  // 1ずつインクリメントされたintを送信するチャネルを生成する関数
	f := func() <-chan int {
		ch := make(chan int)
		go func() {
			var i int
			for {
				ch <- i
				i++
			}
		}()
		return ch
	}

	go func() {
		fmt.Printf("fch1 return = %d\n", <-ch1)
		fmt.Printf("fch2 return = %d\n", <-ch2)
		fmt.Printf("fch1 return = %d\n", <-ch1)
		done <- struct{}{}
	}()

	fch1 := f()
	fch2 := f()
	for {
		select {
		case ch1 <- <-fch1:
		case ch2 <- <-fch2:
		case <-done:
			fmt.Println("done")
			return
		}
	}
}

この実行結果は以下となる。

fch1 return = 0
fch2 return = 1
fch1 return = 2
done

標準出力している部分のコードを確認する。

fmt.Printf("fch1 return = %d\n", <-ch1)
fmt.Printf("fch2 return = %d\n", <-ch2)
fmt.Printf("fch1 return = %d\n", <-ch1)

最初のfmt.Printf("fch1 return = %d\n", <-ch1)の結果は0だ。ch1への入力のfch1f()で生成したチャネル)は0からインクメントされた整数を返すチャネルなので、期待通りだ。 だが、fmt.Printf("fch2 return = %d\n", <-ch2)ch2(fch2)から初めて値を取り出すので0を期待したいところだが、1が出力される。 また、二回目のfmt.Printf("fch1 return = %d\n", <-ch1)ではch1(fch1)に対する二度目の取得なので、1が出てくるのを期待するのだが、2が出てくる。

selectで実行されたなかった時でもcase中の右辺が評価されてしまうので、 fch2からch2へ渡されたはずの0や、fch1からch1に渡されたはずの1はどこか虚空に消えてしまったようだ…

最後に

selectcase中に関数呼び出しやチャネルからの受信をしていると思わぬ挙動をしてしまうことをまとめた。
今回の内容は第25回横浜Go読書会に参加中に話題になったことをまとめた内容である。 具体的な仕様や実装までは追えなかったので実動作についてのみのまとめになってしまった。
本当ならば実際にGoの実装のどの部分でこのような挙動を実現しているかなどちゃんと調べていきたい。

関連記事