見出し画像

Concurrency in Go (Basics) | Go日記: 『Learning Go』『Go言語による並行処理』

Chapter 10. Concurrency in Go をメモを取りながら読む。

はじめに

The key is understanding that concurrency is not parallelism. Concurrency is a tool to better structure the problem you are trying to solve. Whether or not concurrent code runs in parallel (at the same time) depends on the hardware and if the algorithm allows it.

重要なのは、並行性は並列性ではないことを理解することです。 並行性は、解決しようとしている問題をより適切に構造化するためのツールです。 並行処理が(同時に)並行して実行されるかどうかは、ハードウェアとアルゴリズムで許可されているかどうかによって異なります。

Bodner, Jon. Learning Go (p.302). O'Reilly Media. Kindle 版.
Use concurrency when you want to combine data from multiple operations that can operate independently.

独立して動作できる複数の操作からのデータを組み合わせる場合は、同時実行を使用しましょう。

Bodner, Jon. Learning Go (p.302). O'Reilly Media. Kindle 版.
Another important thing to note is that concurrency isn’t worth using if the process that’s running concurrently doesn’t take a lot of time.

注意すべきもう1つの重要な点は、同時に実行されているプロセスにそれほど時間がかからない場合、同時実行は使用する価値がないということです。

Bodner, Jon. Learning Go (p.302). O'Reilly Media. Kindle 版.
Goroutines are lightweight processes managed by the Go runtime. (...) This might seem like extra work, since the underlying operating system already includes a scheduler that manages threads and processes, but it has several benefits:

Goroutine は、Go のランタイムによって管理される軽量プロセスです。 (...)基盤となるオペレーティングシステムにはスレッドとプロセスを管理するスケジューラがすでに含まれているため、これは余分な作業のように見えるかもしれませんが、いくつかの利点があります。

Bodner, Jon. Learning Go (pp.303-304). O'Reilly Media. Kindle 版.

利点とは:

・OSがスレッドを作るよりも、Goroutine を作るほうが速い。

・Goroutine のイニシャル・スタックサイズはスレッドのそれよりも小さく、必要に応じて伸びる。よりメモリを効率的に使用する。

・Goroutine 間のスイッチングは、スレッド間のそれよりも速い。

・Go ランタイムのシュケジューラーは Go プロセスの一部なので Goroutine の実行に効果的な最適化がしやすい。

Goroutine はクロージャ

このあと文法的なところ ーーー 関数呼び出しの前に go キーワードを置く、とか ーーー の話に入っていく。基本的なことは省く。過去の Go 日記を貼っておく。

go キーワードで関数呼び出しを行う際の注意としては、
 ・呼び出しをクロージャとして実行するために必要な初期状態を引数に「閉じ込める」。
 ・関数呼び出しの return 値は無視される。

1つ目の注意点は重要。『Go言語による並行処理』からの例を載せておく。

package main
import (
	"fmt"
	"sync"
)
func main() {
	var wg sync.WaitGroup
	for _, salitation := range []string{"hello", "greetings", "good day"} {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(salitation) // loop variable salitation captured by func literal
		}()
	}
	wg.Wait()
}
/* Outputs:
good day
good day
good day
*/

"hello", "greetings", "good day" が順不同に印字されるわけじゃなく "good day" が3回印字されている!

なんでこうなるかと言うと、この書き方だと for ループが終了した時点の salutation 変数の値、すなわち "good day" を Goroutine の中に閉じ込めてしまうから。

ゴルーチンは未来の任意のタイミングにスケジュールされる。そこはほんとに逐次実行とは考えを切り離してコードを見る必要がある。上の書き方だと、各ゴルーチンの中で salutation のどの値が閉じ込められるかは不確定である。一般的なマシンではゴルーチンが開始される前に for ループが終わってしまうので、ループの最後の salutation の値にセットされる "good day" が閉じ込められる。

ではどのように書き直せばよいかというと、Goroutine それぞれがループのたびに salutation に代入される値("hello", "greetings", "good day")を閉じ込めるよう、関数呼び出しの引数に与えてやれば良い。

package main
import (
	"fmt"
	"sync"
)
func main() {
	var wg sync.WaitGroup
	for _, salitation := range []string{"hello", "greetings", "good day"} {
		wg.Add(1)
		go func(salitation string) {
			defer wg.Done()
			fmt.Println(salitation)
		}(salitation)
	}
	wg.Wait()
}
/* Outputs:
good day
greetings
hello
*/

『Go言語による並行処理』 単行本(ソフトカバー) – 2018/10/26
Katherine Cox-Buday (著), 山口 能迪 (翻訳)
P.41~42 からサンプルコードを引用

Channels

『Learning Go』を読んで改めてインプットした内容を抜書き。

・チャネルはマップやスライスと同様に参照型なので、
 中身はポインターで
 ゼロ値は nil である。

・チャネルに書き込まれた値の読み出しは一度限り。よって、
 複数の Goroutine でひとつのチャネルを読みに行ったら
 値はその中のどれかに読み出されて消費される。

・関数の引数宣言で
 ch <-chan int のように書けば受信オンリー
 ch chan<- int のように書けば送信オンリー

・デフォでチャネルは複数の値をバッファーしない。ひとつの値が書き込まれたら、その値が読み出されるまで書き込みはブロックされる。

・バッファー付きチャネルは次のように宣言する。
 ch := make(chan int, 10)
 バッファーのリミット(例は 10 個のバッファー持ち)まではブロック無しで読み書きされる。バッファーがいっぱいになったら書き込みはブロックされる(読まれることで空くのを待つ)。

・チャネルにも len と cap が使える。

・特に必要でなければバッファーなしチャネルを使うこと。

Closed channels with for-range

close(ch) したチャネルの読み出しには for-range ループが使える(ショートカット)。通常の  for-range ループと違う点は、変数宣言がシングルになるところ。

for v := range ch {
        /* do someting with v */
}

close したチャネルになにか送信しようとすると panic になる。しかし、受信はそうならない。受信で読み出すものがなくなればチャネルが保持する型のゼロ値が返される。これはバッファーなしのチャネルと、バッファーが尽きたバッファーありチャネルで共通している。

ここで疑問。チャネルが close したために読み出される nil と、チャネルに送信した情報としての nil はどう区別する?
Go はここでも一貫したイディオムを提供していて、map のときと同じように comma ok イディオムを使う。

v, ok := <-ch

ok が true なら ch は open、ok が false なら ch は close 済み。

Select

(...) If you can perform two concurrent operations, which one do you do first? You can’t favor one operation over others, or you’ll never process some cases. This is called starvation. The select keyword allows a goroutine to read from or write to one of a set of multiple channels.

(...) 2つの同時操作を実行できる場合、どちらを最初に実行しますか? ある操作を他の操作よりも優先することはできません。そうしないと、一部のケースを処理できなくなります。 これは starvation (飢餓) と呼ばれます。 selectキーワードを使用すると、ゴルーチンは複数のチャネルのセットの1つから読み取りまたは書き込みを行うことができます。

(...) What happens if multiple cases have channels that can be read or written? The select algorithm is simple: it picks randomly from any of its cases that can go forward; order is unimportant. This is very different from a switch statement, which always chooses the first case that resolves to true. It also cleanly resolves the starvation problem, as no case is favored over another and all are checked at the same time.

(...) 複数のケースに読み取りまたは書き込みが可能なチャネルがある場合はどうなりますか? 選択アルゴリズムは単純です。先に進むことができるケースからランダムに選択します。 順序は重要ではありません。 これは、trueに解決される最初のケースを常に選択するswitchステートメントとは大きく異なります。 また、他のケースよりも優先されるケースはなく、すべてが同時にチェックされるため、starvation の問題もきれいに解決されます。

Another advantage of select choosing at random is that it prevents one of the most common causes of deadlocks: acquiring locks in an inconsistent order.

ランダムに選択することのもう1つの利点は、デッドロックの最も一般的な原因の1つである、一貫性のない順序でのロックの取得を防ぐことです。

Bodner, Jon. Learning Go (p.311). O'Reilly Media. Kindle 版.

実例。

まずデッドロックを引き起こすコード例が紹介されている。

package main
import (
	"fmt"
)
func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go func() {
		v := 1
		ch1 <- v
		v2 := <-ch2
		fmt.Println(v, v2)
	}()
	v := 2
	ch2 <- v
	v2 := <-ch1
	fmt.Println(v, v2)
}
/*
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
	/tmp/sandbox615068485/prog.go:17 +0xbd
goroutine 18 [chan send]:                      // v2 := <-ch1
main.main.func1(0xc000094060, 0xc0000940c0)
	/tmp/sandbox615068485/prog.go:12 +0x45 // ch1 <- v
created by main.main
	/tmp/sandbox615068485/prog.go:10 +0x9c // go func()
*/
Remember that our main is running on a goroutine that is launched at startup by the Go runtime. The goroutine that we launch cannot proceed until ch1 is read, and the main goroutine cannot proceed until ch2 is read.

main 関数は、起動時に Go ランタイムによって起動されるゴルーチンで実行されていることを忘れないでください。 (main の中から)起動するゴルーチンは ch1 が読み取られるまで続行できず、main ゴルーチンは ch2 が読み取られるまで続行できません。

Bodner, Jon. Learning Go (p.312). O'Reilly Media. Kindle 版.

これを select を使って解決してみる。

package main
import (
	"fmt"
)
func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go func() {
		v := 1
		ch1 <- v
		v2 := <-ch2
		fmt.Println(v, v2)
	}()
	v := 2
	var v2 int
	select {
	case ch2 <- v:
	case v2 = <-ch1:
	}
	fmt.Println(v, v2)
}
/*
2 1
*/
Because a select checks if any of its cases can proceed, the deadlock is avoided. The goroutine that we launched wrote the value 1 into ch1, so the read from ch1 into v2 in the main goroutine is able to succeed.

select は、そのケースのいずれかが続行できるかどうかをチェックするため、デッドロックが回避されます。 起動したゴルーチンは値 1 を ch1 に書き込んだため、main ゴルーチンのch1からv2への読み取りは成功します。

Bodner, Jon. Learning Go (p.313). O'Reilly Media. Kindle 版.

『Learning Go』はアンチパターンの解説やよく使われるデザインパターンの解説に進んでいく。こちらはいずれ学習 note を作成したら投稿する。

SN

この記事が気に入ったらサポートをしてみませんか?