🐝

GoroutineとChannel

2021/07/17に公開

最初に

ゴルーチン(goroutine) 使ってますか? あまり使わない、使い所がわからない、使い方がわからないなど様々な意見がありそうです。今回はGoの最大の?特徴でもあるゴルーチン(goroutine)チャネル(channel) を紹介してみます。
Goの並行処理(goroutine)を使う場面でよく見かけるchannelという単語を見かけると思います。
Go言語の中でgoroutineを利用する以外の場面ではあまり使われないですがgoroutineの機能を利用する上では重要な役割を果たしている機能です。

Goroutineとは

まず、ゴルーチン(goroutine) って何なのか。
ゴルーチンはGoプログラムの最も基本的な構成単位です。なぜならGoのプログラムで main関数で実行される処理がゴルーチン(goroutine)だからです。それをメインゴルーチンと呼びます。Goroutine は Goランタイムによって管理される 軽量な並行処理スレッド(コルーチン) です。通常の コルーチン)(co-routine)とは異なり開発者が処理の操作・制御を行う事はできません。というより制御する必要がないという方がいいかもしれません。
スレッド数やメモリアクセスの管理など複雑な作業はランタイムが管理するため、開発者側は実装に注力できるということになります。
Goroutineについては、「Go言語による並行処理」を読まれると理解が深まると思います。

https://www.oreilly.co.jp/books/9784873118468/

実装する

goroutineの定義方法は簡単です。
つまり並行処理スレッドの生成コストは小さく、かつ容易に実装できるようにGo言語がそれを提供しています。

// 関数の前に go キーワードを追加する
go f(x,y,z)

// 即時関数でも定義できます
go func(x,y,z int){
    return x + y + z
}(p1,p2,p3)

ゴルーチンの実装例

// 
func output(s string, wg *sync.WaitGroup) {
    // WaitGroupを終了させるコード
    defer wg.Done()

    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("%d回目の %s\n", i, s)
    }
}

// 通常の関数
func outputNormal(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("通常:%d回目の %s\n", i, s)
    }
}

func main() {
    // goroutineより、main関数が終了するより時間がかかるため
    // 処理まちをさせるために、syncパッケージのWaitGroupを利用します
    var wg sync.WaitGroup
    wg.Add(1)

    // goroutineを実際にはこんな感じで実装します
    go output("Hello", &wg)

    // 普通の処理
    outputNormal("World")

    // メイン関数の処理がsay関数より早く終了するために
    // WaitGroupでgoroutineの待ち合わせをします
    wg.Wait()
}

output:

通常:0回目の World
GR: 0回目の Hello
GR: 1回目の Hello
通常:1回目の World
通常:2回目の World
GR: 2回目の Hello
GR: 3回目の Hello
通常:3回目の World
通常:4回目の World
GR: 4回目の Hello

通常処理とgoroutineでの処理が平行に処理されているような出力になりました。

Go Playgroundで確認する

処理の概要図

メイン処理から分離・分岐し、平行で実行されて再びメイン処理に合流するイメージです。

Channelとは

Channel は「チャネル」と呼びます。「チャンネル」でもいいのかな 🤔

Go by Example: Channels には↓のように書かれています。

Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values into another goroutine.
(チャネルは、同時実行するゴルーチンを接続するパイプです。あるゴルーチンからチャネルに値を送信し、それらの値を別のゴルーチンに受け取ることができます。)

Channelの概要図

同時実行されているgoroutine間でデータの送受信ができる機能です。

Channelの実装

シンタックス:

// 通信データがintの場合
var ch chan int

チャネルをmake関数で定義する場合

シンタックス:

// 通信データがintの場合
ch := make(chan int)

// Hogeというstructの場合
ch := make(chan Hoge)

varで作成されるチャネルはnilになりますが、makeで作成されたチャネルはnilにはなりません。

package main

import (
    "fmt"
)

func main() {
    // varキーワードで作成する場合
    var mych chan int
    // 値はnil
    fmt.Println("mychの値: ", mych)
    fmt.Printf("チャネル:mychのデータ型: %T ", mych)

    // makeで作成する場合
    mychMk := make(chan int)
    // 値はアドレスが入ります
    fmt.Println("mychMkの値: ", mychMk)
    fmt.Printf("チャネル:mychMkのデータ型: %T ", mychMk)
}

output:

mychの値:  <nil>
チャネル:mychのデータ型: chan int 
mychMkの値:  0xc000062060
チャネル:mychMkのデータ型: chan int  

Go Playgroundで確認する

データの送受信

<- 演算子を使って送受信を定義します。
<- はデータがはいるチャネルの方向を示して、送信は chan<- 、受信は <-chan と表します。

// チャンネル:ch にデータ:vを送信する
ch <- v

// チャンネル:ch からデータを受信してvに代入する
v := <-ch 

デフォルトでは、反対側の準備ができるまで送受信をブロックします。
この機能によって、明示的なロックや条件を変数で設定することなくゴルーチンを同期できます。
送信された値を受信する準備ができている受信(<-chan)が設定されている場合にのみ、送信(chan <-)を受け入れます。

Channelの種類

チャネルにはバッファードチャネル(Buffered Channel)とアンバッファードチャネル(Unbuffered Channel)の2種類あります。デフォルトはアンバッファードチャネルです。バッファードチャネル(Buffered Channel)は、それに対応する受信(<-chan)がなくても、限られた数の値を受け入れます。

バッファードチャネル(Buffered Channel)

バッファーチャンネルもmake関数で定義しますが、サイズを設定する点が異なります。

// 通信データがintの場合、データサイズも指定します
ch := make(chan int, 3)

バッファードチャネルの実装

func main() {
    // intを送受信するチャネルを定義します。データサイズは2とします。
    ch := make(chan int, 2)
    // 1つ目のデータ
    ch <- 1
    // 2つ目のデータ
    ch <- 10

    fmt.Println("chのデータ数は",len(ch))
    // チャネルの送受信を止めます
    close(ch)

    // チャネルの中身を出力する
    for c := range ch {
        fmt.Println(c)
    }
}

コンソール出力結果

chのデータ数は 2
1
10

データサイズ以上のデータを入れようとすると

func main() {
    // intを送受信するチャネルを定義します。データサイズは2とします。
    ch := make(chan int, 2)
    // チャネルにデータサイズ以上を入れようとすると
    ch <- 1
    fmt.Println("chのデータ数は",len(ch))
    ch <- 10
    fmt.Println("chのデータ数は",len(ch))
    ch <- 100
    fmt.Println("chのデータ数は",len(ch))
}

コンソール出力結果は、オーバフローしてしまうのでエラーとなります。ご注意ください。

chのデータ数は 1
chのデータ数は 2
fatal error: all goroutines are asleep - deadlock!

データ送受信をとめる

close() 関数を使いチャネルを非アクティブにできます。クローズしたチャネルはデータの送受信ができなくなります。

シンタックス:

close(chan)

クローズの確認

シンタックス:

response, ok = <-myChan

channelを使って実装する

最初に実装したgoroutineのサンプルで syncパッケージの waitGroup を使った実装をしましたが、今回は channelを使った実装のサンプルをつくってみます。

package main

import (
    "fmt"
)

// 受け取った文字列を出力する関数
func say(strs ...string) {
    // stringを受け取るchannel
    ch := make(chan string)
    n := 0
    for _, str := range strs {
        n++
        // goroutineでchannelに文字を入れる処理
        go func(s string) {
            ch <- s
        }(str)
    }

    for i := 0; i < n; i++ {
        // 出力する
        fmt.Println(<-ch)
    }
    // 送受信をやめる
    close(ch)
}

func main() {
    say("hello", "goroutine", "world", "with", "channel")
}

コンソール出力結果は、処理がされた順に出力されます。(その都度結果は変わります)

channel
goroutine
hello
world
with

Go Playgroundで確認する

Channel vs WaitGroup

では、channelsync.WaitGroupが出てきましたが、どういったケースがそれぞれ適しているかを解説します。

sync.WaitGroup が適しているのは

  • goroutine から返る結果には興味がないケース
  • シンプルに処理を止めておくという明確な実装のみでいいケース

channel が適しているのは

  • goroutine から返る結果が重要なケース
  • ただ止めるだけではなく、その結果にこだわるケース

という観点で使い分けの目安になると思います。

最後に

今回はgoroutinechannel という特徴的な機能について少し深堀りしてみました。
実際に使えるチャンスが有る場合は積極的に使っていきたいなと思います。

参考リンク

Discussion