包丁一本さらしに巻いて

Golangへの道 #2

前回から大分時間が経ってしまったけど書く。もはやイマサラ感ありまくりだけど、今回は以前勉強したGolangの並行(concurrency)と並列(parallelism)について。

TL;DR

  • 並行と並列は別物だよ
  • Golangは標準機能で処理に並行性を持たせるように組み、且つ並列で実行しやすいよ

並行と並列は別物

とにかく以下の資料が最強に分かりやすい。

自分で咀嚼できているか確認する為、以下簡単に説明をしてみる。間違ってるかもしれないのでコメント歓迎。

「並行性がある」というのはどういう状態かというと、各々独立して実行できる処理群が互いにコミュニケーションしながら処理を実行可能な状態、と言える。以下資料の中で一番しっくりきた言葉。

Concurrency is a way to structure a program by breaking it into pieces that can be executed independently.

並行性とは、各処理を分割し独立に実行可能なプログラムの構造の事、とでも訳せば良いのか。大切なのは「プログラムの構造」の話であって、実際の処理が同時に行えているかどうか(並列性)とは異なるという事。以下のGopherが本を運んで炉で燃やすという処理の例がとてもわかりやすかった。

1匹(?)のGopherがカートを使って本を炉に運んで燃やす、という処理にどうやったら「並行性」を持たせれるか、という観点で例が進んでいく。この例を辿って行くと確かに並行性というのは「問題をどうやって解くか」という事に近く、なんとなく同時に実行できるって事なんだろ的な雑い感じからは遠ざかる。そして上手い問題の解き方を構築することができれば(例えば複数のGopher達で役割分担すれば)、そしてそれらを独立してで実行できる計算リソースがあれば、本をカートで運んで炉で燃やすという処理をより早く行う事ができる。

今回のGopherが本を処分していく例をWebのシステムに当てはめてみると、GopherがCPUで本の山がウェブコンテンツ、カートがマーシャリングやレンダリングもしくはネットワーキングで炉がプロキシやらブラウザやら他のコンシューマー、といった形。

並行性を持たせるようにプログラムを構築することができたのならば、それぞれの処理を独立して並列に走らす事で目指す結果を素早く達成できる、って事らしい。以下の一文がとても良くサマリされていて、「並行と並列ってどっちがどっちだっけな」と迷った時に思い出す事にしている。

Concurrency is about structure, parallelism is about execution.

意訳すると、「並行性とは構造/デザインであり、並列性とは実行の事である」的な感じか。英語だとconcurrencyとparallelismとなり、明確に単語が異なるので分かりやすいけど、日本語にすると平行と並列ってなるので覚えにくい。

Golangで並行と並列を実現する仕組み

上は結構抽象的な話になってしまっているので、ここから具体的にコードを書きながらGolangのどのような機能が並行と並列を実現しやすくしているのか説明してみる。一番最初に並行性を持つということはどういうことか、という事を書いたけど以下再掲。

「並行性がある」というのはどういう状態かというと、各々独立して実行できる処理群が互いにコミュニケーションしながら処理を実行可能な状態、と言える。

つまり、これらを行うためには少なくとも以下の機能が必要になる。

  • 独立した処理を複数実行できる
  • 独立した処理群が互いに連携できる

それぞれ、Golangが持っているgoroutinechannelselectという機能で実現できるようになっているので、以下それぞれ詳細について書く。

独立して処理を動かす仕組み(Goroutine)

Concurrency is not Parallelism p.31

A goroutine is a function running independently in the same address space as other goroutines

Goroutineはスレッドとは違う。リソースもそんなに食わずに1スレッドの中に数百から数千のGoroutineを走らせる事もできるとの事。詳細な内部構造についてはあまり理解していないのだけど、一旦コードを書きながら話を進める。

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func helloAPI(name string) {
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	greeting := "Hello, " + name + "!"
	fmt.Println(greeting)
}

func main() {
	rand.Seed(time.Now().UnixNano())
	users := []string{"8maki", "moqada", "ide"}
	for _, u := range users {
		helloAPI(u)
	}
	fmt.Println("done!")
}

上のコードではhelloAPIという英語で軽く挨拶をしてくれるAPIのモックが定義されており、これをmain関数内で上から順番に実行する。helloAPIの中にはレスポンスタイムのブレを表現するためにランダムにsleepしてから文字列を返すようにしてある。実行結果は以下。確かに順番に挨拶してくれている。

Hello, 8maki!
Hello, moqada!
Hello, ide!
done!

APIのコールは互いに独立なので並列(parallel)に実行したいのでここでgoroutineを使う。helloAPIを呼び出す前にgoを付けるだけ。

func main() {
	rand.Seed(time.Now().UnixNano())
	users := []string{"8maki", "moqada", "ide"}
	for _, u := range users {
		go helloAPI(u)
	}
	fmt.Println("done!")
}

以下実行結果。

done!

なんかおかしいのもその通り、goを付けることでhelloAPIはメインスレッドとは切り離されて実行されているので、helloAPIが結果を表示する前にメインスレッドが完了してしまっている。強引にhelloAPIの結果を標準出力に出そうとするのならばmainの中でsleepしてやるしかない。

func main() {
	rand.Seed(time.Now().UnixNano())
	users := []string{"8maki", "moqada", "ide"}
	for _, u := range users {
		go helloAPI(u)
	}
	time.Sleep(1 * time.Second)
	fmt.Println("done!")
}

実行結果は以下の通りでhelloAPIの結果を出力できた。

Hello, 8maki!
Hello, moqada!
Hello, ide!
done!

このようにgoを関数の前に付けることで処理を独立に実行できる。goは関数だけじゃなくてコードのブロックや無名関数にも適用できる便利な道具だ。ただし、上で見たように「処理を独立して実行する」仕組みであるだけであり、雑に例えるならばシェルにアンパサンド付けて実行するのと同じような感じでしかない。

見ての通り上のプログラムはあまりにもナイーブだ。helloAPIが過負荷でレスポンス返すのに時間がかかっていたらどうするのか、挨拶する対象のユーザが増えたらどうするのか、とにかく処理に1秒以上かかったら正しく動作しない。かと言って30秒待つのはあまりに非効率だし、そうなるとどこかで足切りの基準を設けてそれ以上時間がかかったらエラー通知して処理を進める、とかになるがまぁわかりにくい。

そんなわけで、プログラムに並行性を持たせ独立な処理を組み合わせて処理を進めるには、独立した処理がお互いに(今回の場合はmainhelloAPI)連携できる仕組みが必要になってくる。それが次に紹介するchannelという仕組み。

独立して動く処理が互いに連携する仕組み(Channel)

channelはGolangの中で「独立して動く処理が互いに連携する仕組み」という立ち位置だと認識している。どうやって独立した処理を連携させるかというと、イメージとしては処理同士がデータを受け渡しする経路を作ってやる、という感じ。ちょっと長いけどとりあえずコード。

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func helloAPI(name string, c chan string) {
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	greeting := "Hello, " + name + "!"
	c <- greeting
}

func main() {
	c := make(chan string)
	users := []string{"8maki", "moqada", "ide"}
	for _, u := range users {
		go helloAPI(u, c)
	}
	for i := 0; i < len(users); i++ {
		fmt.Printf("You say: %q\n", <-c)
	}
	fmt.Println("done!")
}

実行結果。

You say: "Hello, 8maki!"
You say: "Hello, moqada!"
You say: "Hello, achiku!"
You say: "Hello, ide!"
done!

最初にmainの中でchannelを作ってやる。Golangが提供するchannelはファーストクラスオブジェクトで型を持っている。メインはこのチャネルを使って独立実行するhelloAPIとデータのやり取りをする。

	c := make(chan string)

肝は以下の部分。

	for i := 0; i < len(users); i++ {
		fmt.Printf("You say: %q\n", <-c)
	}

ここでメインは<-cを使ってgoで独立実行した処理がchannelを経由してデータを渡してくれるのを待っている。helloAPIが処理を完了して文字列を自身が引数で受け取ったチャネルに渡した瞬間に上記コードのfmt.Printlnが実行される。今回のケースではユーザの数分helloAPIを実行しているのでユーザの数分待ち合わせる事になる。チャネルは「データの受け渡し」と「処理の待ち合わせ」の両方の役割を担っている感じ。以下最強の資料から抜粋。

When the main function executes <–c, it will wait for a value to be sent. Similarly, when the boring function executes c <– value, it waits for a receiver to be ready. A sender and receiver must both be ready to play their part in the communication. Otherwise we wait until they are. Thus channels both communicate and synchronize.

Go Concurrency Patterns p.21

このchannelの仕組みを導入することで、メインスレッドとgoで独立実行させた処理が「処理の待ち合わせ」と「データの受け渡し」ができるようになった。goroutinechannelの仕組みを使うことで「独立した処理実行」と「独立した処理同士の連携」ができるようになっており、それらが言語にデフォルトで組み込まれてる且つリソース効率が良いっていう事実こそ、Golangが並行と並列を強く意識した言語だと言われる所以なのかなと思う。

最強の資料にはchannelのよくある使い方パターンも実際のコード含めて解説されているので本当に最強としか言いようがない。

Go Concurrency Patterns p.24

独立して動く複数の処理同士の連携をコントロールする仕組み(Select)

Golangには更に便利な仕組みがある。selectswitch文みたいなものだけど、値が同じだったらこの処理を実行、というのではなく、チャネルからデータが取得できたらこの処理を実行、という感じのもの。以下コード。

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func helloAPI(name string, c chan string) {
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	greeting := "Hello, " + name + "!"
	c <- greeting
}

func profileAPI(name string, c chan string) {
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	profile := name + "'s profile"
	c <- profile
}

func main() {
	rand.Seed(time.Now().UnixNano())
	helloChan := make(chan string)
	profChan := make(chan string)
	users := []string{"8maki", "moqada", "ide", "achiku"}

	for _, u := range users {
		go helloAPI(u, helloChan)
		go profileAPI(u, profChan)
	}

	numAPI := 2
	for i := 0; i < len(users)*numAPI; i++ {
		select {
		case hello := <-helloChan:
			fmt.Println(hello)
		case profile := <-profChan:
			fmt.Println(profile)
		}
	}
	fmt.Println("done!")
}

実行結果。

Hello, moqada!
Hello, 8maki!
ide's profile
Hello, achiku!
moqada's profile
achiku's profile
Hello, ide!
8maki's profile
done!

肝は以下の部分。forループを何回回すかの部分が大分ナイーブだけど一旦説明の為に勘弁してほしい。

	numAPI := 2
	for i := 0; i < len(users)*numAPI; i++ {
		select {
		case hello := <-helloChan:
			fmt.Println(hello)
		case profile := <-profChan:
			fmt.Println(profile)
		}
	}

これはユーザの数xそのユーザに対して実行するAPIの数分selectを実行している。helloChanprofChanどちらに先にデータが届いても、届いたものから実行していく(同時に届いた場合はランダムに実行する処理を選択)。このように複数のチャネルを取り回して行く時selectがあると綺麗に書ける。

まとめ

Golang楽しい。次回はもう少しchannelを使った処理パターンにも踏み込んで書く。