time.Ticker で遊ぶ

【2021-02-18】 Go 1.16 で追加された関数を使った「time.Ticker で遊ぶ【Go 1.16 バージョン】」を公開した。 併せてどうぞ。

相変わらず小ネタで。 今回の目標は2つ。

  1. 一定周期ごとの処理を行う
  2. Ctrl+C 等の割り込み処理を行う

一定周期ごとの処理を行う

Go 言語で一定周期ごとに処理を行うには time.Ticker が使える。 以下は1秒ごとに現在時刻を表示する処理である。

package main

import (
	"fmt"
	"time"
)

func ticker() {
	t := time.NewTicker(1 * time.Second) //1秒周期の ticker
	defer t.Stop()

	for {
		select {
		case now := <-t.C:
			fmt.Println(now.Format(time.RFC3339))
		}
	}
}

func main() {
	ticker()
}

time.Ticker.C は受信 channel で,周期イベント発生時の時刻がセットされる。 defer 構文を使って終了時に time.Ticker.Stop() 関数で周期イベントを止めようとしているが,実際には無限ループなので, return まで到達しない(笑)

このコードはちゃんと動くが,終了条件を記述していないので Ctrl+C などで外部から強制的に止めない限り動き続ける。

SIGNAL を捕まえる

Go 言語では SIGINT や SIGTERM といった OS から送信される SIGNAL をイベントとして channel に送り込む仕掛けがある(ちなみに Ctrl+C は SIGINT として送られる)。 この仕掛けを組み込んだコードが以下である。

package main

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

func ticker() {
	t := time.NewTicker(1 * time.Second) //1秒周期の ticker
	defer t.Stop()

	sig := make(chan os.Signal, 1)
	signal.Notify(sig,
		syscall.SIGHUP,
		syscall.SIGINT,
		syscall.SIGTERM,
		syscall.SIGQUIT,
	)
    defer signal.Stop(sig)

	for {
		select {
		case now := <-t.C:
			fmt.Println(now.Format(time.RFC3339))
		case s := <-sig:
			switch s {
			case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
				fmt.Println("Stop!")
				return
			}
		}
	}
}

func main() {
	ticker()
}

このやり方であれば Ctrl+C でも強制終了することなく正しく time.Ticker.Stop() 関数が起動される。

ここまでが main goroutine のみの処理の場合。 複数の goroutine が協調して動いている場合は SIGNAL イベントに対して全ての goroutine が適切に処理を行う必要がある。

キャンセル・イベントの伝搬

Go 言語 1.7 から context が標準パッケージに加わった。 context パッケージは,名前の通り,異なるレイヤやドメイン間でコンテキスト情報を受け渡しするためのパッケージだが,キャンセル・イベントを扱うことができる。

ctx := context.Background()
parent, cancelParent := context.WithCancel(ctx)
child, cancelChild := context.WithCancel(parent)

cancelParent および cancelChild は関数値で,これをキックすることでそれぞれの context.Context にキャンセル・イベントが発生する。 面白いのは parent で発生したイベントは child にも伝搬する点である(逆向きには伝搬しない)。 これを利用して,発生した SIGNAL に対して親の context.Context にイベントを発生させることによって全ての子 context.Context にイベントを伝搬させることが可能になる。

たとえば,先ほどの SIGNAL の処理は以下のように書き直すことができる。

func SignalContext(ctx context.Context) context.Context {
	parent, cancelParent := context.WithCancel(ctx)
	go func() {
		defer cancelParent()

		sig := make(chan os.Signal, 1)
		signal.Notify(sig,
			syscall.SIGHUP,
			syscall.SIGINT,
			syscall.SIGTERM,
			syscall.SIGQUIT,
		)
        defer signal.Stop(sig)

		select {
		case <-parent.Done():
			fmt.Println("Cancel from parent")
			return
		case s := <-sig:
			switch s {
			case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
				fmt.Println("Stop!")
				return
			}
		}
	}()

	return parent
}

ticker() 関数のほうも context.Context のイベントを拾えるよう以下のように書き直す。

func ticker(ctx context.Context) error {
	t := time.NewTicker(1 * time.Second) //1秒周期の ticker
	defer t.Stop()

	for {
		select {
		case now := <-t.C:
			fmt.Println(now.Format(time.RFC3339))
		case <-ctx.Done():
			fmt.Println("Stop child")
			return ctx.Err()
		}
	}
}

周期処理と SIGNAL 受信処理を別々の goroutine で駆動させて両者を context.Context で繋ぐのである。

完全なコードは以下の通り。

package main

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

func ticker(ctx context.Context) error {
	t := time.NewTicker(1 * time.Second) //1秒周期の ticker
	defer t.Stop()

	for {
		select {
		case now := <-t.C:
			fmt.Println(now.Format(time.RFC3339))
		case <-ctx.Done():
			fmt.Println("Stop child")
			return ctx.Err()
		}
	}
}

func SignalContext(ctx context.Context) context.Context {
	parent, cancelParent := context.WithCancel(ctx)
	go func() {
		defer cancelParent()

		sig := make(chan os.Signal, 1)
		signal.Notify(sig,
			syscall.SIGHUP,
			syscall.SIGINT,
			syscall.SIGTERM,
			syscall.SIGQUIT,
		)
        defer signal.Stop(sig)

		select {
		case <-parent.Done():
			fmt.Println("Cancel from parent")
			return
		case s := <-sig:
			switch s {
			case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
				fmt.Println("Stop!")
				return
			}
		}
	}()

	return parent
}

func Run(ctx context.Context) error {
	errCh := make(chan error, 1)
	defer close(errCh)

	parent := SignalContext(ctx)
	go func() {
		child, cancelChild := context.WithTimeout(parent, 10*time.Second)
		defer cancelChild()
		errCh <- ticker(child)
	}()

	err := <-errCh
	fmt.Println("Done parent")
	return err
}

func main() {
	ctx := context.Background()
	if err := Run(ctx); err != nil {
		fmt.Fprintln(os.Stderr, err)
		return
	}
	fmt.Println("Done")
}

ちなみに context.WithTimeout() 関数は context.Context にタイムアウト・イベントを付加する。 他にもデッドライン日時を指定する context.WithDeadline() 関数がある。

なんか今回は久しぶりに Go 言語っぽいコードだったねぇ(笑)

【追記】 Windows では SIGNAL を送信できない

今回は SIGNAL を受信する場合の話だったが,もちろん任意のプロセスに SIGNAL を送信することもできる。 以下は自分自身に SIGINT を投げるコード例である1

proc, _ := os.FindProcess(os.Getppid())
proc.Signal(os.Interrupt)

ただし,このコードは Windows では(コンパイル・エラーにはならないが)動かない。 Windows 用の os.Process.Signal() 関数の実体は以下の通りだが

func (p *Process) signal(sig Signal) error {
	handle := atomic.LoadUintptr(&p.handle)
	if handle == uintptr(syscall.InvalidHandle) {
		return syscall.EINVAL
	}
	if p.done() {
		return errors.New("os: process already finished")
	}
	if sig == Kill {
		err := terminateProcess(p.Pid, 1)
		runtime.KeepAlive(p)
		return err
	}
	// TODO(rsc): Handle Interrupt too?
	return syscall.Errno(syscall.EWINDOWS)
}

このように os.Kill (SIGKILL) 以外は効かないようになっている。 理由は不明だが TODO になってるようなので何か理由があるのだろう。

というわけでテストができないのですよ orz

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)


  1. syscall パッケージを使うやり方もあるが,今回は割愛する。つか, Windows 版には syscall.Kill() 関数が存在しないのでやりようがないのだが。 ↩︎