Go中級 #4 selectとタイムアウト

読了 5分

#3 ゴルーチンとチャネル入門 で単一のチャネルを見たなら — 今回は 複数のチャネルを同時に 扱う道具。select 文です。

select の基本 #

select 基本
select {
case v := <-ch1:
	fmt.Println("ch1:", v)
case v := <-ch2:
	fmt.Println("ch2:", v)
}

複数のチャネル動作のうち — 準備できたものを一つ 選んで実行します。両方が準備できていれば、ランダムに一つ。

switch と形は似ていますが — case が チャネル動作 であるところが違います。どの case も準備されていなければ ブロック されます。

よく使うパターン #

1) タイムアウト #

time.After が一定時間後に値を送るチャネルを返します。

タイムアウト
select {
case v := <-ch:
	fmt.Println("受信:", v)
case <-time.After(2 * time.Second):
	fmt.Println("タイムアウト")
}

ch から2秒以内に値が来なければ — time.After のチャネルが発動してタイムアウト処理。fetch の timeout のような場面に合います。

2) キャンセル信号を聞く #

キャンセルパターン
done := make(chan struct{})

go func() {
	for {
		select {
		case v := <-ch:
			process(v)
		case <-done:
			fmt.Println("終了")
			return
		}
	}
}()

// 終了させたいとき
close(done)

done チャネルが close されると — <-done が即座に zero value を受け取って case が選択されます。closed チャネルの receive が即座に進行する という特性を活用した標準パターンです。

このパターンを #5 context が助けてくれる場面です。

3) ノンブロック送信/受信 — default #

selectdefault case を置くと — すべての case が準備できていないとき 即座に実行。ブロックしません。

ノンブロック
select {
case v := <-ch:
	fmt.Println("受信:", v)
default:
	fmt.Println("何もない")
}

待たずに「あれば受け取り、なければパス」が可能。ポーリングのような場面に合いますが — 乱用すれば CPU を浪費します。普通はチャネルが自動的に同期するほうが効率的です。

ノンブロック送信
select {
case ch <- v:
	fmt.Println("送信")
default:
	fmt.Println("受信者なし — 破棄")
}

キューがいっぱいのとき作業を捨てるようなパターン。

time.Tick — 周期的な作業 #

周期的
ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for {
	select {
	case <-ticker.C:
		fmt.Println("毎秒")
	case <-done:
		return
	}
}

time.NewTicker は一定間隔で時刻を送るチャネルを持つ ticker を返します。cron のような単純な周期作業に合います。

time.Tick もありますが — 終了する方法がなく、リーク可能性があります。常に NewTicker + defer Stop() が安全。

select + for — イベントループ #

上のコードが示すように、for { select { ... } } パターンはとてもよく使われます。永遠に生きながら複数の種類のイベントを処理するゴルーチン。

イベントループ
for {
	select {
	case msg := <-incoming:
		handleMessage(msg)
	case t := <-ticker.C:
		heartbeat(t)
	case <-shutdown:
		fmt.Println("正常終了")
		return
	}
}

3種類のイベント(受信メッセージ、周期信号、終了信号)のうち何が来ても一箇所で処理。サーバコードでよく出会う形です。

チャネル自体を nil に — case の無効化 #

select の特殊な動作の一つ — nil チャネルでの送受信は永遠にブロック します。

nil チャネル活用
var ch chan int    // nil

select {
case v := <-ch:    // 永遠にブロック (nil)
	fmt.Println(v)
case <-done:
	return
}

これを活用して — case を動的にオン・オフ することが可能です。

動的 case
var send chan<- int = nil    // 最初は非アクティブ

for {
	select {
	case v := <-incoming:
		// データが入ってきたら送信チャネルをアクティブ化
		send = outgoing
		buffer = append(buffer, v)
	case send <- buffer[0]:
		// 送ったらバッファから抜いて
		buffer = buffer[1:]
		if len(buffer) == 0 {
			send = nil    // 送るものがなければ再び非アクティブ化
		}
	}
}

やや高度なパターン — ライブラリ内部でたまに見られます。

タイムアウトとデッドライン — 二つの意味 #

似ているように見えますが、異なる二つの概念。

タイムアウト vs デッドライン
// タイムアウト — 今から N 秒
case <-time.After(2 * time.Second):

// デッドライン — ある絶対時点まで
deadline := time.Now().Add(2 * time.Second)
case <-time.After(time.Until(deadline)):

context.Context は両方を表現できます。次の記事で詳しく。

よく出会う落とし穴 #

1) time.After がリークし得る #

time.After は呼び出すたびに新しい timer を作ります。select が他の case に抜けると — timer が満了するまで GC されません

よく出会う落とし穴
for {
	select {
	case v := <-ch:
		// ...
	case <-time.After(time.Second):
		// 毎回新しい timer
	}
}

毎回新しい timer が作られますが、ch に値が頻繁に来れば timer が GC されずに蓄積される可能性があります。頻繁なループでは — time.NewTimer + 明示的な reset が安全です。

reset パターン
timer := time.NewTimer(time.Second)
defer timer.Stop()

for {
	select {
	case v := <-ch:
		if !timer.Stop() {
			<-timer.C
		}
		timer.Reset(time.Second)
		// ...
	case <-timer.C:
		// タイムアウト
	}
}

少し厄介ですね。一般的な場面では — context.WithTimeout のほうがすっきりします。

2) select の case 評価順序 #

case ランダム選択
for {
	select {
	case <-fast:
		// 頻繁に発動
	case <-slow:
		// たまに発動
	}
}

複数の case が同時に準備できているとき — Go はランダムに選択します。fast ばかり選択されたり、slow が飢えないように(飢餓 starvation 防止)。

もし優先順位が必要なら — 別の select を二段階で。

優先順位 select
for {
	// 1段階: 優先順位の高いものから
	select {
	case <-priority:
		handle()
		continue
	default:
	}

	// 2段階: 優先順位の低いもの + 優先順位
	select {
	case <-priority:
		handle()
	case <-other:
		handleOther()
	}
}

よくあるパターンではありませんが、本当に優先順位が必要なときに使います。

実践 — HTTP クライアントのタイムアウト #

HTTP クライアント + タイムアウト
import (
	"net/http"
	"time"
)

client := &http.Client{
	Timeout: 5 * time.Second,
}

resp, err := client.Get("https://example.com")

http.ClientTimeout フィールドが — 内部的に select + context を活用したタイムアウト処理です。select を直接使うのはライブラリの内部のほうが多いですが、どう動作するかを知っておくとそのライブラリが自然に感じられます。

まとめ #

今回の記事で整理した内容:

  • select は複数のチャネルから準備できた case 一つを選択
  • time.After でタイムアウト case
  • close(done) + <-done パターンでキャンセル信号
  • default でノンブロック送受信
  • time.NewTicker + defer Stop() で周期作業
  • for { select { ... } } がイベントループの標準
  • nil チャネルは永遠にブロック — case を動的に無効化
  • time.After のリーク落とし穴 — NewTimer + Reset
  • 複数 case はランダム選択(starvation 防止)

次の記事(#5 context.Context 深掘り)では — Go の標準キャンセル/タイムアウトツール。context がどのように並行コードの骨格になるかを扱います。

X