Contents
Go言語で並行処理を始める前に
Go言語の並行処理技術は、現代のアプリケーション開発において重要なスキルです。特にgoroutineとchannelという仕組みにより、複数のタスクを効率的に扱えるのが特徴です。しかし「並行処理」と「並列処理」は混同されがちで、それぞれ異なる概念です。このセクションでは、Go言語がなぜ並行処理に適しているのか、そして基本的な仕組みを整理します。
並行処理の必要性とGoの特徴
並行処理は、複数のタスクを同時に実行する技術であり、特にI/O待ちや計算リソースの非効率利用を解消します。Go言語では、軽量なgoroutineと安全なデータ共有が可能なchannelという仕組みで、並行処理を簡単に実装できます。
一方で並列処理は、複数のCPUコアで同時に処理を行う技術です。これらを混同すると設計ミスにつながるため、区別することが重要です。
並行処理とは?
- 定義: 複数のタスクを「実行順序」に従って効率的に切り替える技術
- 目的: 非ブロッキングI/O対応・リソース最適化
- 特徴:
- タスクスイッチングが軽量で高速
- データ共有はchannel経由で安全
Go言語の特徴
- goroutine: 軽量な協調的マルチタスク
- channel: 安全なデータ共有機構
- 設計哲学: 「軽量なタスク管理」と「データ共有」の両立を目指す
goroutineの仕組みと基本構文
goroutineはGo言語特有の並行処理単位で、関数呼び出しと同じように簡単に起動できます。しかし、その背後には「協調的マルチタスク」が働いています。
関数呼び出しで簡単に起動できる軽量な並行処理
Go言語ではgo keywordを使ってgoroutineを開始します。例えば以下のコードは、2つのgoroutineを同時に実行します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package main import "fmt" func sayHello() { fmt.Println("こんにちは") } func sayBye() { fmt.Println("さようなら") } func main() { go sayHello() go sayBye() } |
このコードでは、sayHello()とsayBye()が並行して実行されます。ただし、main関数の終了時にgoroutineが未完了でもプログラムは終了するため、sync.WaitGroupなどの同期手段が必要です。
goroutineのライフサイクル管理
| ステップ | 説明 |
|---|---|
| 1. 実行開始 | go keywordで関数呼び出し |
| 2. 処理実行 | 単独のスタックとメモリ領域を持つ |
| 3. 終了判定 | 関数が正常終了するか、戻り値がない場合に自動終了 |
注意点:goroutineが長時間走り続ける場合、「リーク」してしまう可能性があります。後述のベストプラクティスで詳しく解説します。
channelによる安全なデータ共有
goroutine間でデータを送信する際、channelを使うと競合状態や不正アクセスを防げます。これはGo言語の最大の特徴とも言えます。
チャネルの宣言と基本操作
チャネルはchanキーワードで宣言し、送受信には<-演算子を使います。以下が基本的な例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package main import "fmt" func main() { ch := make(chan string) go func() { ch <- "メッセージ" // チャネルにデータを送信 }() msg := <-ch // チャネルからデータを受信 fmt.Println(msg) } |
このコードでは、goroutineがチャネルに文字列を送信し、main関数側でそれを受け取って表示しています。
バッファード/ノンバッファードチャネルの違い
| 種類 | 特徴 |
|---|---|
| バッファード | 一定量のデータを一時的に保存可能(make(chan int, 10)) |
| ノンバッファード | データが即座に送信される。受信側がないとブロックされる |
ベストプラクティス:同期が必要な場合、ノンバッファードチャネルを、I/O待ちなど非同期処理ではバッファードチャネルを使うと効率的です。
selectステートメントによる複数チャンネル処理
複数のチャネルを同時に監視するにはselectステートメントが有効です。これは、ネットワーク通信やタイムアウト処理などで活用されます。
複数チャネルの同時監視機能
以下はselectを使う例です。どちらかのチャネルからデータが送信されると実行されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
package main import "fmt" func main() { ch1 := make(chan string) ch2 := make(chan string) go func() { ch1 <- "ch1 from goroutine" }() go func() { ch2 <- "ch2 from goroutine" }() select { case msg := <-ch1: fmt.Println("ch1:", msg) case msg := <-ch2: fmt.Println("ch2:", msg) } } |
このコードでは、どちらか一方のチャネルからデータが送信された瞬間に処理が実行されます。
defaultケースの活用法
selectにはdefault句を追加して「いずれもブロックしない」状態でも処理できるようにします。例えばタイムアウトを設定する場合に使われます。
|
1 2 3 4 5 6 7 |
select { case msg := <-ch: fmt.Println(msg) default: fmt.Println("チャネルが空です") } |
非ブロッキングの重要性:
defaultを使うことで、処理が待たずに進めるように設計できます。
並行処理のベストプラクティス
goroutineやchannelの使用にはいくつかの注意点があります。特に「リーク」や「チャンネルクローズ」はミスを起こしやすいポイントです。
goroutineリークの回避方法
- 理由: goroutineが終了せずに残るとリソースを浪費します。
- 対策:
sync.WaitGroupでgoroutineの終了を待つ- コンテキスト(context)を使ってキャンセルする
適切なチャンネルクローズのタイミング
| 情報 | 解説 |
|---|---|
| 送信側がクローズ | データがなくなったときにclose()を呼び出す |
| 受信側はブロック解除 | for range chで安全に受信可能 |
| クローズのタイミング | 1. データ送信終了後 2. 受信側が処理終了した時点でクローズ |
テストの重要性: 並行処理はデバッグが難しいため、ユニットテストやプロファイリングツール(例: pprof)を使うと効率的です。
練習問題で理解を深める
ここまでの解説をもとに、実際にコードを書いてみましょう。以下の問題に挑戦することで、並行処理の仕組みがより実感できます。
基礎レベルのコード完成課題
以下は不完全なコードです。chでデータを受け取るように修正してください。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package main import "fmt" func main() { ch := make(chan int) go func() { ch <- 42 }() // TODO: チャネルから値を受信して出力するコードを追加 fmt.Println(<-ch) } |
ヒント:
<-chを使ってください。main関数の終了を待つため、time.Sleep()やsync.WaitGroupも考慮してください。
チャネル同期を使った応用問題
次のコードは、2つのgoroutineでカウントアップを行うものです。結果として**「1, 2」の順に表示されるように修正してください。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package main import "fmt" func main() { ch := make(chan int) go func() { fmt.Println(1) ch <- 0 }() go func() { <-ch fmt.Println(2) }() } |
ヒント: シーケンシャルに実行するには、
chで同期を取る必要があります。sync.WaitGroupやtime.Sleep()でgoroutine終了を待つことも検討してください。
高度な課題: チャネルクローズの制御
以下のコードは、チャネルが正しくクローズされているか確認するものです。close(ch)を適切に配置し、リークを防ぐように修正してください。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package main import "fmt" func worker(ch chan int) { ch <- 100 // TODO: 適切なタイミングでチャネルをクローズ } func main() { ch := make(chan int) go worker(ch) fmt.Println(<-ch) // TODO: チャネルがクローズされているか確認 } |
ポイント:
worker関数内で送信終了後にclose(ch)を実行- main関数で
for range chを使って受信し、クローズを検知
補足: チャネルクローズのベストプラクティス
| ケース | 推奨処理 | 理由 |
|---|---|---|
| 送信終了時 | close(ch) |
受信側がブロックを解除するため |
| 受信完了時 | なし | チャネルクローズは送信側の責任 |
| 複数送信者がある場合 | 1人だけがクローズ | 多重クローズによるエラー回避 |
注意: 受信終了後、チャネルをクローズする必要はありません。送信終了時にのみクローズしてください。