Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goの並行処理入門 - select編

はじめに

こんにちは。サーバーサイドエンジニアの佐野(@Kiyo_Karl2)です。

本記事は、4本の連載記事のラストとなります。

対象読者

  • Go言語の基礎はわかっているが、Goroutineについてはよく理解していない
  • メモリ、プロセス、スレッド、並行処理、並列処理といったワードについて概要とその違いを理解している

連載記事を通して取り扱わないこと

本連載記事ではGoroutine、syncパッケージ、channel、select文の基礎にフォーカスを当てています。 以下のような発展的な内容については触れていませんのでご了承くださいm( )m

  • ゴルーチンリークを避ける手法
  • 並行処理のエラーハンドリング
  • チャネルやselect文の発展的な内容
  • ゴルーチンのスケジューリングについて
  • ファンアウト、ファンイン
  • contextパッケージによるゴルーチンのタイムアウトや中断方法

select文

select文は並行処理において非常に重要な文法です。 イメージとしてはswitch文のようなもので、go言語においてselect文は複数のチャネルの送受信操作を一箇所で管理できる「ハブ」のようなものです。 select文は、記述されたcase節のどれかが準備できるまで処理をブロックします。

この機能により、select文は複数のチャネル間での通信を統合し、キャンセル処理、タイムアウト、待機、デフォルト処理などをまとめて行うことができます。

select文は以下のように利用します。

func main() {
    var c1,c2<-chan any
    var c3 chan<-any

    select {
    case <-c1:
    // do something
    case <-c2:
    // do something
    case c3<-struct{}{}:
    // do something
    }
}

switch文のように複数の文でcase節が利用されています。 select文がswitch文と決定的に違うのは、ブロック内のcase節は上から順番に評価されるわけではないという点です。select文は、読み込みと書き込みのチャネルをすべて同時に取り扱います。

読み込みの場合は

  • チャネルに書き込みがあったか?
  • チャネルが閉じられているか?

ということを確認します。

書き込みの場合は

  • チャネルのキャパシティが空いているか?

ということを確認します。

どのチャネルも準備できていない場合は、select文全体がブロックされます。 どれか1つでもチャネルの準備ができていれば、条件に合致するcase節が実行されます。

func main() {
    start := time.Now()
    c := make(chan int)
    go func() {
        time.Sleep(2 * time.Second)
        close(c) //1
    }()

    fmt.Println("Blocking on read...")
    select {
    case <-c: //2
        fmt.Printf("Unblocked %v later.\n", time.Since(start))
    }
}
  1. 2秒後にチャネルを閉じる
  2. チャネルが閉じたのを確認後、チャネルを読み込む(※チャネルはcloseされた後書き込みはできませんが、読み込むことは可)

実行結果は以下です。

Blocking on read...
Unblocked 2.001136292s later.

上記から、selectブロックへ入ってから2秒後にブロックをやめたことがわかります。 逆に言うと2秒間はチャネルを読み込めないため、その間はselect文全体がブロックされます。

複数のチャネルを読み込むとき

上記の例は単純にチャネルを1つだけ読み込む例でしたが、ここで複数のチャネルを同時に読み込ませてみるとどうなるか見てみます。

func main() {
    c1 := make(chan int)
    close(c1)
    c2 := make(chan int)
    close(c2)

    var c1Count, c2Count int
    for i := 0; i < 1000; i++ {
        select {
        case <-c1:
            c1Count++
        case <-c2:
            c2Count++
        }
    }

    fmt.Println("c1Count:", c1Count)
    fmt.Println("c2Count:", c2Count)
}

実行してみます。

c1Count: 517
c2Count: 483

1000回のループで、c1c2はそれぞれ約半分ずつ実行される結果となりました。 select文の特性として、複数のcase節が同時に実行可能な場合(複数のチャネル操作が即座に実行できる状態の場合)、どのcase節を実行するかはGoのランタイムによってランダムに選択されます。

このコードでは、c1c2 はどちらもcloseされており即座に受信可能な状態だったため、それぞれのcase節がGoのランタイムによってランダムに実行されます。

1つもチャネルを読み込めないとき

select文の特徴として、チャネルがひとつも読み込めないときはselect文全体が永遠にブロックされてしまいます。 それでは困るので、タイムアウト処理をさせたいですよね。 そのときは以下のように書けます。

func main() {
    var c1, c2 <-chan int

    select {
    case <-c1:
    case <-c2:
    case <-time.After(2 * time.Second):
        fmt.Println("timeout")
    }
}

上記はnilチャネルを読み込もうとしているため、永久にブロックしつづけます。 c1c2のcase節に入ることはないです.

実行すると以下のようになります。

timeout

time.After関数はtime.Durationを引数に取り、引数で与えた経過時間後に現在時刻を送信するチャネルを返すので、どのチャネルも読み込めなくてタイムアウトさせたいときに便利です。

selectで待機中になにか処理をしたいとき

それでは、チャネルが1つも読み込めない間、タイムアウトではなく何かしらの処理をさせたい場合はどうすればよいでしょうか? そのためにはdefault節を利用できます。

func main() {
    channel1 := make(chan string)

    go func() {
        time.Sleep(5 * time.Second)
        close(channel1)
    }()

    workerCounter := 0
loop:
    for {
        select {
        case <-channel1:
            fmt.Println("channel1を受信")
            break loop
        default:
            workerCounter++
            time.Sleep(1 * time.Second)
        }
    }

    fmt.Println("default:", workerCounter)
}

上記を実行します。

channel1を受信
default: 5

5秒間スリープしている間はチャネルがcloseされないため、case節はブロックされつづけますが、その間workerCounterは1秒ごとにカウントされていたことが上記実行結果からわかります。 default節を利用すれば、ゴルーチンの結果を待機している間select文全体をブロックせずに効率良く処理を進めることができます。

まとめ

ここまで、Goによって提供されている並行処理のための基礎について解説してきました。 これで連載記事としては最後となります。なかなかのボリュームだったかと思いますが、最後まで読んでいただきありがとうございます…!

今回紹介したselect文はswitch文と違い

  • 上から順番にcase節が実行されるわけではなく、チャネルが準備でき次第case節が評価される
  • ブロッキングされている間はdefault節の処理が実行される

などは、コードリーディングする上で知っておかないと正しく実装内容を理解することが難しいのでちゃんと挙動を抑えておくと良いかと思います。

ゴルーチンはマルチスレッドのためスレッド間でメモリを共有するので、バグを発生させやすいです。 基礎を理解せずに適当に実装してしまうとデータ競合などが容易に発生します。

ゴルーチンセーフな実装をするためにも、チャネルやsync.Mutexなどを適切に利用していきましょう!

参考