はじめに
Go言語(Golang)は並行処理を簡単に書ける言語として人気がありますが、正しい実装パターンを選ばないと予期せぬバグやデッドロックなどの問題が発生することがあります。
この記事では、あるテスト失敗から学んだ、Go言語でのファイル読み込みと並行処理(goroutine)の関係について、初心者にもわかりやすく解説します。
問題のあるコードと修正済みコードの比較
まずは問題が発生したコードと、それを修正したコードを見てみましょう。
問題のあったコード
// メインgoroutineでファイル読み込み
configData, err := os.ReadFile("config/settings.json")
if err != nil {
fmt.Printf("[config] failed to read config file: %v\n", err)
return
}
// goroutine起動
wg.Add(len(configItems))
for range configItems {
go func() {
defer wg.Done()
// 既に読み込まれたデータを使用
jsonContent := configData
if parsedConfig := parseJSON(string(jsonContent)); parsedConfig != nil {
configChan <- parsedConfig
}
}()
}
修正後のコード
// goroutine起動
wg.Add(len(configItems))
for range configItems {
go func() {
defer wg.Done()
// 各goroutine内でファイル読み込み
configData, err := os.ReadFile("config/settings.json")
if err != nil {
fmt.Printf("[config] failed to read config file: %v\n", err)
return
}
jsonContent := configData
if parsedConfig := parseJSON(string(jsonContent)); parsedConfig != nil {
configChan <- parsedConfig
}
}()
}
どこが違う?初心者にもわかる説明
違い1: 処理の流れ
問題のあったコード:
- メインの処理でファイルを読み込む
- 読み込んだデータを複数のgoroutineで共有して使う
修正後のコード:
- 各goroutineが独立してファイルを読み込む
- 読み込んだデータを各goroutineが個別に処理する
これは車の工場に例えるとわかりやすいでしょう:
問題のあったコード:
- 資材倉庫(メイン処理)が全ての部品を一度に出庫
- 各作業台(goroutine)は同じ在庫から部品を取り出して作業
修正後のコード:
- 各作業台(goroutine)が必要な時に倉庫に直接アクセス
- それぞれが独立して部品を取り出して作業
違い2: エラー処理の範囲
問題のあったコード:
- メイン処理でファイル読み込みに失敗すると、全てのgoroutineが実行されない
- エラーが発生すると全体が停止する「一発アウト方式」
修正後のコード:
- 各goroutineが個別にエラーを処理できる
- 一部のgoroutineでエラーが起きても他は正常に動作する「部分的回復可能方式」
これは学校の試験に例えると:
問題のあったコード:
- 試験用紙を配る先生がいないと、全員が試験を受けられない
修正後のコード:
- 各生徒が自分で試験用紙を取りに行ける。一人が遅れても他の生徒は試験を開始できる
違い3: リソース競合
問題のあったコード:
- 全てのgoroutineが同じメモリ領域(configData)を参照
- 潜在的な競合状態(race condition)の可能性がある
修正後のコード:
- 各goroutineが独自のメモリ領域を使用
- データ競合が発生しにくい
ショッピングモールに例えると:
問題のあったコード:
- 1つのレジに全員が並ぶ方式
修正後のコード:
- 複数のレジが開いていて、それぞれのレジで会計できる方式
なぜ修正後のコードが成功したのか?
修正後のコードが成功した理由は以下の通りです:
-
独立性の確保:
- 各goroutineが他に依存せず独立して処理を行える
- 一つの処理が遅延しても他に影響しない
-
ブロッキングの分散:
- ファイル読み込みのような時間がかかる処理(ブロッキング操作)が分散される
- メイン処理がブロックされず、全体の流れがスムーズになる
-
エラーの局所化:
- 問題が発生しても影響範囲が限定的
- システム全体の頑健性が向上する
デメリットはないの?
もちろん、修正後のパターンにもデメリットはあります:
-
リソース使用量の増加:
- 同じファイルを複数回読み込むため、メモリやディスクI/Oの使用量が増える
- 大量のgoroutineを使う場合はシステムへの負荷が大きくなる可能性がある
-
一貫性の問題:
- ファイルが更新される可能性がある場合、各goroutineが異なるバージョンのデータを読み込む可能性がある
-
実装の複雑さ:
- エラー処理が各goroutineに分散するため、全体のエラーハンドリングが複雑になる場合がある
実際にどんなエラーが発生したのか?
このパターンの違いによって、テスト実行時に以下のような問題が発生していました:
- 問題のあったコードではテスト実行中にタイムアウトが発生
- HTTPテストサーバーの接続受け入れ処理でブロックが発生
- goroutineの処理が完了せず、テストが終了しない状態に
これは、メインgoroutineでのファイル読み込みがなんらかの理由で遅延し、全体の処理に影響を与えたためと考えられます。
Go言語における並行処理のベストプラクティス
この事例から学べる重要なポイントをまとめました:
-
独立性を重視する
- goroutineは可能な限り独立して動作させる
- 共有リソースへの依存を減らす
-
ブロッキング操作に注意する
- ファイルI/OやネットワークI/Oなどの時間がかかる処理は分散させる
- メインgoroutineでのブロッキング操作を避ける
-
適切なエラー処理を行う
- 各goroutineでエラーを適切に処理する
- エラー情報を集約する仕組みを用意する(チャネルなどを使用)
-
テストの安定性を考慮する
- テスト環境では特に、タイムアウト設定やリソースクリーンアップを適切に行う
- race detectorを使ってデータ競合を検出する
go test -race ./...
まとめ
Go言語の並行処理は強力ですが、適切なパターンを選ばないと予期せぬバグやテスト失敗を引き起こします。特にファイル読み込みのような I/O 操作では、処理の独立性を高め、ブロッキング操作の影響範囲を局所化することが重要です。
本記事の事例のように、一見些細な実装の違いが大きな影響を与えることがあります。Go言語での並行処理を設計する際は、これらのパターンと影響を理解し、適切な実装を選択しましょう。
コードの具体的な内容は一例であり、実際のシステムでは要件に応じて適切なパターンを選択してください。状況によっては、共有データを使うパターンが適している場合もあります。大切なのは、それぞれのパターンのメリット・デメリットを理解し、適材適所で使い分けることです。
皆さんのGo言語プログラミングの参考になれば幸いです!