Goと言えばConcurrencyですね。 実際、JavaやPythonのような言語と比べるとハードルが非常に低いと感じました。
安全に使うにはもっと学ばないといけないことが多いと思いますが、とりあえず基本的な部分だけ記載したいと思います。
goroutine
goroutineとはGoにおける軽量スレッドのようなものです。
(厳密にはスレッドではなく、1スレッド上で大量のgoroutineが動作する)
main functionもgoroutineです。
go function名()で新しいgoroutineを開始することができます。
package main
import (
"fmt"
)
func doSomething() {
fmt.Println("doSomething start")
}
func main() {
go doSomething()
// time.Sleep(1 * time.Second)
}
上記の例を実行するとなにも出力されません。 開始したdoSomething goroutineがメッセージを出力するより先にmainが終了してしまうからです。
最後のsleep行のコメントアウトを外せば出力が確認できますが、ダサいです。 次のchannelを使う方法がGoらしい同期方法です。
このようにGoのConcurrencyではmainが他のgoroutineより先に終わらないように(=他のgoroutineの終了を待つように)注意しましょう。
channel
channelはGoのConcurrencyの中核的な要素です。 チャネルを使いこなしましょう。
メッセージ送受信
channelを使ってgoroutine間でデータを送受信できます。 共有リソースにアクセスさせることを避け、channelでデータを交換することがGoの思想です。
channelはmakeで作成します。
このときchannelの型を指定します。
ch := make(chan string)
channelへのデータ送信は以下のようにします。
ch <- "This is a result from doSomething"
channelからデータを受信するには以下のようにします。
result := <-ch
以下がdoSomething goroutineが処理結果をmainへ送っている例です。
func doSomething(ch chan string) {
fmt.Println("doSomething start")
ch <- "This is a result from doSomething"
}
func main() {
ch := make(chan string)
go doSomething(ch)
fmt.Println("Received from channel:", <-ch)
}
以下のように複数のgoroutineに同じchannelを渡してデータを複数回受け取る場合は、受け取る回数だけ<-chします。
func doSomethingA(ch chan string) {
ch <- "This is a result from doSomethingA"
}
func doSomethingB(ch chan string) {
ch <- "This is a result from doSomethingB"
}
func main() {
ch := make(chan string)
go doSomethingA(ch)
go doSomethingB(ch)
fmt.Println("Received from channel:", <-ch)
fmt.Println("Received from channel:", <-ch)
}
Blocking
<-chは、データを受信するまでBlocking状態になります。
これを利用してwait/notifyのようなことができます。
(逆もまた然りでch <- dataは受信側が準備できるまでBlock状態になる)
以下は最初の「goroutine」のサンプルを改善した例です。
main functionの末尾の<-chはdoSomethingがchannelにデータを書き込むまで待ち状態になります。
これでsleepを使わなくてもdoSomethingがメッセージを出力するまでmainの終了を待たせることができます。
このケースではchannel上で送受信するデータはなんでも構わないので空のstructにしてメモリを節約しています。
func doSomething(ch chan struct{}) {
fmt.Println("doSomething start")
ch <- struct{}{} // 終了をmainに通知
}
func main() {
ch := make(chan struct{})
go doSomething(ch)
<-ch // doSomethingが終了するまで待つ
}
channel buffering
前述の通り、受信側(<-ch)がいないchannelに対するwriteはBlocking状態になります。 以下のようにすると指定した分だけバッファできるようになります。 指定した数を超えるとやはりBlockingが発生します。
ch := make(chan struct{}, 3)
受信側がいないchannelにwriteするとblockingしないでエラーが起きる?
試してみたらこんなエラーが出て、blocking状態にならないことがあります。
fatal error: all goroutines are asleep - deadlock!
これはgoroutineが2つ以上、ないからです。
<-chする可能性があるgoroutineが1つもないのでGoがdeadlockと判定しています。
goroutineが開始されてないか、開始したけど終了してしまったかのどちらかです。
<-chしなくてもいいのでgoroutineを開始し、time.Sleepなどで数秒間活かしておくとBlocking状態を見ることができます。
range
for文でrangeが使えます。 書き込み側で最後にcloseすることを忘れるとエラーになるので注意しましょう。
func doSomething(ch chan string) {
fmt.Println("doSomething start")
ch <- "result 1"
ch <- "result 2"
close(ch)
}
func main() {
ch := make(chan string)
go doSomething(ch)
for msg := range ch {
fmt.Println("Received from channel:", msg)
}
}
close
channeをcloseすることは必須ではありません。 上述のrangeを利用するときや、もう書き込むものがないことを受信側へ明示的に伝えたいときはcloseします。
closeは書き込み側が行います。 closeすると全ての受信側にメッセージが送られます。 これをnotifyAllのように利用することもできます。
書き込み側が送信直後にcloseしても、close後、最後に書き込まれたデータを受信側が受信することは可能です。
select
selectを使うと複数のchannelがある状況で、最初にデータを受信したchannelを処理することができます。
time.Afterはchannelを返すので組み合わせるとタイムアウト管理もできます。
func doSomethingA(ch chan string) {
time.Sleep(500 * time.Millisecond)
ch <- "done"
}
func main() {
ch := make(chan string)
go doSomethingA(ch)
timer := time.After(1 * time.Second)
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-timer:
fmt.Println("Timeout occurred")
}
}
selectを活用することで色々な仕組みを実装できます。
Atomic counters
sync/atomicモジュールにatomicな操作ができるカウンタがあります。
複数のgoroutineで共有するカウンタが必要な場合はまずはこれを検討します。
シンプルなことはシンプルに実装しましょう。
func main() {
var counter atomic.Uint64
counter.Add(2)
fmt.Println("counter:", counter.Load())
}
Mutex
MutexはJavaやPythonのsynchronizedです。 共有リソースへのアクセスが避けらず、かつAtomicな仕組みが利用できない場合に利用します。 できれば設計レベルで避けたいですね。
この例ではsomeCommonDataへのアクセスを保護しています。
type CommonResource struct {
lock sync.Mutex
someCommonData int
}
func (cr *CommonResource) Add(number int, name string) {
cr.lock.Lock()
fmt.Println("Lock started for worker ", name)
defer cr.lock.Unlock()
cr.someCommonData += number
time.Sleep(1 * time.Second)
fmt.Println("Lock ended for worker ", name)
}
func worker(cr *CommonResource, name string, ch chan struct{}) {
cr.Add(1, name)
ch <- struct{}{}
}
func main() {
ch := make(chan struct{})
cr := &CommonResource{}
go worker(cr, "A", ch)
go worker(cr, "B", ch)
<-ch
<-ch
}
このようにアクセスが直列化されました。
❯ go run . Lock started for worker B Lock ended for worker B Lock started for worker A Lock ended for worker A ❯
wait group
全てのgoroutineの終了を待つ機能です。 シンプルに書けますが、workerからエラーをmainへ伝搬させるのが困難なことが難点です。
func worker(id int) {
fmt.Printf("Worker %d starting\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Go(func() {
worker(i)
})
}
wg.Wait()
}
感想
他の言語だと単純な処理を並列に動かすだけでも、色々なコードを書く必要があります。
Goでは普通のFunctionをgo function名()するだけで並列実行できるので、ともかく参入の敷居が低いです。
そこにchannelとselectを組み合わせれば多くのことを実現できます。
GoのConcurrencyが優れていると言われる理由がわかります。