Goの並行処理
この章では、Goの並行処理について概要を解説し、ハンズオン形式で実際に動作させながら学習します。これにより、goroutine・channel・select・sync・contextを組み合わせた並行処理の基本が身につきます。
1. 本章の概要
1.1 本章の目的
並行処理は、Goが多くの現場で採用される大きな理由の1つです。本章では、Goの並行処理を扱います。
1.2 ハンズオンの流れ
本章では、まず直列処理と並行処理の違いを実行結果で確認したあと、Goの並行処理の基本要素である goroutine と channel の組み合わせ、select による多重待ち受け、sync.WaitGroup・sync.Mutex による同期、context によるキャンセルとタイムアウトの伝播を、順にサンプルで動かして確認します。
1.3 事前準備
必要なツール
この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。
| ツール名 | 関連箇所 | 理由 |
|---|---|---|
| Visual Studio Code | Visual Studio Codeのインストール | Goのコードを記述するエディタとして使用する |
| Go | Goのインストール | 本章のサンプルコードを実行する言語処理系として使用する |
2. 並行処理とは
複数の処理を1つずつ順番に実行することを 直列処理、複数の処理を同時進行で実行することを 並行処理 と呼びます。まずは直列で書いたときの挙動を確認したうえで、並行処理のメリット、そしてGoでの実現方法(goroutine)を順に見ていきます。
2.1 直列処理の確認
次のコードは、2秒かかる処理 heavy を3回順に呼び出して、経過時間を測る例です。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"fmt"
"time"
)
func heavy(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "完了")
}
func main() {
start := time.Now()
heavy("A")
heavy("B")
heavy("C")
fmt.Println("経過時間:", time.Since(start))
}
以下のような実行結果が表示されます。
A 開始
A 完了
B 開始
B 完了
C 開始
C 完了
経過時間: 6.0s
heavy("A") が完了してから heavy("B") が始まり、それが終わってから heavy("C") が始まる、という順次実行になっています。1つあたり2秒かかるため、合計で約6秒かかりました。
2.2 並行処理のメリット
3つの処理が互いに独立している場合(heavy("A") の結果を heavy("B") が必要としないなど)、直列に順次実行するのではなく、並行で同時に実行 すれば全体を短時間で終わらせられます。例えば A・B・C を同時に走らせれば、最も遅い1つが終わる時間(約2秒)で全体が完了します。特に外部API呼び出しやファイル読み込みなど、CPUではなくI/O待ち時間が大半を占める処理では、並行化による恩恵が大きくなります。
Goでは、この並行処理を goroutine という仕組みで実現します。
2.3 goroutine
goroutine は、Goが提供する軽量な並行実行の単位です。1つあたり数KB程度と非常に軽く、数万〜数十万の同時実行にも耐えられます。関数呼び出しの前に go キーワードを付けるだけで、その処理を別の goroutine として並行に実行できます。書き方は以下のとおりです。
go 関数呼び出し
次のコードは、先ほど直列で呼び出していた heavy を go キーワードで並行に起動する例です。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"fmt"
"time"
)
func heavy(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "完了")
}
func main() {
start := time.Now()
go heavy("A")
go heavy("B")
go heavy("C")
time.Sleep(2500 * time.Millisecond)
fmt.Println("経過時間:", time.Since(start))
}
以下のような実行結果が表示されます(開始と完了の順序は実行ごとに変わる場合があります)。
A 開始
B 開始
C 開始
A 完了
B 完了
C 完了
経過時間: 2.5s
先ほど直列で書いたときは合計約6秒かかっていましたが、go キーワードで並行に起動したことで、3つの heavy が同時に走り、経過時間が2秒台に収まりました。
コードを解説します。
go heavy("A")
go heavy("B")
go heavy("C")
go キーワードで関数を新しい goroutine として実行します。呼び出し元はその完了を待たずにすぐ次の行に進むため、3つの heavy が同時に走り始めます。
time.Sleep(2500 * time.Millisecond)
もし main 関数が終わってしまうと、まだ実行中の goroutine も強制終了されるため、time.Sleep で終わるのを待っています。heavy が2秒かかるので、少し余裕をもって 2500ms 待つ形にしています。実際には、時間を推測するのではなく、この後の WaitGroup を使って goroutine の完了を確実に待つのが一般的です。
3. channel
複数の goroutine が動くと、goroutine 間で値を受け渡す方法が必要になります。以下のように、go で起動した処理から計算結果を main に戻したい場面はよくあります。
go func() {
result := fetch()
// main にどうやって渡す?
}()
共有変数を経由して受け渡すと、複数の goroutine から同時アクセスされたときの結果が保証されず、データ競合の原因になります。
Go はこの課題に対して、共有メモリを直接読み書きするのではなく、channelを通してやり取りする仕組みを提供しています。Effective Go では、この方針が次のスローガンとして示されています。
Do not communicate by sharing memory; instead, share memory by communicating.
「メモリを共有して通信するのではなく、通信によってメモリを共有する」という意味で、channel を軸に goroutine 間のやり取りを設計する考え方の出発点になります。
3.1 channel の基本
channel は make で作り、<- 演算子で送受信します。書き方は以下のとおりです。
ch := make(chan 型) // 型を送受信できる channel を作る
ch <- 値 // channel に値を送信する
値 := <-ch // channel から値を受信する
次のコードは、別の goroutine から channel に 42 を送り、main 側で受信して表示する最小の例です。
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
v := <-ch
fmt.Println("受信:", v)
}
以下のような実行結果が表示されます。
受信: 42
別の goroutine が送信した 42 を、main 側の受信で受け取って表示できました。
コードを解説します。
ch := make(chan int)
make(chan int) は int 型を送受信できる channel を作ります。channel は goroutine 間で値を受け渡すためのパイプのような役割です。
go func() {
ch <- 42
}()
無名関数を新しい goroutine として起動し、その中で ch <- 42 として channel に 42 を送信しています。
v := <-ch
<-ch で channel から値を受信し、v に代入しています。channel はデフォルトで同期で動き、送信側は受信されるまで、受信側は送信されるまで待ちます。
3.2 バッファ付き channel
先ほど扱った channel は、送信するたびに受信を待つ 同期 で動きます。そのため、「値を先にまとめて溜めておいて、あとから順に取り出したい」場面では、送信が受信に引きずられてしまい書きにくくなります。
こうした場合は、make の第2引数に容量を指定して バッファ付き channel を作ります。指定した容量が埋まるまでは受信を待たずに送信できるため、値を溜めておくキューのように扱えます。書き方は以下のとおりです。
ch := make(chan 型, 容量)
次のコードは、容量3の channel を用意し、4つ目の送信を別の goroutine から試みる例です。バッファが容量いっぱいのため4つ目の送信はブロックしますが、main 側が最初の値を受信して空きができた瞬間に、待機していた4つ目が入る様子を確認します。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
fmt.Println("1〜3を送信済み(バッファは容量いっぱい)")
go func() {
fmt.Println("4の送信を試行(バッファが空くまでブロック)")
ch <- 4
fmt.Println("4を送信できました")
}()
time.Sleep(500 * time.Millisecond)
fmt.Println("受信:", <-ch)
time.Sleep(500 * time.Millisecond)
fmt.Println("受信:", <-ch)
fmt.Println("受信:", <-ch)
fmt.Println("受信:", <-ch)
}
以下のような実行結果が表示されます。
1〜3を送信済み(バッファは容量いっぱい)
4の送信を試行(バッファが空くまでブロック)
受信: 1
4を送信できました
受信: 2
受信: 3
受信: 4
1〜3 はバッファに空きがあるためすぐに送信できましたが、4つ目の ch <- 4 は goroutine 側でブロックしています。main 側が最初の値 1 を受信してバッファに空きができた瞬間に、待機していた ch <- 4 が完了していることが確認できます。
コードを解説します。
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
make(chan int, 3) で容量3のバッファ付き channel を作り、3つの値を送信しています。容量が埋まるまでは受信を待たずに送れます。ここまででバッファは容量いっぱいの状態です。
go func() {
fmt.Println("4の送信を試行(バッファが空くまでブロック)")
ch <- 4
fmt.Println("4を送信できました")
}()
バッファが容量いっぱいの状態で ch <- 4 を実行するため、この送信は goroutine 側でブロックします。受信によってバッファに空きができるまで、次の行には進みません。
fmt.Println("受信:", <-ch)
main 側で最初の値を受信すると、バッファから 1 が取り出されて空きが1つでき、待機していた goroutine の ch <- 4 が完了します。以降の受信では 2・3・4 の順に、送信された順序どおり取り出せます。
バッファ付き channel は、値を先に溜めておいてから受信側で順に取り出す、キューのような使い方をしたい場合に使うことが一般的です。
3.3 channelのクローズ
複数の値を送信する場面では、受信側が「送信がいつ終わったか」を知る必要があります。もし送信の終わりを伝えないと、受信側は「まだ来るかもしれない」と受信を続けようとして、来ない値を待ってブロックしてしまいます。
close なしでの deadlock
ここでは、送信側は3つで送信を終えるのに対して、受信側は「4件届くはず」として for ループで4回受信を試みる、送信数と受信数で認識齟齬が起きた例で挙動を確認します。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import "fmt"
func main() {
ch := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ { // 送信側は3つ送って終わる
ch <- i
}
}()
for i := 1; i <= 4; i++ { // 受信側は4件届くはずとして4回受信を試みる
fmt.Printf("%d回目のループ\n", i)
fmt.Println(<-ch)
}
}
以下のような実行結果が表示されます。
1回目のループ
1
2回目のループ
2
3回目のループ
3
4回目のループ
fatal error: all goroutines are asleep - deadlock!
3周目までは <-ch で値を受信できていますが、4周目に入って <-ch が実行された時点で、受信側は「もう送信は来ない」ことを判断できず、来ない値を待ち続けてブロックしました。全ての goroutine がブロックした状態を Go ランタイムが deadlock として検出し、プログラムが強制終了しています。
close で送信終了を伝える
Go には、送信側から「もう送るものがない」ことを明示的に伝える手段として close が用意されています。書き方は以下のとおりです。
close(ch) // 「もう送信はない」ことを受信側に伝える
for v := range ch { // channel が閉じられるまで受信を続ける
// v を使った処理
}
次のコードは、先ほどのコードに close(ch) を1行だけ追加した例です。受信側のループは4回のままにして、close の有無だけで挙動がどう変わるかを確認します。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import "fmt"
func main() {
ch := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ { // 送信側は3つ送って終わる
ch <- i
}
close(ch) // 送信終了を受信側に伝える
}()
for i := 1; i <= 4; i++ { // 受信側は4件届くはずとして4回受信を試みる
fmt.Printf("%d回目のループ\n", i)
fmt.Println(<-ch)
}
}
以下のような実行結果が表示されます。
1回目のループ
1
2回目のループ
2
3回目のループ
3
4回目のループ
0
close(ch) を追加したことで、先ほどのような deadlock は発生しなくなりました。ただし4周目の <-ch は int のゼロ値である 0 を返しています。閉じた channel から受信すると、バッファに残った値を取り出し切ったあとはブロックせず、型のゼロ値が返る仕様です。
コードを解説します。
close(ch) // 送信終了を受信側に伝える
送信ループの直後で close(ch) を呼び出しています。閉じることで受信側に「もう送信はない」と伝わり、以降の <-ch はブロックせずゼロ値を返すようになります。
実際には、受信側で「あと何件届くか」を数えたくないケースが多くあります。そのため、上のプレースホルダで示した for v := range ch を使うのが一般的です。この書き方であれば、close が検知された時点でループが自動的に終了するため、受信件数を意識せずに書けます。閉じずに for range を使うと永久にブロックしてしまうので、送信側が閉じる責任を持つのが原則です。
4. select
複数の channel を扱っていると、「どれか1つから最初に来たものを処理したい」という場面が出てきます。そのまま書くと以下のように片方から順に受信するしかありませんが、これでは常に ch1 を先に待つことになり、ch2 の値が先に来ても取り出せません。
v := <-ch1
// ch2 は無視されている
ネットワーク待ちの channel と、一定時間で発火する channel を並行して監視してタイムアウトを実装したい場合にも、同じ問題があります。
Go はこの課題に対して、複数の channel を同時に待ち受け、最初に準備できたものを処理する select 文を提供しています。
4.1 複数channelの待機
select は複数の channel の受信を同時に待ち受け、最初に値が来たほうの case を実行します。書き方は以下のとおりです。
select {
case v := <-ch1:
// ch1 から値が届いたときの処理
case v := <-ch2:
// ch2 から値が届いたときの処理
}
次のコードは、2つの goroutine が異なるタイミングで channel に送信する状況で、select で両方の channel を同時に待ち受け、先に来たほうから順に表示する例です。
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "ch1から"
}()
go func() {
time.Sleep(50 * time.Millisecond)
ch2 <- "ch2から"
}()
for i := 0; i < 2; i++ {
select {
case v := <-ch1:
fmt.Println(v)
case v := <-ch2:
fmt.Println(v)
}
}
}
以下のような実行結果が表示されます。
ch2から
ch1から
先に送信するのが ch2(50ms)で、その後に ch1(100ms)が続くため、この順で表示されました。
コードを解説します。
select {
case v := <-ch1:
fmt.Println(v)
case v := <-ch2:
fmt.Println(v)
}
select は複数の channel の受信を同時に待ち受け、最初に値が来たほうの case を実行します。ここでは ch1 と ch2 を待っており、先にどちらか一方が届いた時点でそのブロックが実行され、select を抜けます。
外側の for i := 0; i < 2; i++ で2回まわしているので、両方の channel から1回ずつ値を受け取れます。
4.2 タイムアウトとの組み合わせ
time.After は指定時間後に値を送る channel を返します。これを select に組み合わせるとタイムアウトが表現できます。書き方は以下のとおりです。
select {
case v := <-通常の処理channel:
// 通常の処理
case <-time.After(制限時間):
// タイムアウト時の処理
}
次のコードは、1秒のタイムアウトを設定した select を2回実行し、送信側が500ms(1秒以内)のケースと2秒(1秒超過)のケースで挙動がどう変わるかをまとめて確認する例です。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"fmt"
"time"
)
func main() {
// ケース1: 送信側が500msで応答(1秒以内)
ch1 := make(chan int)
go func() {
time.Sleep(500 * time.Millisecond)
ch1 <- 1
}()
select {
case v := <-ch1:
fmt.Println("500ms 送信: 受信", v)
case <-time.After(1 * time.Second):
fmt.Println("500ms 送信: タイムアウト")
}
// ケース2: 送信側が2秒かかる(1秒を超える)
ch2 := make(chan int)
go func() {
time.Sleep(2 * time.Second)
ch2 <- 2
}()
select {
case v := <-ch2:
fmt.Println("2秒 送信: 受信", v)
case <-time.After(1 * time.Second):
fmt.Println("2秒 送信: タイムアウト")
}
}
以下のような実行結果が表示されます。
500ms 送信: 受信 1
2秒 送信: タイムアウト
ケース1は送信側が500msで送信を完了するため、time.After(1 * time.Second) が発火する前に case v := <-ch1 が動き、受信結果が表示されました。ケース2は送信が2秒かかるため、time.After(1 * time.Second) の channel が1秒で先に発火し、タイムアウト側の case が実行されています。
コードを解説します。ケース1とケース2の select はどちらも同じ構造で、送信側の所要時間だけが異なります。共通する各 case を見ていきます。
case v := <-ch:
fmt.Println("... 送信: 受信", v)
ch から値が届いた場合は、その値を v に受け取り、受信結果として表示します。送信側が1秒以内で送信できれば、この case が発火します。
case <-time.After(1 * time.Second):
fmt.Println("... 送信: タイムアウト")
time.After は指定した時間後に値を送信する channel を返します。この case は「1秒経過したら」実行される形になります。ch からの受信より先にこちらが発火すればタイムアウト側の処理が走ります。
送信側の所要時間と time.After の閾値のどちらが先に発火するかで、どの case が実行されるかが決まります。外部APIの呼び出しやDBのクエリなど、「何秒待って応答がなければ諦める」という処理を書くときに使います。
5. 排他制御
複数の goroutine を扱うようになると、「起動した goroutine が全て終わるのを待ちたい」「複数の goroutine が同じ変数を同時に読み書きするのを防ぎたい」といった同期の処理が必要になります。Go は標準の sync パッケージで、それぞれに対応する WaitGroup(複数 goroutine の完了待ち合わせ)と Mutex(相互排他ロック)を提供しています。
5.1 sync.WaitGroup
sync.WaitGroup は、複数の goroutine を1つのグループとしてまとめ、そのグループ全体の完了を待つための仕組みです。「起動した goroutine のうち、どれが終わったか」を個別に追う必要はなく、「グループ全体が終わるまで待つ」という書き方ができます。ここで言う「グループ」は、同じ sync.WaitGroup の変数を共有している goroutine の集まりを指します。異なる sync.WaitGroup の変数を使えば、それぞれ独立した別のグループとして扱われます。
内部的には、待つ goroutine の数をカウンタで管理しています。goroutine を起動する前にカウンタを増やし、各 goroutine が終わったときにカウンタを減らして、カウンタが 0 になるまで main を待たせる、というシンプルな流れで完了待ちを実現します。
sync.WaitGroup の書き方は以下のとおりです。
var wg sync.WaitGroup
wg.Add(1) // 待つ goroutine の数を1つ増やす
go func() {
defer wg.Done() // goroutine 完了時にカウンタを1減らす
// 何らかの処理
}()
wg.Wait() // カウンタが 0 になるまで待つ
wg.Add(N) で待つ数を先に増やし、goroutine 内で defer wg.Done() を書くのが定番パターンです。wg.Wait() は全ての Done() が呼ばれてカウンタが 0 になるまでブロックします。
次のコードは、3つの goroutine を並行に起動し、全ての完了を sync.WaitGroup で待つ例です。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(500 * time.Millisecond)
fmt.Println("goroutine", i, "完了")
}()
}
wg.Wait()
fmt.Println("すべての goroutine が完了しました")
}
以下のような実行結果が表示されます(完了の順序は実行ごとに変わる場合があります)。
goroutine 3 完了
goroutine 1 完了
goroutine 2 完了
すべての goroutine が完了しました
3つの goroutine がそれぞれ 500ms 待って完了メッセージを出し、wg.Wait() によって全ての完了を待ってから main の最後の行が出力されました。
コードを解説します。
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(500 * time.Millisecond)
fmt.Println("goroutine", i, "完了")
}()
goroutine を起動するたびに wg.Add(1) でカウンタを1増やし、goroutine の中では defer wg.Done() で終了時に確実にカウンタを1減らします。
wg.Wait()
fmt.Println("すべての goroutine が完了しました")
wg.Wait() はカウンタが 0 になるまで、つまり全ての goroutine が Done() を呼び終えるまでブロックします。全 goroutine の完了を待ってから、次の行の「すべての goroutine が完了しました」が出力されます。
5.2 sync.Mutex
複数の goroutine が同じ変数を同時に読み書きすると、値が壊れることがあります。以下のコードは 10 個の goroutine から共有変数 count をインクリメントしていますが、結果は必ずしも 10 になりません。
var count int
for i := 0; i < 10; i++ {
go func() {
count++ // 複数 goroutine が同時にアクセスして値が壊れる
}()
}
これをデータ競合と呼びます。基本方針は channel でやり取りすることですが、状態を持つ struct のフィールドを守る場面などでは、channel よりも直接ロックを取る方が自然な場合があります。
sync.Mutex は、「同時に1つの goroutine だけがロックを取得できる」ことを保証する仕組みです。共有変数を読み書きする前にロックを取得し、読み書きが終わったら解放するようにすると、まだロックを取得できていない他の goroutine はその間待たされます。この順番待ちによって、複数の goroutine が同じ変数を同時に読み書きしてしまうことを防げます。この「一度に1つだけ」を保証する仕組みを 相互排他ロック(Mutual Exclusion Lock) と呼び、Mutex の名前もこれに由来します。
sync.Mutex の Lock() でロックを取得し、Unlock() で解放します。ロック中は他の goroutine が同じ mutex の Lock() でブロックされます。書き方は以下のとおりです。defer で必ず解放するのが慣用パターンです。
var mu sync.Mutex
mu.Lock()
defer mu.Unlock() // 関数を抜けるときに必ず解放する
// 排他制御が必要な処理
次のコードは、1000個の goroutine が同じカウンタを 1 ずつ増やす例です。sync.Mutex で count を守っているため、最終的に正しく 1000 が得られます。完了待ちには先ほど学んだ sync.WaitGroup を使います。
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func main() {
c := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.count)
}
以下のような実行結果が表示されます。
1000
1000個の goroutine がそれぞれ 1 増やし、mutex によって競合を防いだ結果、最終的に正しく 1000 になりました。
コードを解説します。
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
SafeCounter は sync.Mutex を内部に持ち、Inc の中で Lock() してから count++ します。Lock() を呼ぶとロックを取得し、他の goroutine は同じ mutex の Lock() でブロックされます。defer c.mu.Unlock() で必ず解放するのが慣用パターンです。
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
1000個の goroutine を起動し、sync.WaitGroup で全てが終わるのを待ちます。もし mutex を使わずに c.count++ を書くと、1000 に満たない値が返ることがあります(データ競合)。go run -race main.go で実行すると、Go に組み込まれたレース検出器がデータ競合を報告してくれます。
6. context
長時間かかる処理や外部通信を含む処理では、「ユーザがリクエストをキャンセルした」「処理がタイムアウトした」といった理由で途中で止めたい場面があります。そのまま書くと、以下のような「一度始めたら止められない」処理になります。
func fetchData() Result {
// 数秒かかる処理
return heavy()
}
呼び出し側が「もういらない」と思っても、fetchData の中から中断する術がなく、その分のリソースを浪費し続けてしまいます。goroutine が階層的に呼び出されている場合、キャンセルの指示を末端まで伝える手段を自前で用意するのは大変です。
Go はこの課題に対して、キャンセル・タイムアウトを goroutine の連鎖を跨いで伝える context パッケージを提供しています。多くのGoコードでは、関数の第1引数に ctx context.Context を渡すのがほぼ標準的な作法です。
6.1 タイムアウト付きの実行
context.WithTimeout は指定時間後に自動的にキャンセルされる context を返します。受け側は ctx.Done() を select で待ち受け、キャンセル時は ctx.Err() で理由を取り出します。書き方は以下のとおりです。
ctx, cancel := context.WithTimeout(context.Background(), 制限時間)
defer cancel()
select {
case <-通常の処理完了を示すchannel:
// 正常終了
case <-ctx.Done():
// ctx.Err() でキャンセルの理由を取り出せる
}
次のコードは、2秒かかる処理 longJob に対して 1 秒のタイムアウトを設定し、時間切れでキャンセルされる様子を確認する例です。
package main
import (
"context"
"fmt"
"time"
)
func longJob(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := longJob(ctx); err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println("完了")
}
以下のような実行結果が表示されます。
エラー: context deadline exceeded
longJob は 2 秒かかる想定ですが、1 秒のタイムアウトが先に発火したため、context deadline exceeded エラーが返り、それが表示されました。
コードを解説します。
func longJob(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
select で「2秒後の完了」と「ctx.Done()(キャンセル通知)」の両方を待っています。どちらか先に発火したほうが処理されます。ctx.Done() が先に発火した場合は ctx.Err() でキャンセルの理由(context deadline exceeded など)を取り出して返します。
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
context.WithTimeout は指定時間後に自動的にキャンセルされる context を返します。第2戻り値の cancel は「明示的にキャンセルする関数」で、defer cancel() で必ず呼び出しておくとリソース解放のためにも安全です。
6.2 context の伝播
context は関数間で受け渡すことで、上位の処理から下位の処理まで一貫してキャンセル信号を伝えられます。HTTPサーバ、DBクライアント、外部API呼び出しは全て context.Context を第1引数で受ける設計になっているため、上位で作った ctx を下位に渡していけば、途中でどこかがキャンセル・タイムアウトしたときに末端まで一気に伝わります。
書き方は以下のとおりです。関数の第1引数で ctx context.Context を受け取り、内部で呼び出す別の関数にもそのまま渡していきます。
func 上位の関数(ctx context.Context) error {
return 下位の関数(ctx) // 受け取った ctx をそのまま渡す
}
次のコードは、main で作った1秒タイムアウトの ctx を callWorkflow → doWork と渡し、doWork の処理が2秒かかることでタイムアウトが末端まで伝播する例です。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context) error {
select {
case <-time.After(2 * time.Second): // 2秒かかる処理を想定
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func callWorkflow(ctx context.Context) error {
return doWork(ctx)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := callWorkflow(ctx); err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println("完了")
}
以下のような実行結果が表示されます。
エラー: context deadline exceeded
doWork は2秒かかる想定ですが、main が作った ctx の1秒タイムアウトが先に発火し、ctx.Done() の case が選ばれました。ctx.Err() から得た context deadline exceeded が callWorkflow を経由して main まで戻り、エラーとして表示されています。
コードを解説します。
func callWorkflow(ctx context.Context) error {
return doWork(ctx)
}
callWorkflow は受け取った ctx を加工せずそのまま doWork に渡しています。中間層は自分でキャンセル判定を行わなくても、ctx を素通しさせるだけで下位に信号が届きます。
func doWork(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
末端の doWork で select を使って「通常の処理の完了」と「ctx からのキャンセル」の両方を待ち受けています。main が作った ctx の1秒タイムアウトが、関数呼び出しの連鎖を跨いでこの select まで届いていることが分かります。
上位で ctx の寿命を変えれば、callWorkflow や doWork の実装を変えずにタイムアウトの長さを調整できます。これが context を関数間で受け渡す最大のメリットです。
7. まとめ
この章では、Goの並行処理を学びつつ、実際にgoroutine・channel・select・sync・contextを組み合わせた並行処理を体験しました。
go 関数呼び出しにより、軽量なgoroutineを非常に軽く扱える- channelにより、goroutine間の値の受け渡しを行い、同期/非同期は容量指定で切り替える
selectにより、複数のchannelを待ち受けられ、time.Afterと組み合わせるとタイムアウトを表現できる- 状態を持つstructを守るときは、
sync.Mutexを使い、defer mu.Unlock()で解放するのが基本パターンである sync.WaitGroupにより、複数のgoroutineが終わるのを待てるcontextパッケージにより、キャンセル・タイムアウトをgoroutineの連鎖を跨いで伝えられ、実際には関数の第1引数にctx context.Contextを受けるのが標準的な作法である
次の章では、Goでのデータベース操作をハンズオン形式で体験します。