🐡

Go言語チャネルの使い方と落とし穴:実践パターン&バッドプラクティス集

に公開

はじめに

チャネルは「Goroutine間の安全な通信路」であり、「明示的な同期を意図して使うツール」です。

ゴルーチンは、go言語で並列実行する軽量な関数のことです。

チャネルが使われるユースケース

質問 YESならチャネル
Goroutine間で イベント通知 したいか?
処理を 非同期化 して、結果を集めたいか?
明示的に 順序や同期制御 が必要か?
値の送信先が 複数 or 不特定 か?
処理の終わりを 待ちたい/通知したい か?

チャネルを使うべきではないユースケース

ケース 代替手段
共有変数の排他制御 sync.Mutexsync.Atomic
状態管理・ストレージ 構造体+ミューテックス
長期保存や再利用が必要なデータ DBやキューなどの永続層
オブジェクト指向的な依存関係管理 構造体の埋め込み・インターフェース設計

チャネルの基本

以下にソースコードがまとまっています
https://github.com/GitEngHar/learnecho/tree/master/channel

チャネルの作成

整数を送受信するためのチャネルを作成する

ch := make(chan int)

チャネルの送信

valueという値をチャネル ch に送信する

ch <- value

データの受信

チャネルからのデータの受信。受信したデータは変数に格納される

value := <-ch

ブロッキングの特性

  • 基本

    • 送信者は受信者がデータを受け取るまで待機する
    • 受信者は送信者からデータが来るまで待機する
    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func main() {
    	messageChannel := make(chan string)
    	var message = "hello"
    	go func() {
    		time.Sleep(2 * time.Second)
    		messageChannel <- "hello channel from go routine"
    	}()
    	message = <-messageChannel
    	fmt.Println(message)
    }
    
    
    • バッファがない、容量MAXのチャネルへの送信はブロックされる

      package main
      
      import (
      	"fmt"
      	"time"
      )
      
      func main() {
      	ch := make(chan int, 2) // バッファサイズが2のチャネルを作成
      
      	// ゴルーチンでメッセージを送信
      	go func() {
      		for i := 0; i < 5; i++ {
      			fmt.Printf("Sending: %d\n", i)
      			ch <- i // ここで送信
      		}
      	}()
      
      	// すべてのメッセージを受信する
      	time.Sleep(1 * time.Second) // 1秒待ってから受信(チャネルがブロックできるのを避けるため)
      	close(ch) // チャネルを閉じる
      	for msg := range ch {
      		fmt.Printf("Received: %d\n", msg)
      	}
      }
      
    • chに何もデータがない状態で受信を試すと、待機し続け、ブロックされる

      package main
      
      import (
      	"fmt"
      	"time"
      )
      
      func main() {
      	ch := make(chan int) // 空のチャネルを作成
      
      	// ゴルーチンを使って受信
      	go func() {
      		time.Sleep(2 * time.Second) // 2秒待機
      		ch <- 42 // データを送信
      	}()
      
      	fmt.Println("Waiting for data...")
      	value := <-ch // ここで受信、チャネルにデータがないとブロックされる
      	fmt.Printf("Received: %d\n", value)
      }
      

チャネルのベストプラクティス

  1. チャネルのバッファを指定する
    1. 指定したバッファ以上をリクエストする場合、バッファが確保できてからリクエストをチャネルに格納することができる
  2. チャネルを閉じること
    1. タスクを実行するゴルーチンを正常に終了することができる
  3. ゴルーチンの処理が全て終了することを保証する
    1. 一部のルーチンが完了していないのに、システムが終了する事態を阻止・検知する
    2. 完了したことを保証して、リソースを解放できるので無駄なリソースが残らない
package main

import (
	"fmt"
	"sync"
	"time"
)

// ワーカーの起動数
const MaxConcurrentWorkers = 3

func main() {
	requests := make(chan int, 5) // Good 1: 5つのバッファを持ったチャネルを生成
	var wg sync.WaitGroup         // WaitGroupを作成

	// 一定数のワーカーを開始
	for i := 0; i < MaxConcurrentWorkers; i++ {
		wg.Add(1) // ワーカーを追加する
		go worker(requests, &wg)
	}

	// リクエストを送信
	for i := 0; i < 10; i++ {
		requests <- i
	}
	close(requests) // チャネルを閉じる

	wg.Wait() // 全てのゴルーチンの終了を待つ
	fmt.Println("All requests processed.")
}

func worker(requests chan int, wg *sync.WaitGroup) {
	defer wg.Done() // 終了時に減算する
	for req := range requests {
		process(req)
	}
}

func process(req int) {
	time.Sleep(1 * time.Second) // 処理を模擬
	fmt.Printf("Processed request: %d\n", req)
}

チャネルのバッドプラクティス

❗ 基本ベストプラクティスの逆です
❗ コメントに色々書いてあります
❗ バッファなしチャネルを大量のゴルーチンと一緒に使うと、送受信のタイミング次第でデッドロックやCPUスパイクの原因になります。

package main

import (
	"fmt"
	"time"
)

func main() {
	requests := make(chan int) // バッファなしのチャネルを作成

	// 無限にゴルーチンを生成
	for i := 0; i < 10; i++ {
		go func(id int) {
			for {
				req := <-requests // 空のチャネルからデータを受信(ブロッキング)
				process(req)
			}
		}(i)
	}

	// リクエストを送信
	for i := 0; i < 20; i++ {
		requests <- i // この行でプロセスが多すぎてメモリを消費するかもしれない
	}

	time.Sleep(5 * time.Second) // 一時的にプログラムが終了しないように待機
	fmt.Println("Finished.")
}

func process(req int) {
	time.Sleep(1 * time.Second) // 処理を模擬
	fmt.Printf("Processed request: %d\n", req)
}

まとめ

Goのチャネルは、Goroutine間の通信・同期に強力なツールですが、すべてのケースで万能ではありません。適切なユースケースを見極め、sync パッケージやキュー、DBなど他の手段と組み合わせて使うことで、より堅牢なGoアプリケーションを構築できます。

Discussion