chanの使い方パターンメモ。


chan使用パターンを記録するよ! chanは強力な仕掛けなんだけど、 道を踏み外すと道半ばで刺さる。 なので良かったパターンをメモっとく。

chanを扱う役割は5つある。

  • makeする役(maker)
  • writeする役(writer)
  • readする役(reader)
  • closeする役(closer)
  • 破棄する役(GC)

これらを踏まえて抑えるべきポイントは

  • read,write,closeは時間的にmakeからGCまでの間にしか存在してはいけない。
  • readerとwriterは基本別のgoroutineであること。
  • closeはreaderに終了を通知するためのもの。
  • readerを終了させる最善の手はclose。
  • 基本writerとcloserは一蓮托生。
  • そうでなければcloseは使うべきでない。
  • chanは同期のための仕組みで、Mutexなどを併用するのは使い方がおかしい。
  • chanを入れた変数はwriter起動中の書き換え禁止。(Mutexが必要になる)
  • 基本chanはスケールが期待できるが、
  • コアあたりのスループット性能は落ちる。
  • chanは使わないという選択を常に意識する。
  • readerを終了させる別手段は終了通知chanがオススメ。
  • 終了通知chanとしてgo.net/contextが使える。
  • タイムアウトやキャンセルが必要になったらgo.net/contextの出番。

基本パターン

playground

ch := make(chan int) // maker
go func() {          // writer
	defer close(ch)  // closer
	ch <- 1
	ch <- 2
	ch <- 3
}()

for i := range ch { // reader
	fmt.Println(i)
}
// 2スコープ両方抜けると
// そののちにGCに破棄される。

パイプラインジョブパターン

chanを引数にchanを返すパターンなら引数に渡したchanをcloseすることで 各goroutineは正常に終了できる。

playground

package main

import (
	"fmt"
	"strings"
)

func Job(input chan string) (output chan string) {
	output = make(chan string)
	go func() {
		defer close(output)
		for in := range input {
			output <- strings.ToUpper(in)
		}
	}()
	return output
}

func main() {
	input := make(chan string)
	go func() {
		defer close(input)
		input <- "hoge"
		input <- "moge"
	}()
	for out := range Job(input) {
		if len(out) != 0 {
			fmt.Println(out)
		}
	}
}

タイムアウトが必要になったら

前述のような「for〜range chan」によるイテレーションでは処理できません。 selectによる多chan待ちが必要です。

playground

package main

import (
	"fmt"
	"strings"
	"time"
)

func Job(input chan string, timeout time.Duration) chan string {
	output := make(chan string)
	go func() {
		defer close(output)
		tm := time.NewTimer(timeout)
		for {
			select {
			case <-tm.C:
				fmt.Println("timeout")
				return
			case v, ok := <-input:
				if !ok {
					return
				}
				output <- strings.ToUpper(v)
				tm.Reset(timeout)
			}
		}
	}()
	return output
}
func main() {
	input := make(chan string)
	go func() {
		defer close(input)
		input <- "hoge"
		input <- "moge"
		time.Sleep(20 * time.Millisecond)
		input <- "oge"
	}()
	for out := range Job(input, 10*time.Millisecond) {
		if len(out) != 0 {
			fmt.Println(out)
		}
	}
}

さらにキャンセル可能にしたい場合

net/contextの出番です。

非同期のgoroutineに対し異常系のカバーを行う際、以下のニーズをContextオブジェクトで実現できます。

  • キャンセルしたい。
  • タイムアウトさせたい。
  • キャンセルの理由を収集したい。
  • WithValueでオブジェクトを子に渡したい。
package main

import (
	"fmt"
	"strings"
	"time"

	"golang.org/x/net/context"
)

func Job(ctx context.Context, input chan string) chan string {
	output := make(chan string)
	go func() {
		defer close(output)
		for {
			select {
			case <-ctx.Done():
				fmt.Println("cancel:", ctx.Err())
				return
			case v, ok := <-input:
				if !ok {
					return
				}
				output <- strings.ToUpper(v)
			}
		}
	}()
	return output
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond)
	defer cancel()
	input := make(chan string)
	go func() {
		defer close(input)
		for _, v := range []string{"hoge", "moge"} {
			select {
			case <-ctx.Done():
				return
			case input <- v:
				//
			}
		}
		time.Sleep(1100 * time.Millisecond)
	}()
	for v := range Job(ctx, input) {
		fmt.Println(v)
	}
	time.Sleep(100 * time.Millisecond)
}

main関数でcancel関数受け取ってすぐdeferに渡す部分はこの関数スコープ抜けたら ctx参照しているgoroutineはすべて終了することを保証します。 つまりinputを閉じ忘れてもctx.Done()で終了するgoroutineはちゃんと終了することが保証されます。 ctxは親子関係を持つこともできますが、子もすべてDone()チャネルが値を返す様になります。

この例ではギリギリタイムアウト発生しますが、 ctx.Done()が値を返す状態になるとctx.Err()で理由が分かります。 上記の例では「cancel: context deadline exceeded」と表示されます。 cancel関数が先に呼ばれても終了し、「cancel: context canceled」が表示されます。