フューチャー技術ブログ

Go 1.16のsignal.NotifyContext()

Go 1.16リリース記念連載の最終回はsignal.NotifyContext()です。

ご存知のように、Go 1.7でcontext.Contextが入ってから、少しずついろいろなAPIがContext対応になりました。

  • 1.7
    • netDialerDialContext()メソッドを追加
    • net/httphttp.RequestContext()WithContext()メソッドを追加。
    • os/execCommandContextを追加
  • 1.8
    • database/sqlが大幅にcontext.Context対応を追加
    • net/httphttp.ServerShutdown()を追加
    • netcontext.Contextに対応したリゾルバーを追加
  • 1.13
    • net/httpNewRequestWithContextと、Request.Clone()が追加

外部へのネットワークアクセスや、外部プロセス起動など、戻ってくる時間が未知数なものはContextを受け入れる口があります。

もともとnet.Connにはタイムアウトがありましたが、Contextを受け入れるAPIは共通インタフェースでタイムアウトの設定をしたり、外部呼び出しをキャンセルできるようになります。また、複数のAPIアクセスを並行して行う場合にも同じContextを渡すことで一括でタイムアウト設定したり、キャンセルできるようになります。

Goは例外処理(大域脱出による処理の中断と、それにともなうリソース開放)はありませんが、並行処理に対応した一括処理中断は、並行処理時代の例外処理と言えると思います。現代においては、外部API呼び出しやデータベースアクセス、外部プロセス起動を行うようなロジックを作成する場合、Contextを第一引数として取るように設計するのが紳士淑女の嗜みです。

すべてのキャンセル処理をContextで統一すると一括処理中断がしやすくなるので、異常処理が中央集権的になって、見通しが良くなります。Doneというメソッド名で終了判断のselect等で利用できてコードがみやすくなるので、僕は実行フローに関係するような非同期の情報伝達は全部Context化していました。

一方でユーザー主導のキャンセルのトリガーとなるシグナルはContextのAPIがなく、チャネルの提供でした。そのため、次のような関数を作ってContextに合わせるというのを1.15まではする必要がありました。

1.15まででシグナルをContext化
func signalContext() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
go func() {
select {
case <-c:
fmt.Fprintln(os.Stderr, "signal received")
cancel()
case <-ctx.Done():
}
}()
return ctx, func() {
signal.Stop(c)
cancel()
}
}

1.16ではsignal.NotifyContext()が入ったので、このような関数を手作りする必要がなくなりました。タイムアウトでもシグナルでも終了するような行儀の良いプログラムは次のように書けます。数値を変えてタイムアウトさせたり、正常終了させたり、シグナルを送ってみたり、いろいろ遊んでみてください。

package main

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

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
ctx, cancel := context.WithTimeout(ctx, time.Second*5)
defer cancel()

cmd := exec.CommandContext(ctx, "sleep", "10")
err := cmd.Run()

if err != nil {
if ctx.Err() != nil {
fmt.Printf("error: %v\n", ctx.Err())
} else {
fmt.Printf("error: %v\n", err)
}
os.Exit(1)
}
}

errの周りの処理がif文が増えています。cmd.Run()のエラーは、タイムアウトでも必ずsignal: killedとなってしまいます。これはexec.CommandContextがタイムアウト時にプロセスにシグナルを送ってkillするためです。そのシグナルの理由が本当にユーザーが子プロセスに向けてKILLを送ったのか、Goのランタイムが送ったものなのかを区別するにはContext側のエラーを見る必要があります。

Goが良く使われるようなウェブサービスをシグナルでgracefulにシャットダウンするのはこんな感じになるでしょうか

package main

import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"time"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "hello world")
})

server := &http.Server{
Addr: ":8888",
Handler: nil,
}
go func() {
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
fmt.Println("start receiving at :8888")
log.Fatal(server.ListenAndServe())
}

これまでシグナル処理をサボっていた人も、Contextはちょっと面倒と思っていた人も、ぜひsignal.NotifyContext()でぜひハッピーシグナルライフを送ってください。Goユーザーは行儀が良い、という評判をみんなで広めましょう。