この記事で学ぶこと
#18 では goroutine を起動し、WaitGroup で完了を待ち、共有変数への同時書き込みが壊れる様子を実測しました。最後の実技問題では、5件の結果を「各自の席に書く」方式で集めました。競合しないきれいな解決でしたが、あの方式には前提があります。件数が事前にわかっていて、席を番号で割り当てられることです。
今日は goroutine 同士が値を直接手渡しする channel を学びます。件数を知らなくても結果を集められ、完了待ちと受け渡しを1つの道具でこなせます。そして #17 で「まだ学んでいない型なので今日は使いません」と先送りにした ctx.Done() の正体を、ついに回収します。
この記事を終えると以下ができるようになります。
- channel で goroutine 間の値の受け渡しと完了待ちを同時にできる
- 送受信が「相手が揃うまで待つ」ことを説明でき、deadlock のエラーを読める
-
closeとfor rangeで「もう値は来ない」を伝えられる -
selectとctx.Done()でタイムアウトつきの待ちを書け、worker pool で同時実行数を絞れる
この回はGo編です。channel はGo固有の文法ですが、「行列(キュー)を介して仕事と結果を受け渡す」という設計は言語を問いません。Python編では asyncio.Queue で同じ内容を扱う予定です。
見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。
準備
今回も PostgreSQL は使いません。Goだけで完結します。
mkdir channel-practice && cd channel-practice
go mod init channel-practice
#18 と同じく、ループ変数の扱いが新しくなった Go 1.22 以降を前提にしています(私の環境は go1.22.2 です)。
goroutine は値を返せない
まず、channel が何を解決する道具なのかをはっきりさせます。1秒かかる仕事から結果を受け取りたいとします。
func fetch(id int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("結果%d", id)
}
普通に呼べば r := fetch(1) で受け取れます。では並行にしようと go を付けると、戻り値はどこへ行くのか。受け取ろうとしてみます。
result := go fetch(1)
syntax error: unexpected go, expected expression
コンパイルすら通りません。go f() は「起動して即座に次へ進む」文なので、戻り値を待って代入すること自体が矛盾します。goroutine の戻り値は、誰にも受け取られずに捨てられます。#18 の実技問題で戻り値ではなくスライスへの書き込みで結果を集めたのは、これが理由です。
では書き込み先を用意する以外に、値を起動した側へ届ける方法はないのか。ここで channel が登場します。
レストランの喩えを続けます。厨房と配膳係の間には、完成した料理を置く受け渡しカウンターがあります。料理人は皿をカウンターに出し、配膳係はカウンターから取る。料理人は配膳係が今どこにいるか知らなくていいし、配膳係は厨房の中の事情を知らなくていい。カウンターという1点だけを共有して、あとは互いに独立に動けます。channel はこの受け渡しカウンターです。
【手を動かす①】値を1つ手渡しする
channel-practice/main.go を作ります。
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "唐揚げ定食"
}()
dish := <-ch
fmt.Println("受け取った:", dish)
}
文法は3つだけです。make(chan string) で「string を流せる channel」を作る。ch <- 値 で送信する。<-ch で受信する。矢印が channel に向いていれば送信、channel から出ていれば受信と覚えてください。
go run .
受け取った: 唐揚げ定食
受け取れました。ここで #18 を思い出してほしいのですが、このコードには time.Sleep も sync.WaitGroup もありません。#18 の最初の実験では、main が先に終わって goroutine の挨拶が消えました。今回消えなかったのは、<-ch が「値が来るまでその場で待つ」からです。goroutine が皿を出すまで main は動きません。
つまり channel は値を運ぶと同時に、待ち合わせもこなしています。この二役が channel の本質です。
🔴 送受信は「手渡し」で、相手が揃うまで進まない
make(chan string) で作った channel(バッファなし channel と呼びます)の受け渡しは、置き逃げではなく手渡しです。送信側は受け取り手が現れるまでその場で待ち、受信側は値が来るまでその場で待ちます。両者が揃った瞬間に値が渡り、両者が同時に動き出します。
「待つ」がどれほど本気か、実験で確かめます。受け取り手を用意せずに送信してみます。
package main
import "fmt"
func main() {
ch := make(chan string)
ch <- "唐揚げ定食"
fmt.Println("ここには到達しない")
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/channel-practice/main.go:7 +0x28
exit status 2
プログラムが強制終了しました。エラーを読み解くと「全 goroutine が眠っていて、誰も他の誰かを起こせない(deadlock)」「goroutine 1(main)は channel への送信で止まっている([chan send])。場所は main.go の7行目」です。main は受け取り手が現れるのを待っていますが、この世界には main しかいないので、待ち人は永遠に来ません。Goのランタイムはこの「全員が待ちで固まった」状態を検出して、その場で落としてくれます。
恐ろしげなエラーですが、実は親切な挙動です。黙って固まり続けるサーバーより、行番号付きで即死してくれるプログラムの方がずっとデバッグしやすいからです。このエラーを見たら「どこかの送信または受信に、相手がいない」と読んでください。
【手を動かす②】5件の結果を集める
#18 の実技問題と同じ題材を channel で書き直します。1秒かかる fetch を5回、並行で呼んで全結果を集めます。
package main
import (
"fmt"
"time"
)
func fetch(id int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("結果%d", id)
}
func main() {
start := time.Now()
results := make(chan string)
for i := 0; i < 5; i++ {
go func() {
results <- fetch(i)
}()
}
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
fmt.Printf("%.1f秒\n", time.Since(start).Seconds())
}
結果3
結果2
結果4
結果1
結果0
1.0秒
5件揃って約1秒です。go run -race . も警告なしでした(順序が毎回変わるのは #18 で見たとおりです)。
#18 の「各自の席」方式と見比べてください。2つ消えたものがあります。
1つ目は sync.WaitGroup です。5回の受信がそのまま完了待ちを兼ねるので、点呼が要りません。値を運ぶ道具と待つ道具が別々だったのが、channel では1つになっています。
2つ目は競合の心配です。席方式は「書き込み先を全員分けることで」競合を避けました。channel は逆で、全員が同じ1本の管に送りますが、管への出し入れは1件ずつしか起きないように channel 自身が守っています。#18 のカウンタ事故は「読んで書き戻す隙間に割り込まれる」ことが原因でしたが、channel の送受信にその隙間はありません。ロックを自分で書かなくていいのは、channel が内部で面倒を見ているからです。
ただしこのコードには弱点が残っています。受信側が「5回」と回数を知っている必要があることです。仕事の件数が動的に変わる場面では、この書き方は使えません。
🟡 close と for range で「もう来ない」を伝える
配膳係の立場で考えると、知りたいのは「あと何皿か」ではなく「もう皿は出てこないのか」です。channel には閉店の合図があります。close(ch) です。受信側は for r := range ch と書くと、channel が close されるまで受け取り続けます。
回数のハードコードを消した版がこれです。
package main
import (
"fmt"
"sync"
"time"
)
func fetch(id int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("結果%d", id)
}
func main() {
start := time.Now()
results := make(chan string)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- fetch(i)
}()
}
// 全員の送信が終わったら閉店の札を掛ける係
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
fmt.Printf("%.1f秒\n", time.Since(start).Seconds())
}
結果3
結果2
結果1
結果0
結果4
1.0秒
受信側のループから「5」が消えました。goroutine を7個に増やしても受信側は書き換え不要です。
WaitGroup が戻ってきたことに気づいたと思います。ただし役割が変わっていて、main のためではなく close のタイミングを見極めるためにいます。close を main がやるわけにはいきません(main は range で受信中です)。各 goroutine が自分の送信後にやるのもだめです(自分が最後の1人かどうか、誰にもわからないからです)。そこで「全員の完了を見届けてから札を掛ける」専任の goroutine を1人立てます。wg.Wait() してから close() するこの3行は、複数の送信者がいるときの定番の型なので、形ごと覚えてください。
🟡 close された channel の挙動
close の意味を正確に押さえます。close は「もう送信しない」という宣言であって、channel を破壊するわけではありません。実験します。
package main
import "fmt"
func main() {
ch := make(chan string, 1)
ch <- "最後の1皿"
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Printf("%q %v\n", v, ok)
}
最後の1皿 true
"" false
受信は2つの値を返せて、2つ目の ok が「本物の値か」を教えてくれます。整理するとこうなります。
| 操作 | 挙動 |
|---|---|
| close 済みから受信(中身が残っている) | 普通に受け取れる。ok は true
|
| close 済みから受信(空) | ゼロ値と ok = false が待たずに即座に返る |
| close 済みへ送信 |
panic: send on closed channel で即死 |
| 二重に close | 同じく panic |
送信だけが panic するのは、close が送信側の宣言だからです。「もう送らない」と宣言した後に送るのは自己矛盾なので、プログラムの誤りとして即死させます。ここから close は送信側の仕事というルールが導けます。受信側が勝手に close すると、まだ送るつもりだった送信者を panic させてしまいます。
もう1つ、表の2行目に線を引いてください。空の closed channel からの受信は、待ちません。即座に返ります。①で見た「値が来るまで待つ」が崩れる特別な場合で、for range が終了できるのもこの性質のおかげです。そしてこの一行が、次の節の伏線です。
⚪ バッファつき channel
make(chan string, 2) のように容量を指定すると、カウンターに皿を2枚まで置けるようになります。置き場が空いている限り、送信は受け取り手を待たずに戻れます。
ch := make(chan string, 2)
ch <- "1皿目"
ch <- "2皿目"
fmt.Println("2皿目まで置けた")
ch <- "3皿目"
2皿目まで置けた
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/channel-practice/main.go:10 +0x96
2皿目までは受け取り手なしで進み、3皿目で満杯になって手渡し待ちに戻りました。バッファは待ちを消すのではなく、待ちが始まるまでの余裕を作るだけです。迷ったらバッファなしで書き、必要な理由を説明できるときだけ付ける、という運用で困りません。次の節で「理由を説明できる」使いどころを1つ見せます。
【手を動かす③】select で「早い方」を待つ。Done() の正体
場面を実務に寄せます。3秒かかる重い集計があるとして、呼び出し元としては1秒までしか待てない。#17 で学んだ context.WithTimeout の出番ですが、あのときタイムアウトの実行はサーバーやDBドライバに任せていました。今日は自分が起動した goroutine の待ちを、自分で打ち切ります。
必要なのは「結果の到着」と「タイムアウト」のどちらか早い方を待つ道具です。select 文がそれで、複数の受信を並べて書くと、先に来た方の case だけを実行します。
package main
import (
"context"
"fmt"
"time"
)
func slowWork() string {
time.Sleep(3 * time.Second)
return "重い集計の結果"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
fmt.Printf("Done()の型: %T\n", ctx.Done())
result := make(chan string, 1)
go func() {
result <- slowWork()
}()
start := time.Now()
select {
case r := <-result:
fmt.Println("間に合った:", r)
case <-ctx.Done():
fmt.Printf("あきらめた: %v (%.1f秒)\n", ctx.Err(), time.Since(start).Seconds())
}
}
Done()の型: <-chan struct {}
あきらめた: context deadline exceeded (1.0秒)
3秒の仕事を1.0秒で見切りました。そして1行目に注目してください。#17 で先送りにした ctx.Done() の戻り値は、受信専用の channel でした(<-chan struct{} の先頭の矢印が「受信しかできない」印です)。
仕組みはこうです。context は内部に channel を1本持っていて、キャンセルまたはタイムアウトの瞬間にそれを close します。②の表で引いた線を思い出してください。closed channel からの受信は、待たずに即座に返るのでした。つまり <-ctx.Done() は「close されるまで待ち、された瞬間に進む」受信になります。値を送るのではなく close を使うのは、送信だと受け取れるのが1人だけなのに対し、close なら受信者が何人いても全員に一斉に伝わるからです。1本の channel が放送設備になるわけです。#17 で「キャンセルは context の木構造を親から子へ自動的に伝わる」と学びましたが、その伝達の実体がこの close でした。
細かい点を1つ。result にバッファを1つ付けています。タイムアウトした場合、main は select を抜けて受信をやめますが、slowWork の goroutine は3秒後に律儀に送信しにきます。バッファなしだと受け取り手が永遠に現れず、この goroutine は送信の行で固まり続けます。#18 で学んだ goroutine リークの channel 版です。バッファが1つあれば置き逃げして帰れるので、リークしません。これが「理由を説明できるバッファ」の代表例です。
【手を動かす④】worker pool で同時実行数を絞る
#18 で goroutine は10万個起動できると実測しました。ただし、それは起動する側の話です。100件の外部API呼び出しを100並行で一斉に投げたら、今度は相手側が悲鳴を上げます。APIのレート制限に刺さり、DBならコネクション上限を食い潰します。並行にしたい、しかし同時実行は3人までに絞りたい。この形を worker pool と呼びます。
channel のもう1つの顔が、ここで効きます。channel は受け渡しカウンターであると同時に、**仕事の行列(キュー)**としても使えます。注文伝票を channel に流し、3人の worker がそれぞれ「伝票を取る、こなす、次を取る」を繰り返す構図です。
package main
import (
"fmt"
"sync"
"time"
)
// 1件1秒かかる仕事(外部APIの呼び出しを想像してください)
func process(job int) string {
time.Sleep(1 * time.Second)
return fmt.Sprintf("仕事%d 完了", job)
}
func worker(id int, jobs <-chan int, results chan<- string) {
for j := range jobs {
results <- fmt.Sprintf("%s (担当: worker%d)", process(j), id)
}
}
func main() {
start := time.Now()
jobs := make(chan int)
results := make(chan string)
// worker を3人だけ起動する
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1)
go func() {
defer wg.Done()
worker(id, jobs, results)
}()
}
// 仕事を9件流し、流し終えたら閉店
go func() {
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
fmt.Printf("9件を3人で: %.1f秒\n", time.Since(start).Seconds())
}
仕事2 完了 (担当: worker2)
仕事1 完了 (担当: worker1)
仕事3 完了 (担当: worker3)
仕事5 完了 (担当: worker3)
仕事6 完了 (担当: worker1)
仕事4 完了 (担当: worker2)
仕事7 完了 (担当: worker3)
仕事8 完了 (担当: worker1)
仕事9 完了 (担当: worker2)
9件を3人で: 3.0秒
go run -race . で警告なし。1秒×9件なので直列なら9秒、9並行なら1秒のところ、3人で3.0秒。同時実行がきっちり3に絞られている証拠です。出力を見ると、3人がほぼ同時に1件ずつ取り、終えた人から次を取っているのがわかります。
コードの読みどころは3つです。
- 同時実行数を決めているのは、起動した worker の人数だけです。仕事が9件でも90件でも、worker が3人なら同時に進むのは3件。絞りの制御に特別な仕組みは何も要りません
-
close(jobs)が「注文はもう来ない」の合図です。これで3人全員のrange jobsが終わり、worker たちは静かに帰ります。close 1回で全員に伝わるのは、③で見た放送の性質そのものです - 引数の型
jobs <-chan intは受信専用、results chan<- stringは送信専用という宣言です。worker が誤って jobs に送信するとコンパイルエラーになります。#14 で学んだ「依存の向きは一方通行」と同じで、守るべき向きを型に書いてコンパイラに見張らせる発想です
見覚えのある構図だと感じたら、それは #12 です。SetMaxOpenConns は「DBへ同時に開く接続数の上限を決める」設定でした。プールという同じ発想を、あちらはライブラリ設定で、こちらは worker の人数で実現しています。凄腕が仕事の速さと相手への負荷をどう両立させているか、その答えはたいてい「無制限に並行化せず、絞り幅を自分で決める」です。
実務での判断基準
🔴 Mutex と channel の使い分け
#18 の Mutex と今日の channel は、どちらも並行の事故を防ぐ道具です。使い分けの軸は1つで、渡したいのか、守りたいのかを自問します。
値やジョブを goroutine から goroutine へ渡したい、完了やキャンセルを伝えたい。それは受け渡しの問題なので channel です。逆に、複数の goroutine が触る共有のカウンタやマップを壊れないようにしたい。それは保護の問題なので Mutex です。#18 のカウンタを channel で書き直すこともできますが、集計係の goroutine を立てる大掛かりな話になり、mu.Lock() 3行で済むものを複雑にするだけです。逆に worker pool を Mutex で組もうとすると、行列の管理を全部自作するはめになります。道具に合う問題を選ぶのではなく、問題に合う道具を選んでください。
🔴 goroutine の詰まりは channel 起因が定番
#18 で「goroutine は終わり方を決めてから起動する」と学びました。終われない goroutine の実際の死因は、channel の待ちであることが大半です。典型は2つ。受け取り手が先に帰った channel へ送信し続けている(③でバッファ1により回避した形)。close されないまま放置された channel を range で待ち続けている(②で見届け役を立てて防いだ形)。
対策も今日すでに全部出ています。結果 channel には受け手が消える可能性を考えてバッファを1つ。close の責任者を1人決める。長く待つ受信には select と ctx.Done() で抜け道を用意する。障害調査で goroutine の数が右肩上がりのグラフを見たら、まずどこかの channel の前で行列ができていないかを疑ってください。
🟡 close は義務ではなく通信
「開いたら閉じる」の連想で、すべての channel を close したくなりますが、ファイルとは事情が違います。close しなかった channel も、誰からも参照されなくなればGCが普通に回収します。close が必要なのは「もう来ない」を受信側に伝える必要があるとき、つまり for range や ②③ のパターンを使うときだけです。伝える相手がいないのに形式的に close を書くと、かえって「二重 close の panic」の危険を増やします。close は後始末ではなく、それ自体が1つのメッセージだと捉えてください。
⚪ 今日は流してよいもの
実務のコードでは、今日手書きした「WaitGroup + エラー処理 + context 連動」を1つにまとめた errgroup というライブラリ(golang.org/x/sync/errgroup)をよく見かけます。今日の内容が土台になっているので、出会ったときに調べれば読めます。また select に default 節を書くと「待たずに試すだけ」になる、nil の channel は永遠にブロックする、といった細かい挙動もありますが、必要になってからで大丈夫です。
確認テスト
知識問題
Q1. ①のコードには time.Sleep も WaitGroup もないのに、main は goroutine の結果を受け取れました。理由を説明してください。また、受け取り手のいないバッファなし channel へ送信すると何が起きるか、エラー名とともに述べてください。
解答を見る
<-ch という受信は、値が届くまでその場で待つからです。main は goroutine が送信するまで先へ進めないので、待ち合わせの道具を別に用意しなくても、受信そのものが完了待ちを兼ねます。channel が「通信」と「同期」の二役をこなす、という今日の核心がこれです。
受け取り手のいない送信は、現れるはずのない相手を永遠に待ちます。プログラム内の全 goroutine が待ちで固まると、Goランタイムが検出して fatal error: all goroutines are asleep - deadlock! で強制終了します。エラーには止まっている goroutine と行番号([chan send] なら送信で停止)が載るので、「この送信の相手は誰のはずだったか」から逆算してデバッグします。
Q2. close 済みの channel に対する「受信(中身あり)」「受信(空)」「送信」の挙動をそれぞれ述べてください。また「close は送信側がやる」というルールの理由を説明してください。
解答を見る
中身が残っていれば普通に受け取れます(ok は true)。空になった後の受信は、待たずに即座にゼロ値と ok = false を返します。送信は panic: send on closed channel で即死します。二重の close も panic です。
close は「もう送らない」という送信側の宣言です。だから宣言後の送信は自己矛盾として panic になります。もし受信側が close できてしまうと、まだ送るつもりの送信者を panic させられることになります。送る側にしか「もう送らない」は判断できないので、close の権限も送信側にあります。送信者が複数いる場合は、②のように全員の完了を wg.Wait() で見届けてから close する係を1人立てます。
Q3. ctx.Done() の戻り値は何ですか。また context のキャンセル通知が、channel への「値の送信」ではなく「close」で実装されているのはなぜですか。
解答を見る
受信専用の channel(<-chan struct{})です。context は内部に channel を持ち、キャンセルやタイムアウトの瞬間にそれを close します。
close が使われる理由は、通知を受けたい相手が1人とは限らないからです。値の送信では、その値を受け取れるのは1つの受信者だけです。一方 close されると、空の closed channel からの受信は即座に返るという性質により、<-ctx.Done() で待っていた全員が一斉に動き出します。1回の close が全受信者への放送になるわけです。1つのリクエストから派生した多数の goroutine へキャンセルを同時に伝える、という context の仕事にちょうど合っています。
Q4. ④の worker pool で、同時実行数が3に絞られるのはなぜですか。また close(jobs) を消すと何が起きるか予想してください。
解答を見る
同時実行数を決めているのは worker として起動した goroutine の人数です。仕事が何件並んでいても、jobs channel から伝票を取って処理できるのは、生きている worker の数だけです。3人それぞれが「1件取る、1秒かけてこなす、次を取る」を繰り返すので、9件が3巡で片付き、実測3.0秒になりました。
close(jobs) を消すと、9件を配り終えた後も3人の worker は range jobs で10件目を待ち続けます。worker が終わらないので wg.Wait() も終わらず、close(results) も実行されず、main の range results も終わらない。全員が待ちで固まるので、この規模なら deadlock として検出されて落ちます。サーバーの中でこれをやると検出されず、worker がリークとして残り続けます。close の1行が全体の「終われるかどうか」を握っています。
実技問題
Q5. 遅さの違う5つのサービスへ並行に問い合わせ、全体の制限時間1.2秒以内に届いた結果だけを表示するプログラムを書いてください。時間切れになったら、集められた件数を報告して打ち切ること。次の関数を使います(id が大きいほど遅く、id×0.5秒かかります)。
// idが大きいほど遅いサービス(id × 500ミリ秒かかる)
func fetch(id int) string {
time.Sleep(time.Duration(id) * 500 * time.Millisecond)
return fmt.Sprintf("結果%d", id)
}
id は 1〜5 とします。条件は2つ。go run -race . で警告が出ないこと。打ち切った後も、まだ仕事中の goroutine が送信で永遠に詰まらないこと(③の細かい点がヒントです)。
解答を見る
package main
import (
"context"
"fmt"
"time"
)
// idが大きいほど遅いサービス(id × 500ミリ秒かかる)
func fetch(id int) string {
time.Sleep(time.Duration(id) * 500 * time.Millisecond)
return fmt.Sprintf("結果%d", id)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
results := make(chan string, 5)
for i := 1; i <= 5; i++ {
go func() {
results <- fetch(i)
}()
}
got := 0
for got < 5 {
select {
case r := <-results:
fmt.Println(r)
got++
case <-ctx.Done():
fmt.Printf("タイムアウト。%d件で打ち切り: %v\n", got, ctx.Err())
return
}
}
fmt.Println("全件集めた")
}
結果1
結果2
タイムアウト。2件で打ち切り: context deadline exceeded
0.5秒で結果1、1.0秒で結果2が届き、1.2秒の期限が来た時点で打ち切られました。結果3(1.5秒)以降は間に合っていません。go run -race . も警告なしでした。
ポイント
- 受信ループの中身が
selectになっている。1件受け取るたびに「次の結果か、時間切れか」の早い方を待ち直す。③の select を、1回きりではなくループで使う形 -
make(chan string, 5)のバッファ5が打ち切り後の保険。main が先に帰っても、遅かった3つの goroutine は結果を置き逃げして終了できる。バッファなしだと3人が送信で固まり、goroutine リークになる(送信者の人数分のバッファを取るのが定石) - 期限の管理は自前のタイマー計算ではなく context に任せる。#17 で学んだとおり、期限は呼び出しの連鎖全体で共有すべき情報だから
「全部揃うのを待つ」から「期限内に届いた分で進む」への切り替えは、実務の外部API呼び出しでそのまま使う形です。1つの遅いサービスに全体を道連れにさせない、という #17 のタイムアウトの思想を、今日は自分の手で実装できるようになりました。
- goroutine の戻り値は捨てられる。値を届けるには channel を使う。
ch <- vが送信、v := <-chが受信 - バッファなし channel の送受信は手渡しで、相手が揃うまで両者とも待つ。この待ちが完了待ちを兼ねるので、channel は通信と同期の二役をこなす。相手が永遠に来ないと deadlock
- 「もう来ない」は送信側が
closeで伝え、受信側はfor rangeで受ける。送信者が複数ならwg.Wait()してから close する見届け役を立てる。空の closed channel の受信は待たずに返る -
ctx.Done()の正体は受信専用 channel で、キャンセル時に close される。close は全受信者への一斉放送。selectと組み合わせて「結果か打ち切りの早い方」を待つ - worker の人数で同時実行数を絞るのが worker pool。並行化の実務は「速くする」と「相手に優しくする」の両立で、絞り幅は自分で決める
これで Part 3(コードの構造化)は完了です。レイヤードアーキテクチャで層を分け、context で打ち切りを伝え、goroutine と channel で並行に動かす。単体のGoアプリケーションとしては、かなり実務に近い道具が揃いました。
次回は #20 コンテナの本質(namespace / cgroup) です。Part 4 コンテナ編に入ります。#4 からずっと使ってきた pg-practice コンテナ、あれの正体を説明できますか。コンテナは「軽い仮想マシン」ではありません。Linuxの2つの機能で作られた、ただのプロセスです。docker の裏側を自分の手で確かめにいきます。
