Go言語での定期実行とタイムアウト(Software Design 2019年2月号の復習)

f:id:suganoo:20190123001521p:plain
こんにちは suganoo です。

今月号のSoftware Design (2019年2月号)に「”速い”コードの書き方」という特集がありました。Go言語についても書かれていたので、おお!っとさっそく読み込んでしまいました。

ソフトウェアデザイン 2019年2月号

ソフトウェアデザイン 2019年2月号

  • 作者: なぎせゆうき,成瀬ゆい,石本敦夫,mattn,片山善夫,藤崎正範,清水勲,岩永翔,柘植翔太,吉川拓哉,馬場俊彰,竹端尚人,吉田英二,安藤幸央,結城浩,武内覚,宮原徹,平林純,くつなりょうすけ,上田拓也,職業「戸倉彩」,上田隆一,田代勝也,eban,山田泰宏,中村壮一,速水祐,中谷克紀,小飼弾,すずきひろのぶ,青田直大,やまねひでき,中島雅弘,あわしろいくや,榎真治,細矢研人,後藤大地,杉山貴章,Software Design編集部
  • 出版社/メーカー: 技術評論社
  • 発売日: 2019/01/18
  • メディア: 雑誌
  • この商品を含むブログを見る

内容としてはGo言語の特徴であるgoroutineを使った非同期処理やタイムアウトがまとめられています。goroutineを使ってchanで非同期処理ってけっこうやってみないと理解できなかったりするので、あのページ数にまとめていたのはけっこう大変だったのではと推測してしまいます。
たまたま自分はGo言語で非同期処理のツールを作ってたので復習として読めてラッキーでした。

またタイミングが良かったことに、WebSokcketでチャットアプリを作ってみたいなと思っていまして、Go言語でWebSocketっというとgorilla/websocketがよく使われているのでそれをよく読んでいました。

https://github.com/gorilla/websocket

そこのexamples/echoでお勉強していたのですが、定期実行を下記のようにtimeパッケージのticker.Cでやっていたんですね。最初はこれが何やってるのかわかりませんでした。

	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-done:
			return
		case t := <-ticker.C:
			err := c.WriteMessage(websocket.TextMessage, []byte(t.String()))
..........

そこでSoftware Designにも書かれていたことだし自分の復習もかねて定期実行とタイムアウトを調べてみました。
※以降のコードはSoftware Design (2019年2月号)も参考にしています。

ctrl + c でプログラムを止める

その前にプログラムを実行中にプログラムを止めたい場合があります。
そのやり方をざっくり書いておきます。

import "os"
.......
        sc := make(chan os.Signal, 1)  // シグナル用のチャンネル作って
        signal.Notify(sc, os.Interrupt)  // シグナルを登録する
loop:
        for {
                select {
                case <- sc:  // <---- ctrl + c を押すとここでキャッチされる。
                        fmt.Println("interrupt")
                        break loop
                }
        }

定期実行する

いくつかやり方はあるそうですがここではtime.After を使う方法と、NewTickerを使う方法、time.Tickを使う方法を紹介します。

time.After()

https://golang.org/pkg/time/#After

func After(d Duration) <-chan Time

例えば2秒ごとに何かを実行したいなら、こんな感じに書けます。
Software Design (2019年2月号) ではこのように書いてますね。

case <-time.After(2 * time.Second):  

サンプルコードです

package main

import (
        "fmt"
        "os"
        "os/signal"
        "time"
)

func doSomething() {
        fmt.Println(time.Now())
}

func main() {
        sc := make(chan os.Signal, 1)
        signal.Notify(sc, os.Interrupt)
loop:
        for {
                select {
                case <- sc:
                        fmt.Println("interrupt")
                        break loop
                        //os.Exit(0)
                //case t := <-time.After(2 * time.Second):
                //      fmt.Println(t)
                case <-time.After(2 * time.Second):
                        doSomething()
                }
        }
}

実行結果です。以降実行結果はどれも同じなので省略します。

2019-01-22 13:48:58.5850586 +0900 JST m=+2.025493201
2019-01-22 13:49:00.5845793 +0900 JST m=+4.025013901
2019-01-22 13:49:02.5862735 +0900 JST m=+6.026708101
interrupt     // ctrl + c を押す

またループの抜け方ですが、Software Design (2019年2月号)に書いてありましたが上記のようにbreak loopで抜けられます。Go言語ってラベルでループを抜けるとかダメじゃないんですよね。もしくは os.Exit()でプログラムを終わらすこともできます。

またちなみにですが、下記のようにチャネルの結果を取得することも可能で、表示させてみるとtime型のオブジェクトが取得できます。以降の例も同様です。

case t := <--time.After(...):
        fmt.Println(t)  // --> ex. "2019-01-22 13:49:00.5845793 +0900 JST m=+4.025013901"

time.NewTicker()

time.NewTickerを使うとこんな感じです。
https://golang.org/pkg/time/#Ticker

func NewTicker(d Duration) *Ticker

type Ticker struct {
        C <-chan Time // The channel on which the ticks are delivered.
        // contains filtered or unexported fields
}
.....
tk := time.NewTicker(2 * time.Second)
defer tk.Stop()
.....
case <-tk.C:  
  ...

サンプルコードです

package main

import (
        "fmt"
        "os"
        "os/signal"
        "time"
)

func doSomething() {
        fmt.Println(time.Now())
}

func main() {
        sc := make(chan os.Signal, 1)
        signal.Notify(sc, os.Interrupt)

        tk := time.NewTicker(2 * time.Second)
        defer tk.Stop()
loop:
        for {
                select {
                case <- sc:
                        fmt.Println("interrupt")
                        break loop
                case <-tk.C:
                        doSomething()
                }
        }
}

time.Tick()

https://golang.org/pkg/time/#Tick

func Tick(d Duration) <-chan Time

time.Tick()を使うとこんな感じです。

for _ = range time.Tick(2 * time.Second) {
        doSomething()
}

サンプルコードです。

package main

import (
        "fmt"
        "os"
        "os/signal"
        "time"
)

func doSomething() {
        fmt.Println(time.Now())
}

func main() {
        sc := make(chan os.Signal, 1)
        signal.Notify(sc, os.Interrupt)

        go func() {
                for _ = range time.Tick(2 * time.Second) {
                        doSomething()
                }
        }()

loop:
        for {
                select {
                case <- sc:
                        fmt.Println("interrupt")
                        break loop
                }
        }
}

どれも <-chan 型を返してきてますね。

context.WithCancel, WithTimeout, WithDeadline

WithCancel, WithTimeout, WithDeadlineを使うと別プロセスから特定の関数をキャンセルすることができます。
ちょっとここでWithDeadlineについては特定の時間が来たらキャンセルになる機能でして、時間を指定すればいいだけなので説明は省きます。WithDeadlineの引数に特定の時間を入れればいいです。
https://golang.org/pkg/context/#WithDeadline

WithCancel

WithCancelは(というかWithTimeoutもWithDeadlineもですが)第二パラメーターにCancelFuncを返します。そのCancelFuncを実行するとContextを受け継いだ関数がキャンセルされます。
https://golang.org/pkg/context/#WithCancel

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

サンプルコードです

package main

import (
        "context"
        "fmt"
        "time"
)

func doSomething(ctx context.Context) {
        fmt.Println("5s sleep start from doSomething.")  // (4)
        time.Sleep(5 * time.Second)    // (5)
        fmt.Println("End doSomething.")   // (2)で2秒待って(3)でキャンセルされるので表示されない
}

func main() {
        ctx := context.Background()
        ctx, cancel := context.WithCancel(ctx)

        go doSomething(ctx)

        fmt.Println("2s sleep start")  // (1)
        time.Sleep(2 * time.Second) // (2)

        cancel()     // (3)

        select {
        case <-ctx.Done():    // (6)
                fmt.Println("done:", ctx.Err())
        }
}

Playgroundで動作確認してみる

実行結果

2s sleep start   // (1)が実行
5s sleep start from doSomething.   // (4)が実行
// (2)の2秒待機
done: context canceled  // 5秒待たず(3)でキャンセルされdoSomething()が終わり(6)に来る

WithCancelを使わない場合はわざわざキャンセル用のチャンネルを作ってdoSomethingに持たせ、forでループさせて待機させる必要があります。その点WithCancelのおかげでプログラムが簡素になりました。

キャンセルの連鎖

WithCancelを連鎖させることもできます。連鎖というとぷよぷよを思い出してしまいますね。ですがそんなイメージです。

サンプルコードを見てみましょう。

package main

import (
        "context"
        "fmt"
        "time"
)

func doSomethingParent(ctx context.Context) {
        fmt.Println("5s sleep start from doSomethingParent.")
        time.Sleep(5 * time.Second)
        fmt.Println("End doSomethingParent.")
}

func doSomethingChild(ctx context.Context) {
        fmt.Println("3s sleep start from doSomethingChild.")
        time.Sleep(3 * time.Second)
        fmt.Println("End doSomethingChild.")
}

func main() {
        ctx := context.Background()
        pCtx, pCancel := context.WithCancel(ctx)
        cCtx, cCancel := context.WithCancel(pCtx)
        defer cCancel()
        //defer pCancel()

        go doSomethingParent(pCtx)
        go doSomethingChild(cCtx)

        fmt.Println("2s sleep start")
        time.Sleep(2 * time.Second)

        pCancel()  // doSomethingParent 関数が終わりきる前に親関数をキャンセルする。
        //cCancel()

        select {
        case <-pCtx.Done():
                fmt.Println("done Parent Context :", pCtx.Err())
                fmt.Println("done Child  Context :", cCtx.Err())
        case <-cCtx.Done():
                fmt.Println("done Parent Context :", pCtx.Err())
                fmt.Println("done Child  Context :", cCtx.Err())
        }
}

Playgroundで動作確認してみる

実行結果

2s sleep start
3s sleep start from doSomethingChild.
5s sleep start from doSomethingParent.
done Parent Context : context canceled // <--- (pCancel)親関数がキャンセルされたから
done Child  Context : context canceled  // <--- 子関数もキャンセルされてるよ!!

そこで今度は子関数を先にキャンセルしてみましょう。main()だけ記載します。

func main() {
        ctx := context.Background()
        pCtx, pCancel := context.WithCancel(ctx)
        cCtx, cCancel := context.WithCancel(pCtx)
        //defer cCancel()
        defer pCancel()

        go doSomethingParent(pCtx)
        go doSomethingChild(cCtx)

        fmt.Println("2s sleep start")
        time.Sleep(2 * time.Second)

        //pCancel()
        cCancel()  // <--- 子関数をキャンセルする。

        select {
        case <-pCtx.Done():
                fmt.Println("done Parent Context :", pCtx.Err())
                fmt.Println("done Child  Context :", cCtx.Err())
        case <-cCtx.Done():   // <-- こっちが実行されるよ!
                fmt.Println("done Parent Context :", pCtx.Err())
                fmt.Println("done Child  Context :", cCtx.Err())
        }
}

Playgroundで動作確認してみる

実行結果

2s sleep start
3s sleep start from doSomethingChild.
5s sleep start from doSomethingParent.
done Parent Context : <nil>  // <---  親関数のcontextはキャンセルされてない!
done Child  Context : context canceled

想像通り子関数だけキャンセルされますね。
ちょっと有効方法が思いつきませんが、何かと使えそうですね。

WithTimeout 基本的使い方

WithTimeoutはSoftware Design (2019年2月号)Go言語のところの最後に書いてありました。
指定時間が経過したら関数をキャンセルします。
(2019/01/23 ctrl + c で止める必要はなさそうなのでその部分は削除しました。)

package main

import (
	"context"
	"fmt"
	"time"
)

func doSomething(ctx context.Context) {
	fmt.Println("before in doSomething")
	time.Sleep(5 * time.Second) // (2)
	fmt.Println("after  in doSomething")
}

func main() {
	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // (1)
	defer cancel()

	go doSomething(ctx)

	select {
	case <-ctx.Done(): // (3)
		fmt.Println("done:", ctx.Err())
	}
}

Playgroundで動作確認してみる

実行結果

before in doSomething
// (2)doSomething で5秒待機が始まる
// (1)しかしWtithTimeoutの3秒待機後のキャンセルが早い
done: context deadline exceeded  // そのため(3)のチャネルが返る

WithTimeoutで指定時間が過ぎるとキャンセルすることができます。

WithTimeout 自分でキャンセル実行

ここまでのサンプルコードを自分で書いててふと疑問に思ったのですが、タイムアウト予定の関数(ここではdoSomething)が思いのほか早く終わってタイムアウト以内で終わってしまったらどうなるのか?どうしたらいいのか?を考えてみました。

  1. WithTimeoutで10秒タイムアウトを設定する。
  2. doSomethingで5秒待機に、doSomethingが終了。

っとなったら、すぐに次の処理に行ってほしいですよね。

サンプルコードでタイムアウト時間を単純に延ばしてやってみます。
(2019/01/23 ctrl + c で止める必要はなさそうなのでその部分は削除しました。)

package main

import (
	"context"
	"fmt"
	"time"
)

func doSomething(ctx context.Context) {
	fmt.Println("before in doSomething")
	time.Sleep(5 * time.Second)
	fmt.Println("after  in doSomething")
}

func main() {
	ctx := context.Background()
	// 10秒にしました。
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	go doSomething(ctx)

	select {
	case <-ctx.Done():
		fmt.Println("done:", ctx.Err())
	}
}

Playgroundで動作確認してみる

実行結果

before in doSomething
after  in doSomething     // doSomethingの5秒待機が終わる
// でもWithTimeoutのタイムアウト時間が10秒だから残り5(=10 - 5)秒間待たされる!
// タイムアウト以内で処理が終わるんならすぐに終わってほしい。。。
done: context deadline exceeded

ちょっと場合によってはよろしくない動作になりました。
おそらくWithTimeoutを設定した場合は一定時間内に処理が終わってほしいものの、すぐに終わるんであればすぐ次の動作に行ってほしいことが多いと思われます。例えばどこかのサーバーにデータを取りにって30秒以内にリクエストが返ってこなければタイムアウトしてしかたないけど、数秒でリクエストが返ってくるのであればタイムアウト時間まで待つ必要はありません。

これどうしたもんかな?っと考えてみました。

ググってみたところあまり見つからなかったので、こんなコーディングにしてみました。CancelFuncを関数に渡しています。

package main

import (
	"context"
	"fmt"
	"time"
)

// 第二引数にcancel context.CancelFunc
func doSomething(ctx context.Context, cancel context.CancelFunc) {
	fmt.Println("before in doSomething")
	time.Sleep(5 * time.Second)
	fmt.Println("after  in doSomething")

	cancel() // 終わったら自分でcancelする。
}

func main() {
	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	go doSomething(ctx, cancel) // cancel関数も一緒に渡す

	select {
	case <-ctx.Done():
		fmt.Println("done:", ctx.Err())
	}
}

Playgroundで動作確認してみる

実行結果

before in doSomething
after  in doSomething
done: context canceled   // doSomethingが終わったらすぐにcancel()するのでctx.Done()のチャンネルをキャッチ

defer cancel()がプログラム終了後に実行されてしまうんで、なんかモヤっとしてしまいます。けどもcontextをタイムアウトを無効化する方法がなさげなので、うーんまあこれでいいかなーと・・・・。

最後に

Go言語のgorotine,非同期処理はなかなか難しいですが知ってると柔軟な実装ができていいですね。

そんでふと以前読んだ「Go言語による並行処理」を見てみると今回の内容がほとんど書いてありますね!かなり飛ばし読みして途中でやめてしまったのが悔やまれます。必読の本であることを再確認しました。

Go言語による並行処理

Go言語による並行処理

  • 作者: Katherine Cox-Buday,山口能迪
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2018/10/26
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る
blog.suganoo.net

(こんな記事もあります)
blog.suganoo.net