Go での非同期処理がいまいちわかっていなかったので、調べてみた。 Go の並列処理の基本は、「メモリをシェアして、コミュニケーションをとるのではなく、コミュニケーションをとって、メモリをシェアする」と言うコンセプトらしい。一瞬なんのことかわからないが、試してみよう。
1. 非同期処理を、複数は知らせて、全部終わったら、何らかの処理をする。
C# や TypeScript だと、 async/await が便利すぎていい感じだが、Go には go routine と、 channel が存在する。かなりかっこいい感じで並列処理がかける。 REST-API からデータを取ってきたかったり、IO関係の処理だと、並列処理を行いたいだろう。次のは、2つの web サーバーから並列処理で、データを取得して、両方が読み終わったら、内容を表示するサンプルだ。
package main
import (
"fmt"
"io/ioutil"
"net/http"
)
func GetContent(url string, c chan string) {
resp, err := http.Get(url)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
s := string(body[:])
if err != nil {
fmt.Println(err)
return
}
c <- s
}
func main() {
fmt.Println("Hello, playground")
c := make(chan string)
go GetContent("https://www.bing.com/", c)
go GetContent("https://www.yahoo.co.jp", c)
result01, result02 := <-c, <-c
fmt.Println(result01)
fmt.Println("********-----------")
fmt.Println(result02)
}
余談だが、:
は、スライス 参考: Go Slices: usage and internalsに使われる記号で、 body[:]
は、スライスの前後が省略されているので、byte[] body
の body のストレージ自体を指し示している。(それを string に変換している)
ポイントを解説していこう。
1.1. Channel を作成する
非同期で実行されるメソッドは先頭に、go
をつけて関数をコールする。これをgo routine
と呼ぶ。
go GetContent("https://www.bing.com/", c)
ポイントは、下記の通り、channel と呼ばれるもので、メインの処理と、非同期に実行する処理で、シェアされるチャネルだ。メイン処理と、非同期処理の方で何か共有したかったら、チャネルを介して、データをやり取りする。この辺りが、「メモリをシェアして、コミュニケーションをとるのではなく、コミュニケーションをとって、メモリをシェアする」と書いてある所以だろう。下記のは、バッファーのないチャネルを作成しているが、バッファー付きも存在する。その違いはあとで解説する。
c := make(chan string)
1.2. 非同期処理側で、チャネルに書き込む
func GetContent(url string, c chan string) {
resp, err := http.Get(url)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
s := string(body[:])
if err != nil {
fmt.Println(err)
return
}
c <- s
}
前半は、引数でもらった、URL から、その Web ページを取ってきて、それをs
に代入しているだけだが、その戻り値を、channel に格納している。 c <- s
の箇所である。こうすると、結果が、チャネルに格納されて、メイン側から、その値にアクセスできるようになる。
go GetContent("https://www.bing.com/", c)
go GetContent("https://www.yahoo.co.jp", c)
result01, result02 := <-c, <-c
メイン側では、2つの go routine
を実行しているが、それぞれ、処理が始まったら、次の処理を待ち合わせない。じゃあ、await
に相当するのはどうするかというと、3行目でやっている内容だ。
result01, result03 := <-c, <-c
<-c
を実行すると、チャネルから1つの値を取ってくる。つまり、この行を終えようとしたら、チャネルから、2つの値を取ってこないといけない。だから、ここで待ち合わせがかかるのだ。もし、go GetContent("https://www.yahoo.co.jp", c)
の間にロジックがあったとしても、この行までは、待ち合わせは行わない。
go では、リソースをロックする方法ではなく、Mutex と言う方法で、データを共有する。Mutexの方法は、複数の人がいるときに、おもちゃのアヒルを持っている人だけが、話をできると言うルールにしておく。アヒルを持っている人でないと話はできない。参加者に次々アヒルを渡していけば、一人だけが話をするという状態になる。
人をスレッドに置き換えると、アヒルが、Mutex だ。つまり、Channel そのものである。(たぶんw) What is a mutex?
このようにすると、実際に、2つの非同期処理が同時に実行されて、上記の箇所で待ち合わせて、プログラムが終了する。まさに、async, await っぽいことができている。ただ、考え方は違うので慣れが必要だ。
2. バッファー付きチャネルと、バッファーなしチャネルを理解する
ちなみに、チャネルには、バッファー付きと、バッファーなしが存在する。この違いをプログラムを作って理解してみよう。
package main
import (
"fmt"
"time"
)
func GetInt(a int, c chan int) {
for i := 1; i < 5; i++ {
c <- a + i
fmt.Println("GetInt:", i)
}
close(c)
}
func main() {
c := make(chan int)
go GetInt(0, c)
for d := range c {
time.Sleep(time.Second * 2)
fmt.Println("Main: ", d)
}
}
2.1. バッッファーなし channel
このプログラムでは、go routine
の方で、チャネルに、書き込み、メインの方では、2秒ほど待ってから、チャネルを読み込んでいる。これを実行するとどうなるだろう。チャネルにデータを入れたくても、バッファがないので、1つか入れれない。だから、メイン側で、読み込みが行われないと、go routine
側で、書き込みを行えない。だから、 go routine
側で1つデータが入ったら、メインで1つ読み込む、、、といった感じになる。
$ go run spike3.go
GetInt: 1
Main: 1
GetInt: 2
Main: 2
GetInt: 3
Main: 3
GetInt: 4
Main: 4
2.2. バッファ付き channel
じゃあ、バッファ付きだとどうなるだろう? バッファが2つになる。
c := make(chan int, 1)
実行すると、バッファが2つあるので、読み込みは、2件同時にできている。でも、読み込みが行われないと、バッファがあかないので、最初以降は、1件読み込まれたら、1件書き込みができると言うような形で動いている。
$ go run spike3.go
GetInt: 1
GetInt: 2
Main: 1
GetInt: 3
Main: 2
GetInt: 4
Main: 3
Main: 4
もっと豪勢にバッファを持ってみる。
c := make(chan int, 3)
予想通り、一気にバッファに ぶち込んで、一気に読み取ると言うことができている。
$ go run spike3.go
GetInt: 1
GetInt: 2
GetInt: 3
GetInt: 4
Main: 1
Main: 2
Main: 3
Main: 4
3. コンカレントと、パラレルの違い
並列処理の英語訳は何かわからないが、Concurrent と、 Parallel は意味が違う。
Stack Overflow のこの図が最高にわかりやすい。
What is the difference between concurrent programming and parallel programming?
go routine
は、コンカレントプログラミングを実施するものなので、パラレル実行のためには、一工夫必要だ。
さっきのプログラムを改造してみる。
すごく単純だが、CPU の数を取ってきて、その数だけ、Channelのバッファを作ればいい。
package main
import (
"fmt"
"runtime"
)
func GetInt(a int, c chan int) {
c <- a
fmt.Println("GetInt", a)
}
func main() {
numCPU := runtime.GOMAXPROCS(0)
fmt.Println("NUMCPU:", numCPU)
c := make(chan int, numCPU)
for i := 0; i < numCPU-1; i++ {
go GetInt(i*10, c)
}
result03, result02, result01 := <-c, <-c, <-c
fmt.Println("Main:", result01)
fmt.Println("Main:", result02)
fmt.Println("Main:", result03)
}
実行結果
$ go run spike3.go
NUMCPU: 4
GetInt 20
GetInt 10
GetInt 0
Main: 10
Main: 0
Main: 20
多少順番は入れ替わっているが、予想通りの結果になっている。オリジナルのコードでは、CPUの数だけ回していたが、メインが動いているCPUがあるはずなので、3つにしてみた。
終わりに
go routine
の強力さはわかってきたが、まだまだ、go routine
で panic
発生したらどうやってデバッグするんだろう?とかわかっていないこともあるので、次回以降掘り下げたい。