Day 031 — 並行処理パターン③(Pipeline)

2026-08-28 🟡 上級者 / Phase 3 実装 並行処理パターン③(Pipeline)

📚 背景知識(読んでから問題へ)

Pipeline(パイプライン)パターンは、「あるステージの出力channelを次のステージの入力channelにそのまま繋ぐ」ことで、複数の処理段階をストリームとして連結するパターンです。Unixのcmd1 | cmd2 | cmd3のパイプと発想は同じで、各ステージが独立したgoroutineとして動き、channelを通じてデータが流れていきます。

Day 029のWorker Poolが「同じ処理を複数goroutineで並列実行して負荷分散する」パターン、Day 030のFan-in/Fan-outが「1ステージを複数goroutineに分散・集約する」パターンだったのに対し、Pipelineは「異なる処理を複数ステージに分割し、ストリームとして流す」パターンです。Fan-in/Fan-outはPipelineの1ステージを内部で並列化する技法として組み合わせて使われることもあります。

stage3 := filterEven(ctx, square(ctx, generate(ctx, n))) // 3段のステージを直接連結

Pipelineを正しく実装する上で最重要なのが「送信側が必ず自分の出力channelをcloseする」という規律です。Goのchannelは受信側からcloseすることはできず、送信側の責務としてcloseします。これを怠ると、下流のfor v := range chが永久にブロックし、goroutineリークが発生します。

また、上流でキャンセルが発生した際に下流まで正しく伝播させるには、context.Contextをパイプライン全体で共有し、各ステージの送信処理をselectでctx.Done()とも競合させる必要があります。これにより「受信側がもう受け取らない(例: 早期終了した)」ケースでも送信側がchannel送信でブロックしたまま残り続ける、いわゆるgoroutineリークを防げます。

📝 問題

以下の3段パイプラインを実装してください。

  1. generate ステージ: 1からnまでの整数を1つずつ出力channelに送る
  2. square ステージ: 受け取った整数を2乗して出力channelに送る
  3. filterEven ステージ: 受け取った値が偶数であれば出力channelに送り、奇数なら捨てる

各ステージはfunc(ctx context.Context, in <-chan int) <-chan int(generateのみfunc(ctx context.Context, n int) <-chan int)のシグネチャを持ち、内部で新しいgoroutineを起動して即座に出力channelを返す(=呼び出し側をブロックしない)ノンブロッキング関数として実装してください。

さらに、main関数で3ステージを連結し、最終出力をfor v := rangeで受け取ってすべて合計し、標準出力に表示してください。加えて、context.WithCancelを使い、合計値が500を超えた時点でcancel()を呼んで残りのパイプライン処理を打ち切る動作も実装してください(打ち切り後にgoroutineがリークしていないことを意識すること)。

要件

  • 各ステージの送信はselect { case out <- v: case <-ctx.Done(): return }のパターンで、キャンセル時に必ずreturnして出力channelをcloseすること(defer close(out)を使う)
  • n = 100として実行し、合計が500を超えた時点で打ち切られることを確認できるログを出力すること
  • go run main.goでそのまま実行できること

🔍 ヒント(段階的開示)

ヒント1 — 方向性

各ステージ関数は「入力channelを受け取り、出力channelを返す」形にし、関数内部でgoroutineを1つ起動してそのgoroutineが処理と送信を担当します。呼び出し側(main)は返ってきた出力channelを次のステージの入力にそのまま渡すだけでよい設計です。

ヒント2 — アプローチ

defer close(out)を各ステージのgoroutine冒頭に書いておけば、goroutineがどの経路で終了してもchannelが必ずcloseされます。送信部分は次の形が定石です。

select {
case out <- v:
case <-ctx.Done():
    return
}

これにより「下流が受け取らなくなった(mainがcancelした)」場合でも送信側がブロックし続けません。

ヒント3 — コード骨格
func generate(ctx context.Context, n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ {
			select {
			case out <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in {
			select {
			case out <- v * v:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}
// filterEven も同様の形

mainではstage3 := filterEven(ctx, square(ctx, generate(ctx, n)))のように連結し、for v := range stage3で受け取りながら合計を計算し、閾値超えでcancel()します。

✅ 模範解答

ファイル: main.go

package main

import (
	"context"
	"fmt"
)

// generate は 1..n の整数を出力channelに流すパイプラインの起点ステージ。
func generate(ctx context.Context, n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ {
			select {
			case out <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// square は受け取った整数を2乗して次段に流す中間ステージ。
func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in {
			select {
			case out <- v * v:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// filterEven は偶数のみを次段に通す中間ステージ。
func filterEven(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in {
			if v%2 != 0 {
				continue
			}
			select {
			case out <- v:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // main終了時に念のため全ステージへキャンセルを伝播させる

	const n = 100
	pipeline := filterEven(ctx, square(ctx, generate(ctx, n)))

	sum := 0
	for v := range pipeline {
		sum += v
		fmt.Printf("received=%d sum=%d\n", v, sum)
		if sum > 500 {
			fmt.Println("合計が500を超えたためパイプラインを打ち切ります")
			cancel()
			// cancel後もgenerateが送信中の値をfor rangeが消費し続けるため、
			// 全ステージがctx.Done()経由で終了し出力channelがcloseされるまでループを回す。
		}
	}
	fmt.Printf("最終合計: %d\n", sum)
}
▶ 実行結果を見る(go run で検証済み)
$ go run main.go
received=4 sum=4
received=16 sum=20
received=36 sum=56
received=64 sum=120
received=100 sum=220
received=144 sum=364
received=196 sum=560
合計が500を超えたためパイプラインを打ち切ります
最終合計: 560

(実際にgo run main.goで実行し、上記の出力を確認済み。cancel後に残っていた送信中の値を消費し切った後、パイプライン全体が有限回で収束してプログラムが終了することを確認した)

go run main.goでそのまま動作する単一ファイルの完全なコードとして検証済みです。

🪜 Step-by-Step 解説

1
各ステージをノンブロッキング関数として設計する
generate / square / filterEvenはいずれも「呼ばれた瞬間にgoroutineを起動して出力channelを即返す」形にします。これにより呼び出し側(main)は各ステージの内部実装を意識せず、filterEven(ctx, square(ctx, generate(ctx, n)))のように関数合成の感覚で連結できます。
2
受信は for range、送信は select で書き分ける
入力channelからの受信はcloseされれば自動でループが終わるfor v := range inで十分ですが、出力channelへの送信は「下流が受け取ってくれるとは限らない」(cancel済みなら永久に受信されない)ため、必ずctx.Done()と競合させたselectにします。この非対称性がPipeline実装の勘所です。
3
defer close(out) でリーク防止を保証する
goroutineがforループを正常に終えて抜けた場合でも、ctx.Done()で早期returnした場合でも、deferにより出力channelは必ずcloseされます。下流ステージはfor rangeで待っているだけなので、closeされれば自動的にループを抜けて自分自身もcloseに向かいます。この連鎖により、cancel一発でパイプライン全体がきれいに畳まれます。
4
mainでの受信ループとキャンセルの関係を理解する
sum > 500でcancel()を呼んでも、その時点でgenerateが送信しようとしていた値がまだselectの送信側でブロック中の場合があります。cancel()によりそのselectはctx.Done()分岐に落ちてreturnし、close(out)が実行されます。mainのfor range pipelineは、cancel後に届く残り数個の値(既にchannelに乗っていた分)を消費し切った後、最終的にpipelineがcloseされてループを抜けます。この「即座には止まらないが、必ず有限回で収束する」挙動を体感することが本問の狙いの一つです。

💡 設計思想・なぜこう書くのか

📌
「通信することでメモリを共有せよ」の直接的な体現: Goの並行処理設計は「共有メモリを介して通信するな、通信することでメモリを共有せよ(Do not communicate by sharing memory; share memory by communicating)」という思想に基づきます。Pipelineパターンはこの思想を最も直接的に体現したパターンで、ステージ間の依存関係が「channelという型で表現された明示的な契約」になります。ステージ関数のシグネチャが<-chan intという受信専用型を返すことも、「このchannelはもう送信されない、受信専用として扱ってよい」という契約をコンパイラレベルで強制しており、Goの「明示性を型で担保する」設計哲学の表れです。
📌
キャンセルを「普通の値」として扱う設計: defer close(out)とselect + ctx.Done()の組み合わせは、Goでは例外機構がない代わりに「終了・キャンセルを普通のchannel/contextの値として扱う」という設計思想の実践でもあります。特別なキャンセル例外を投げるのではなく、ctx.Done()も他のchannelと同列にselectの1分岐として扱えることが、Goの並行処理プリミティブの一貫性の高さを示しています。

🌐 他言語との比較

言語同等の概念Goとの違い
Javajava.util.stream.Stream / Reactive Streams(RxJava, Project Reactor)Streamは遅延評価の関数チェーンで単一スレッド前提、Reactive Streamsはバックプレッシャー付きだが専用ライブラリと演算子DSLが必要。Goはchannel+goroutineという言語組み込みプリミティブだけで同等のことを素朴に書ける
Pythonasyncio.Queueを使ったコルーチン間パイプライン、またはmultiprocessing.Pipeasyncioはシングルスレッドの協調的並行性でGILの制約下、GoのgoroutineはOSスレッド上でランタイムが多重化する真の並行実行。キャンセル伝播もGoのcontextほど言語標準として統一されていない
TypeScript / Node.jsstream.Transformを繋いだNode.js Streams、またはRxJSのpipe()Node.js Streamsはバックプレッシャーを内蔵しAPIも似ているがシングルスレッドイベントループ上での協調処理。Goはマルチコアを活かした真の並行ステージ実行が可能な点が異なる

🏆 実務での使いどころ

  • ログ処理パイプライン: ログ行を読み込む → パースする → フィルタする → 集計してメトリクス化する、という多段処理を各ステージ独立のgoroutineで動かし、スループットを段階ごとに調整できる
  • 画像/動画処理バッチ: デコード → リサイズ → エンコード → アップロード、のようなCPU/IOバウンドが混在する処理をステージ分割し、IOバウンドなステージだけFan-outで並列数を増やすといった最適化がしやすい
  • ETLジョブ: 抽出(DB/APIから読む)→ 変換(正規化・バリデーション)→ ロード(書き込み)の3段をchannelで繋ぐことで、メモリに全件を溜め込まずストリーミングで処理できる
  • APIゲートウェイのリクエスト処理: 認証 → レート制限チェック → バリデーション → ハンドラ呼び出し、という単一リクエストの処理段階をミドルウェアチェーンとして表現する際、Pipelineの発想(各段が独立しctxのキャンセルが全段に伝播する)が直接活きる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
受信側でclose(in)してしまう「使い終わったchannelは閉じるべき」という直感からcloseは送信側だけの責務。受信側がcloseすると、まだ送信中の別goroutineがclosed channelへの送信でpanicする
selectを使わず単純にout <- vと書くキャンセル不要なシンプルなケースだと勘違いする下流がcancelなどで受信をやめた場合、out <- vは永久にブロックしgoroutineリークになる。ctx.Done()とのselectが必須
defer close(out)を書き忘れる正常系のfor-rangeループの終わりだけで自然にcloseされると思い込むループを抜けた後、明示的にclose(out)を呼ばないと下流のfor rangeが永久にブロックする。deferで書けば早期return経路も含めて確実にcloseされる
cancel()を呼べば全goroutineが即座に停止すると思う例外機構のあるスレッドキャンセルのイメージを持ち込むGoのcancelは「合図を出す」だけで、各goroutineがselectやctx.Err()チェックで能動的に気づいて初めて終了する。既にchannelに乗っているデータは消費され切る
ステージ関数をブロッキングにしてしまう(goroutineを起動せず直接処理してreturn)単純な関数として書きたくなるステージ関数はgoroutineを起動して即座に出力channelを返す設計でなければ、パイプラインとして連結した瞬間に呼び出し側全体がブロックしてしまう

🚀 次のステップ

  • 発展: filterEvenステージをFan-outし、複数goroutineで並列にフィルタ処理させた上でFan-inで1本のchannelに集約する「Pipeline + Fan-out/Fan-in」のハイブリッド構成を実装してみましょう
  • 次回予告: errgroup パッケージ — 複数goroutineのエラーを集約し、最初のエラーで全体をキャンセルする仕組みを学びます(Pipelineの各ステージでエラーが発生した場合の伝播をより堅牢に書けるようになります)

🎯 自己評価

自分の回答

気づき・メモ