📚 背景知識(読んでから問題へ)
Day 029のWorker Poolは「1つのjobschannelを複数workerが奪い合いながら処理し、結果を1つのresultschannelに書き込む」という構成でした。実はこれ自体がすでにFan-out(分配)の一種です。今日はこの考え方を明示的に2つの部品に分解し、単独で再利用できる形にします。
- Fan-out(分配): 1つの入力channelを、複数のgoroutine(=複数の出力channel)に分散して処理させること。「1つの仕事の流れを、複数の実行系統に広げる」イメージです。
- Fan-in(集約): 複数のchannelから届く値を、1つのchannelにまとめること。「複数の実行系統の結果を、1つの流れに戻す」イメージです。
Worker Poolでは「複数workerが同じresultschannelに直接書き込む」ことでFan-inを暗黙に行っていましたが、これはworkerの数だけresults<-という書き込み箇所が存在するということでもあります。Fan-out/Fan-inパターンを明示的に分離すると、次のように役割がはっきりします。
outs := fanOut(jobs, numWorkers, process) // 1つのjobsを複数の出力channelに分配
merged := fanIn(outs...) // 複数の出力channelを1つに集約
こう分離する利点は、fanOutとfanInをそれぞれ独立にテスト・再利用できることです。実務では「fanOutの段数を増やす」「fanInの前段にフィルタ処理を挟む」といった変更が、部品が分かれているだけで局所化できます。これはPhase 3後半で扱うPipelineパターン(Day 031予定)の前段階でもあります。
Fan-inの実装で最も重要な注意点は、「複数の送信元がある1つのchannelを、いつcloseするか」です。Day 029で学んだ「closeできるのは送信側、それも全送信元の完了を知っている場所だけ」という原則が、Fan-inではそのまま「sync.WaitGroupで全入力channelの読み切りを待ってから、集約先channelをcloseする」という形で登場します。
📝 問題
商品価格を複数の外部ベンダーAPI(ここではモック関数で模擬)から並行して取得する処理を、Fan-out/Fan-inパターンで実装してください。
要件
- 以下の型を使用すること。
type Job struct { ID int SKU string } type PriceResult struct { JobID int SKU string Price int WorkerID int } fanOut関数を実装すること。シグネチャは次の通り。func fanOut(jobs <-chan Job, numWorkers int, process func(workerID int, job Job) PriceResult) []<-chan PriceResultjobsから受け取ったジョブをnumWorkers個のgoroutineに分配し、それぞれが自分専用の出力channelにprocessの結果を書き込む- 戻り値は
numWorkers個の受信専用channelのスライス - 各goroutineは
jobsがcloseされて空になったら、自分の出力channelをcloseして終了する
fanIn関数を実装すること。シグネチャは次の通り。func fanIn(channels ...<-chan PriceResult) <-chan PriceResult- 可変長の入力channel群から届く値を、1つの出力channelにまとめる
- 全ての入力channelがcloseされ、かつ中身を読み切ったら、出力channelもcloseする
- 出力channelへの書き込みは複数goroutineから行われるため、closeは「全員の完了を検知できる1箇所」でのみ行うこと(Day 029の原則の再利用)
mainで、5件のジョブ(SKU:"A001"〜"A005")を投入し、numWorkers=3でfanOut→fanInを実行し、全結果をIDでソートして表示すること。runFanOutFanIn(jobs []Job, numWorkers int, process func(int, Job) PriceResult) []PriceResultという統合関数を用意し、テストしやすい形にすること。- テーブル駆動テストを書き、次を検証すること。
- ジョブ件数分の結果が過不足なく返る(
JobIDでソートして期待値と一致することを確認) - ジョブが0件のとき、パニックせず空スライスが返る
numWorkersがジョブ件数より多くても正しく全件処理される
- ジョブ件数分の結果が過不足なく返る(
go test -raceでデータ競合が検出されないことを確認すること。
go run . / go test -race -v ./...でそのまま動作する完全なコードとして提示すること。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
fanOutは「1つの入力を複数の出力に分ける」、fanInは「複数の入力を1つの出力にまとめる」という、ちょうど逆方向の処理です。それぞれが「自分の担当範囲のchannelだけをcloseする」という責務の境界をどう引くかを考えてください。fanOutの各goroutineは「自分の出力channel」の所有者、fanInは「集約先の出力channel」の所有者です。
ヒント2 — アプローチ
- fanOut:
numWorkers個のループの中でそれぞれmake(chan PriceResult)した出力channelを作り、go func(out chan<- PriceResult) { defer close(out); for job := range jobs { out <- process(...) } }(out)という形のgoroutineを起動します。全workerが同じjobsをrangeで奪い合うので、Day 029のWorker Poolとほぼ同じ形です。 - fanIn:
sync.WaitGroupをlen(channels)分Addし、各入力channelごとに1つgoroutineを立ててfor v := range c { merged <- v }のあとwg.Done()します。全goroutineの完了を待つ専用goroutineでwg.Wait()してからclose(merged)する構造は、Day 029のresultsのclose処理とまったく同じパターンです。
ヒント3 — コード骨格
func fanOut(jobs <-chan Job, numWorkers int, process func(int, Job) PriceResult) []<-chan PriceResult {
outs := make([]<-chan PriceResult, numWorkers)
for i := 0; i < numWorkers; i++ {
out := make(chan PriceResult)
outs[i] = out
go func(workerID int, out chan<- PriceResult) {
defer close(out)
for job := range jobs {
out <- process(workerID, job)
}
}(i, out)
}
return outs
}
func fanIn(channels ...<-chan PriceResult) <-chan PriceResult {
merged := make(chan PriceResult)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, c := range channels {
go func(c <-chan PriceResult) {
defer wg.Done()
for v := range c {
merged <- v
}
}(c)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
✅ 模範解答
ファイル: main.go
package main
import (
"fmt"
"sort"
"sync"
)
// Job は価格照会対象の商品ジョブ。
type Job struct {
ID int
SKU string
}
// PriceResult はfanOutのworkerが返す価格照会結果。
type PriceResult struct {
JobID int
SKU string
Price int
WorkerID int
}
// fanOut はjobsをnumWorkers個のgoroutineに分配し、それぞれ専用の出力channelへ書き込む。
// 各goroutineはjobsがcloseされて空になったら自分の出力channelをcloseする(自分の出力の所有者は自分自身)。
func fanOut(jobs <-chan Job, numWorkers int, process func(workerID int, job Job) PriceResult) []<-chan PriceResult {
outs := make([]<-chan PriceResult, numWorkers)
for i := 0; i < numWorkers; i++ {
out := make(chan PriceResult)
outs[i] = out
go func(workerID int, out chan<- PriceResult) {
defer close(out)
for job := range jobs {
out <- process(workerID, job)
}
}(i, out)
}
return outs
}
// fanIn は複数の入力channelから届く値を1つの出力channelに集約する。
// closeは「全入力channelの読み切りを検知できる唯一の場所」であるwg.Wait()の直後でのみ行う。
func fanIn(channels ...<-chan PriceResult) <-chan PriceResult {
merged := make(chan PriceResult)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, c := range channels {
go func(c <-chan PriceResult) {
defer wg.Done()
for v := range c {
merged <- v
}
}(c)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
// runFanOutFanIn はjobsをfanOut→fanInで並行処理し、全結果をスライスで返す。
// 結果の順序は並行実行のため保証されない。
func runFanOutFanIn(jobs []Job, numWorkers int, process func(int, Job) PriceResult) []PriceResult {
jobsCh := make(chan Job, len(jobs))
for _, j := range jobs {
jobsCh <- j
}
close(jobsCh)
outs := fanOut(jobsCh, numWorkers, process)
merged := fanIn(outs...)
var results []PriceResult
for r := range merged {
results = append(results, r)
}
return results
}
// checkPrice は外部ベンダーAPIへの価格照会を模擬する(ここではSKUの文字数×100円+workerIDで疑似計算)。
func checkPrice(workerID int, job Job) PriceResult {
price := len(job.SKU)*100 + workerID
return PriceResult{JobID: job.ID, SKU: job.SKU, Price: price, WorkerID: workerID}
}
func main() {
jobs := []Job{
{ID: 1, SKU: "A001"},
{ID: 2, SKU: "A002"},
{ID: 3, SKU: "A003"},
{ID: 4, SKU: "A004"},
{ID: 5, SKU: "A005"},
}
results := runFanOutFanIn(jobs, 3, checkPrice)
sort.Slice(results, func(i, j int) bool { return results[i].JobID < results[j].JobID })
for _, r := range results {
fmt.Printf("job=%d sku=%s price=%d (worker=%d)\n", r.JobID, r.SKU, r.Price, r.WorkerID)
}
}
ファイル: main_test.go
package main
import (
"sort"
"testing"
)
func TestRunFanOutFanIn(t *testing.T) {
tests := []struct {
name string
jobs []Job
numWorkers int
}{
{
name: "5件・3worker",
jobs: []Job{
{ID: 1, SKU: "A001"},
{ID: 2, SKU: "A002"},
{ID: 3, SKU: "A003"},
{ID: 4, SKU: "A004"},
{ID: 5, SKU: "A005"},
},
numWorkers: 3,
},
{
name: "0件",
jobs: nil,
numWorkers: 3,
},
{
name: "worker数がジョブ件数より多い",
jobs: []Job{{ID: 1, SKU: "B001"}},
numWorkers: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := runFanOutFanIn(tt.jobs, tt.numWorkers, checkPrice)
if len(results) != len(tt.jobs) {
t.Fatalf("len(results) = %d, want %d", len(results), len(tt.jobs))
}
sort.Slice(results, func(i, j int) bool { return results[i].JobID < results[j].JobID })
wantJobs := append([]Job(nil), tt.jobs...)
sort.Slice(wantJobs, func(i, j int) bool { return wantJobs[i].ID < wantJobs[j].ID })
for i, r := range results {
if r.JobID != wantJobs[i].ID {
t.Fatalf("results[%d].JobID = %d, want %d", i, r.JobID, wantJobs[i].ID)
}
if r.SKU != wantJobs[i].SKU {
t.Fatalf("results[%d].SKU = %s, want %s", i, r.SKU, wantJobs[i].SKU)
}
}
})
}
}
func TestFanIn_EmptyChannels(t *testing.T) {
merged := fanIn() // channelを1つも渡さない場合、即座にcloseされる必要がある
count := 0
for range merged {
count++
}
if count != 0 {
t.Fatalf("count = %d, want 0", count)
}
}
▶ 実行結果を見る(go build / go vet / go test -race -v / go run で検証済み)
$ go vet ./... && go build ./...
(エラーなし)
$ go test -race -v ./...
=== RUN TestRunFanOutFanIn
=== RUN TestRunFanOutFanIn/5件・3worker
--- PASS: TestRunFanOutFanIn/5件・3worker (0.00s)
=== RUN TestRunFanOutFanIn/0件
--- PASS: TestRunFanOutFanIn/0件 (0.00s)
=== RUN TestRunFanOutFanIn/worker数がジョブ件数より多い
--- PASS: TestRunFanOutFanIn/worker数がジョブ件数より多い (0.00s)
--- PASS: TestRunFanOutFanIn (0.00s)
=== RUN TestFanIn_EmptyChannels
--- PASS: TestFanIn_EmptyChannels (0.00s)
PASS
ok example.com/day030 1.021s
$ go run .
job=1 sku=A001 price=402 (worker=2)
job=2 sku=A002 price=402 (worker=2)
job=3 sku=A003 price=400 (worker=0)
job=4 sku=A004 price=400 (worker=0)
job=5 sku=A005 price=401 (worker=1)
(並行実行のため、どのworkerがどのjobを処理するかは実行のたびに変わりうる。priceはcheckPriceがlen(SKU)*100 + workerIDで決定的に計算するため、担当workerが変われば表示される値も変わる。何度かgo run .を回して確認済み)
(wg.Add(len(channels))をlen(channels)==0のときに呼んでも問題なくwg.Wait()は即座に返るため、fanIn()を引数なしで呼んだ場合もデッドロックせず即closeされることを実機で確認済み)
go.mod・main.go・main_test.goを1モジュール(module example.com/day030)として配置し、go vet / go build / go test -race -v ./... / go run .で実際に検証済みです。
🪜 Step-by-Step 解説
go func(workerID int, out chan<- PriceResult) {
defer close(out)
for job := range jobs {
out <- process(workerID, job)
}
}(i, out)
各workerには自分専用の出力channelがあるため、Day 029のような「誰がcloseするか」の悩みがそもそも発生しません。jobsがcloseされてrangeが終わったら、defer close(out)で自分の出力を閉じるだけです。
go func() {
wg.Wait()
close(merged)
}()
複数の入力channelそれぞれに読み取り用goroutineを立てるため、mergedへの書き込み元は複数存在します。「全員が読み切ったこと」を検知できるのはwg.Wait()が返った瞬間だけなので、そこでのみclose(merged)します。
outs := make([]<-chan PriceResult, numWorkers)
outs[i] = out // out は chan PriceResult 型だが、代入先が <-chan なので自動的に受信専用に変換される
Goでは双方向channel(chan T)は受信専用(<-chan T)・送信専用(chan<- T)へ暗黙に変換できます。fanOutの戻り値を受信専用にしておくことで、呼び出し側が誤ってouts[i] <- ...と書き込んでしまう事故をコンパイル時に防げます。
merged := fanIn(outs...)
fanInはchannels ...<-chan PriceResultという可変長引数を取るため、すでに持っている[]<-chan PriceResult型のスライスは...を付けて展開して渡します。これによりfanOutの出力段数(numWorkers)が何個であってもfanIn側のコードを変える必要がありません。
💡 設計思想・なぜこう書くのか
fanOutとfanInを独立した関数にすると、TestFanIn_EmptyChannelsのように「集約だけ」を単体でテストできます。Goの「小さい部品を組み合わせる」という設計思想が、並行処理のパターンにもそのまま適用されている例です。chan<- T(送信専用)と<-chan T(受信専用)を関数シグネチャで使い分けると、「このchannelに書き込んでいいのは誰か」がコンパイラによって強制されます。今日のfanOutが返す[]<-chan PriceResultは「呼び出し側は読むだけ、closeもしない」という契約を型で表現しています。🌐 他言語との比較
| 観点 | Go(fan-out/fan-in) | Java(CompletableFuture + allOf) | Python(asyncio.gather) | Node.js(Promise.all + worker分散) |
|---|---|---|---|---|
| 分配(fan-out) | 複数goroutineが同じ入力channelをrangeで奪い合う | ExecutorServiceに複数タスクをsubmit | asyncio.create_taskを複数生成 | worker_threadsに複数タスクを振り分け |
| 集約(fan-in) | 複数goroutineが1つの出力channelに書き込み、sync.WaitGroupで完了検知 | CompletableFuture.allOf(futures).join() | await asyncio.gather(*tasks) | await Promise.all(promises) |
| 結果の受け取り方 | 出力channelをrangeでストリーム的に受信(早く終わった結果から順に読める) | allOfは全完了まで待つ一括取得が基本 | gatherは全完了まで待つ一括取得が基本 | Promise.allは全完了まで待つ一括取得が基本 |
| 途中結果のストリーミング | channelなのでネイティブに可能 | CompletableFuture.anyOf等を別途組み合わせる必要あり | asyncio.as_completedで近い効果 | 独自にPromise.raceをループする等の工夫が必要 |
Goのchannelベースのfan-in最大の特徴は、「全部揃うまで待つ一括取得」ではなく「終わったものから順に流れてくるストリーム」として自然に扱える点です。Java/Python/Node.jsのallOf/gather/Promise.allは基本的に「全完了を待ってから配列で受け取る」設計であり、Goのように結果をfor rangeでその場から処理し始める形にするには追加の工夫(as_completed等)が必要です。
🏆 実務での使いどころ
- 複数ベンダーAPIからの並行価格照会: 今日の問題そのものが典型例です。1つの商品IDに対して複数の外部ベンダーへ並行問い合わせを行い、結果を1つのchannelに集約して「最安値を選ぶ」「全ベンダーの結果をログに残す」といった処理につなげられます
- 複数ソースのログ集約: 複数のログファイル・複数のKafkaパーティションなど、異なるソースから届くイベントを1つの処理パイプラインに合流させる場面でfan-inが使われます
- CPU負荷の高い処理をコア数で分散: 画像リサイズや暗号化処理などをfan-outで複数goroutineに分配し、結果をfan-inで集約してから後続処理(DB書き込み等)に渡す構成は頻出パターンです
- Pipelineパターンへの接続: Day 031で扱う予定のPipelineパターンは、今日のfan-out/fan-inを複数のステージに直列でつなげたものとして理解できます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
fanOutの出力channelを呼び出し元で直接closeしてしまう | 「自分が受け取る側だから、使い終わったら閉じるべき」と直感的に考えてしまう | closeは送信側だけの責務。受信側がcloseすると、まだ書き込もうとしているworkerがpanic: send on closed channelを起こす |
fanInでwg.Addを各goroutine起動の直前ではなくgoroutineの中で呼んでしまう | ループ内にまとめて書きたくなる | wg.Addはgoroutine起動前(メインgoroutine側)で呼ばないと、wg.Wait()が先に実行されてカウントが0のまま素通りしてしまう競合が起きる |
fanOutのworker数を増やせば増やすほど速くなると思い込む | 並行数=速度という単純な直感 | CPUバウンドな処理ではGOMAXPROCS(≒CPUコア数)を超えてworkerを増やしてもコンテキストスイッチが増えるだけで速くならない。I/Oバウンド(今回のような外部API呼び出し)ならより多くの並行数が有効な場合が多い |
channels ...<-chan Tのような可変長引数の型を書けずに、[]chan Tで妥協してしまう | 可変長引数と方向付きchannelの組み合わせに慣れていない | Goは...<-chan Tのように可変長引数にも方向付きchannel型をそのまま使える。呼び出し側はfanIn(outs...)とスライスを展開して渡せばよい |
🚀 次のステップ
- 発展: 今日の
fanOutのprocess関数の中で、意図的にpanicを起こすジョブを1件混ぜてみてください。現在の実装ではworkerのgoroutineがpanicでクラッシュし、fanIn側は該当channelが二度とcloseされないため、結果を読み切れず永久にブロックします。recoverを使ってこの状況を防ぐにはprocessの呼び出しをどう変更すればよいか考えてみましょう - 次回予告: Day 031 — 並行処理パターン③(Pipeline)(実装)。今日のfan-out/fan-inをステージとして複数連結し、「生成 → 変換 → フィルタ」のようなストリーム処理パイプラインを構築します