Go言語のselect文を使いこなす!複数チャネル/タイムアウト制御の基礎

2025年4月18日金曜日

Go言語

Go言語のselect文、並行処理を書くときに出てくるけど、いまいち掴みどころがない…なんて思っていませんか? 

この記事では、Go言語のパワフルな機能である`select`文について、基本的な仕組みから、複数のチャネルを華麗にさばく方法、タイムアウトの設定、さらにはハマりやすい落とし穴まで解説していきます。

読み終わるころには、`select`文と友達になれて、Goの並行処理コードを書くのがもっと楽しくなっているはず! さあ、一緒に見ていきましょう!

この記事で学べること

  • `select`文が何をするものなのか
  • `select`文の基本的な書き方
  • 複数のチャネルからデータを受け取る方法
  • 特定のチャネルにデータを送る方法
  • 処理を止めずにチャネル操作する`default`の使い方
  • 処理に時間制限をつけるタイムアウトの方法
  • `select`文を使うときに気をつけること

Go言語のselect文とは?並行処理の「交通整理役」

Go言語といえば、並行処理、つまり同時にたくさんの処理をこなすのが得意ですよね。その並行処理をスムーズに進めるために活躍するのが`select`文なんです。

たくさんのゴルーチン(Go言語版の軽いスレッドみたいなもの)が、チャネルっていうデータの通り道を使ってやり取りするとき、どのチャネルの準備ができたかを見張って、最初に準備ができたチャネルの処理を実行してくれる、いわば並行処理界の交通整理役みたいな存在です。

もし`select`文がなかったら、どのチャネルが使える状態か一つ一つチェックしないといけなくて、コードがごちゃごちゃしたり、うっかり処理が止まっちゃったり(デッドロックって言います)するかもしれません。

`select`文は、そんな面倒ごとから私たちを救ってくれるヒーローなのです。

// イメージ
複数のチャネル(道路) ---> | select文 | ---> 最初に準備完了した
チャネル1 (道路1)          | (交通整理員) |     チャネルの処理を実行
チャネル2 (道路2)          |            |
チャネル3 (道路3)          |            |
...                       ---> |            | --->

Go言語のselect文の基本的な書き方

`select`文の基本的な形はこんな感じです。

select {
case <-チャネル1:
    // チャネル1から受信したときの処理
case データ := <-チャネル2:
    // チャネル2から受信して、データを使う処理
case チャネル3 <- 送るデータ:
    // チャネル3へデータを送信したときの処理
default:
    // どのcaseも準備できていないときの処理 (省略可能)
}

見た目は`switch`文に似ていますね。

  • select キーワードで始まります。
  • case の後に、チャネルの受信操作 (<-チャネル) か送信操作 (チャネル <- データ) を書きます。
  • それぞれのcaseブロックには、そのチャネル操作が成功したときに実行したい処理を書きます。
  • もし、複数のcaseのチャネルが同時に準備OKになった場合は、どれか一つがランダムに選ばれて実行されます。全部実行されるわけではないので注意です!
  • default は、どのcaseもすぐに実行できないときに実行されます。これは無くてもOKです。

Go言語のselect文の使い方

複数チャネルからの受信

一番よく使われるのが、複数のチャネルの中から、最初にデータを受信できるようになったチャネルを待つ、という使い方です。

例えば、チャネルAとチャネルBがあって、どちらから先にデータが送られてくるか分からない…という状況で、`select`文を使うと、先にデータが来た方の処理を実行できます。早い者勝ちってことですね!

実際にコードを見てみましょう。2つのチャネルを用意して、別々のゴルーチンから、ちょっと時間をずらしてデータを送ってみます。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    // ゴルーチン1: 1秒後にch1へ送信
    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "データ1が来たよ!"
    }()

    // ゴルーチン2: 2秒後にch2へ送信
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "データ2が来たよ!"
    }()

    fmt.Println("どっちのチャネルが先に来るかな…?")

    // selectで待機
    select {
    case msg1 := <-ch1:
        fmt.Println("ch1から受信:", msg1)
    case msg2 := <-ch2:
        fmt.Println("ch2から受信:", msg2)
    }

    fmt.Println("処理が終わったよ。")
}

実行結果は、おそらくこうなります(ch1の方が早くデータが来るので)。

どっちのチャネルが先に来るかな…?
ch1から受信: データ1が来たよ!
処理が終わったよ。

もし、ゴルーチン1と2の`time.Sleep`の時間を逆にすれば、ch2から受信する結果になります。場合によっては、タイミング次第でどちらが実行されるか変わることもある、という点は覚えておいてくださいね。

このサンプルコードが何をしているか、もう少し詳しく見てみましょう。

  1. make(chan string) で、文字列を送受信できるチャネルch1ch2を作っています。
  2. go func() { ... }() で、2つのゴルーチンを起動しています。ゴルーチンはメインの処理とは別に、裏側で動いてくれる働き者です。
  3. 1つ目のゴルーチンは、1秒待ってからch1に文字列データを送信します (ch1 <- "データ1が来たよ!")。
  4. 2つ目のゴルーチンは、2秒待ってからch2に文字列データを送信します (ch2 <- "データ2が来たよ!")。
  5. メインの処理では、select文がch1からの受信とch2からの受信を同時に待ち構えます
  6. 今回はch1の方が早く送信されるので、select文はcase msg1 := <-ch1:のブロックを実行します。受信したデータはmsg1に入ります。
  7. select文はどれか一つのcaseを実行したら終了するので、ch2からの受信を待たずに次の処理に進みます。

このように、Goroutineとチャネルが連携して非同期にデータを送り、`select`がそれを受け取る準備ができるのを待つ、というのが基本的な流れです。

チャネルへの送信

受信だけでなく、複数のチャネルの中から送信できるチャネルを選ぶ、という使い方もできます。

例えば、チャネルXとチャネルYがあって、どちらかのチャネルがデータを受け取れる状態になったら、そっちにデータを送りたい、という場合に役立ちます。

select {
case chX <- データ:
    fmt.Println("チャネルXに送信成功!")
case chY <- データ:
    fmt.Println("チャネルYに送信成功!")
}

どのcaseが実行されるかは、相手側のチャネルがデータを受け取れる状態 (<- chX のような受信待ちの状態) になっているかどうかで決まります。

ノンブロッキング操作 (default)

`select`文には、特別なdefaultというケースを追加できます。

select {
case msg := <-ch:
    fmt.Println("受信:", msg)
default:
    fmt.Println("今は受信できるデータがないみたい。")
    // 他の処理をするなど
}

defaultケースがあると、select文は他のどのcaseのチャネル操作もすぐに実行できない場合に、defaultブロックを実行します。そして、待たずにすぐに次の処理に進みます。これをノンブロッキング操作といいます。

もしdefaultがなくて、どのチャネルも準備ができていなかったら、準備ができるまで`select`文はずーっと待ち続けます(ブロックするといいます)。

defaultは、例えばこんな時に便利です。

  • チャネルにデータが来ていれば処理したいけど、来ていなければ別の作業を進めたいとき (ポーリング)
  • メインの処理の合間に、もしチャネルに何か来てたら対応したいとき

`default` を使うと処理が止まらなくなるので、プログラム全体の応答性を良くしたい場合に便利です。

タイムアウト処理 (time.After)

チャネル操作をするとき、「一定時間待ってもデータが来なかったら、諦めて別の処理をしたい」なんて思うこと、ありますよね。そんなときに使えるのが、`select`文と`time`パッケージの合わせ技です。

time.After(時間) という関数を使うと、指定した時間が経過した後に現在時刻を送信してくる特殊なチャネルを作れます。これを`select`文の`case`の一つとして使うのです。

select {
case res := <-処理結果チャネル:
    fmt.Println("処理完了:", res)
case <-time.After(5 * time.Second): // 5秒待つ
    fmt.Println("時間切れ!タイムアウトしました。")
}

このコードでは、

  1. 処理結果チャネルからデータを受信できるか
  2. 5秒経過するか

のどちらか早い方が起こるのを待ちます。もし5秒以内に処理結果チャネルからデータが来れば、その処理が実行されます。もし5秒経ってもデータが来なければ、time.Afterのチャネルが受信可能になり、タイムアウト処理が実行される、という仕組みです。

これで、一定時間待ってダメなら諦める処理が書けるようになります。ネットワーク通信とか、時間のかかる可能性のある処理を扱うときに非常に役立ちますね。

タイムアウトする例を見てみましょう。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string, 1) // バッファを1に

    // わざと時間のかかる処理(今回は何もしない)
    go func() {
        // time.Sleep(3 * time.Second) // 3秒かかる想定
        // ch <- "処理終わったよ!"      // でも結果を返さない
        fmt.Println("ゴルーチンは仕事したフリ...")
    }()

    fmt.Println("処理結果を待つよ... (2秒以内に来ないとタイムアウト)")

    select {
    case res := <-ch:
        fmt.Println("結果受信:", res)
    case <-time.After(2 * time.Second): // 2秒でタイムアウト
        fmt.Println("タイムアウト!もう待てないよ!")
    }

    fmt.Println("メイン処理終了。")
}

実行結果

処理結果を待つよ... (2秒以内に来ないとタイムアウト)
ゴルーチンは仕事したフリ...
タイムアウト!もう待てないよ!
メイン処理終了。

今度は、タイムアウトする前に処理が終わる例です。上記のコードのコメントアウト部分を有効にしてみましょう。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string, 1)

    go func() {
        time.Sleep(1 * time.Second) // 1秒で終わる!
        ch <- "処理終わったよ!"      // 結果を返す
        fmt.Println("ゴルーチンは仕事した!")
    }()

    fmt.Println("処理結果を待つよ... (2秒以内に来ないとタイムアウト)")

    select {
    case res := <-ch:
        fmt.Println("結果受信:", res)
    case <-time.After(2 * time.Second): // 2秒でタイムアウト
        fmt.Println("タイムアウト!もう待てないよ!")
    }

    fmt.Println("メイン処理終了。")
}

実行結果

処理結果を待つよ... (2秒以内に来ないとタイムアウト)
ゴルーチンは仕事した!
結果受信: 処理終わったよ!
メイン処理終了。

タイムアウトの仕組みは、select文が複数の可能性を同時に待てる、という性質を利用しています。

  1. time.After(設定時間) は、指定時間が経つと時刻データを送ってくる特別な読み取り専用チャネルを返します。これを「タイマーチャネル」と呼ぶことにしましょう。
  2. select文は、通常の作業用チャネル(例:ch)からの受信と、このタイマーチャネルからの受信を同時に待ちます。
  3. もし設定時間内に作業用チャネルからデータが届けば、そちらのcaseが実行されます。めでたしめでたし。
  4. もし設定時間内に作業用チャネルからデータが来なければ、タイマーチャネルの方が先に受信可能になります。すると、タイマーチャネルを待っていたcaseが実行され、タイムアウトしたことが分かります。

`time.After` が時間切れチャネルを用意してくれるおかげで、`select`文だけで簡単にタイムアウト処理が書ける、というわけですね。

Go言語のselect文を使う上での注意点

`select`文は便利ですが、いくつか気をつけておきたい点があります。これを知らないと、予期せぬ動きに悩まされるかも…

nilチャネルに注意!

初期化されていない(nilの)チャネルに対する送受信操作は、永遠にブロックします。つまり、casenilチャネルを書くと、そのcase絶対に選ばれなくなります

もしselect文の全てのcasenilチャネルの操作で、かつdefaultもなければ、プログラムはそこで永久に停止(デッドロック)してしまいます!意図しないnilチャネルを作らないように気をつけましょう。

デッドロックの可能性

前述のnilチャネルのケースもそうですが、defaultケースがなく、かつ全てのcaseのチャネル操作が(相手がいなくて)実行できない状態になると、`select`文は永遠に待ち続けてデッドロックとなります。

例えば、誰も送信してくれないチャネルを受信待ちするselectしかない、といった状況です。

forループとの組み合わせ

forループの中で`select`を使うこともよくあります。

このとき、ループを抜ける条件をちゃんと考える必要があります。例えば、特定のチャネルが閉じられたらループを終える、などの処理を入れないと、無限ループになってしまうかもしれません。

チャネルが閉じられたかどうかは、受信操作の2つ目の戻り値で判定できます (val, ok := <-chokfalse なら閉じられている)。

【まとめ】Go言語 selectを使いこなして並行処理をレベルアップ!

いやー、`select`文、なかなか奥が深いけど面白いヤツでしたね!

最後に、今回学んだことのポイントをおさらいしておきましょう。

  • `select`文は、複数のチャネル操作の中から最初に準備できたものを実行する「交通整理役」。
  • 基本的な書き方はselect { case 操作1: ... case 操作2: ... }
  • 複数チャネルからの受信や、送信可能なチャネルへの送信に使える。
  • defaultケースで、待たずに進むノンブロッキング操作ができる。
  • time.Afterと組み合わせて、タイムアウト処理を実装できる。
  • nilチャネルやデッドロックには注意が必要!

`select`文をマスターすれば、Go言語の得意な並行処理を、より柔軟に、より安全に書けるようになります。ゴルーチンとチャネルを使ったコードが、もっとパワフルになりますよ!

さあ、どんどん`select`文を使って、Goプログラミングを楽しんでいきましょう!

【関連記事】 Go言語とは?特徴・できること・将来性

このブログを検索

  • ()

自己紹介

自分の写真
リモートワークでエンジニア兼Webディレクターとして活動しています。プログラミングやAIなど、日々の業務や学びの中で得た知識や気づきをわかりやすく発信し、これからITスキルを身につけたい人にも役立つ情報をお届けします。 note → https://note.com/yurufuri X → https://x.com/mnao111

QooQ