yikegaya’s blog

仕事関連(Webエンジニア)と資産運用について書いてます

「Goならわかるシステムプログラミング」の12章を読んだ(シグナルによるプロセス間の通信)

シグナルには、大きく2つの用途があります。

  • プロセス間通信:カーネルが仲介して、あるプロセスから、別のプロセスに対してシグナルを送ることができる。自分自身に対してシグナルを送ることも可能
  • ソフトウェア割り込み:システムで発生したイベントは、シグナルとしてプロセスに送られる。シグナルを受け取ったプロセスは、現在行っているタスクを中断して、あらかじめ登録しておいた登録ルーチンを実行する これまでの説明で何度も登場したシステムコールは、ユーザー空間で動作しているプロセスからカーネル空間にはたらきかけるためのインタフェースでしたが、その逆方向がシグナルだと考えることもできます

シグナルのライフサイクル

シグナルはさまざまなタイミングで発生(raise)します。0除算エラーや、メモリの範囲外アクセス(セグメント違反)は、CPUレベルで発生し、それを受けてカーネルがシグナルを生成します。アプリケーションプロセスで生成(generate)されるシグナルもあります。 生成されたシグナルは、対象となるプロセスに送信(send)されます。プロセスは、シグナルを受け取ると、現在の処理を中断して受け取ったシグナルの処理を行います。

プロセスは、受け取ったシグナルを無視するか、捕捉して処理(handle)します。 デフォルトの処理は、無視か、プロセスの終了です。 プロセスがシグナルを受け取った場合の処理内容は、事前に登録してカスタマイズ できます。プロセスを終了しない場合は、シグナルを受け取る前に行っていたタスク を継続します。

シグナルの一覧取得

# Linux
man 7 signal

# mac/BSD
man signal

ハンドルできないシグナル

強制力を持ち、アプリケーションではハンドルできないシグナルがあります。

  • SIGKILL:プロセスを強制終了
  • SIGSTOP:プロセスを一時停止して、バックグラウンドジョブにする

サーバーアプリケーションでハンドルするシグナル

サーバーアプリケーション側で独自のハンドラを書くことが比較的多いシグナルとしては、次のようなものがあります。

  • SIGTERM:kill()システムコールやkillコマンドがデフォルトで送信するシグナルで、プロセスを終了させるもの
  • SIGHUP:通常は、後述するコンソールアプリケーション用のシグナルだが、「ターミナルを持たないデーモンでは絶対に受け取ることはない」ので、サーバーアプリケーションでは別の意味で使われる。具体的には、設定ファイルの再読み込みを外部から指示する用途で使われることがデファクトスタンダードとなっている

その他Ctrl + Cでプログラムを停止したり、ウィンドウサイズ調整したりする際もシグナルが送られている。

Go言語におけるシグナルの種類

Go 言語では syscall パッケージ内でシグナルを定義しています。たとえば、syscall.SIGINTのように定義されています。なお、SIGINTとSIGKILLの2つに関 してはosパッケージで次のようにエイリアスが設定されていて、全OSで使えることが保証されています。

var (
    Interrupt Signal = syscall.SIGINT
    Kill Signal = syscall.SIGKILL
)

次のシグナルはPOSIX系OSで使えるシグナルの一覧です。

  • ハンドル不可・外部からのシグナルは無視 :SIGFPE、SIGSEGV、SIGBUSが該当。 算術エラー、メモリ範囲外アクセス、その他のハードウェア例外を表す、致命度の高いシグナル。Go言語では、自分のコード中で発生した場合にはpanicに変換して処理される。外部から送付することはできず、ハンドラを定義しても呼ばれない
  • ハンドル不可 :SIGKILL、SIGSTOPが該当。Go言語に限らず、C言語でもハンドルできないシグナル
  • ハンドル可能・終了ステータス1 :SIGQUIT、SIGABRTが該当
  • ハンドル可能・パニック、レジスタ一覧表示、終了ステータス2:SIGILL、SIGTRAP、SIGEMT、SIGSYSが該当

シグナルのハンドラを書く

シグナルはフォアグラウンドのプロセスに最初に送信されます。したがって、自作のコードでシグナルのハンドラを書き、それをgo runを使って実行す ると、シグナルは自作コードのプロセスではなくgoコマンドのプロセスに送信されてしまいます。これを避けるため、シグナルをハンドルするコードはgo runでは実行せず、go buildコマンドで実行ファイルを作成してから実行してください。

package main

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

func main() {
    signals := make(chan os.Signal, 1)
    signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)

    s := <-signals

    switch s {
    case syscall.SIGINT:
        fmt.Println("SIGINT")
    case syscall.SIGTERM:
        fmt.Println("SIGTERM:")
    }
}
go build -o signal main.go

./signal
// Ctrl +C、Zで停止するとコンソールにSIGINTなど表示される

シグナルを無視するコード。最初の10秒だけCtrl + Cを無視する。
package main

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

func main() {
    fmt.Println("Accept Ctrl + C for 10 second")
    time.Sleep(time.Second * 10)

    signal.Ignore(syscall.SIGINT, syscall.SIGHUP)

    fmt.Println("Ignore Ctrl + C for 10 second")
    time.Sleep(time.Second * 10)
}
go build -o ignore_signal main.go
./ignore_signal
Accept Ctrl + C for 10 second
Ignore Ctrl + C for 10 second

シグナルのハンドラをデフォルトに戻す

signal.Reset(syscall.SIGINT, syscall.SIGHUP)

シグナルの送付を停止させる

signal.Stop(signals)

シグナルを他のプロセスに送る

package main

import (
    "fmt"
    "os"
    "strconv"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Printf("usage: %s [pid]\n", os.Args[0])
        return
    }

    pid, err := strconv.Atoi(os.Args[1])

    if err != nil {
        panic(err)
    }
    process, err := os.FindProcess(pid)

    if err != nil {
        panic(err)
    }

    process.Signal(os.Kill)

    process.Kill()
}

os/execパッケージを使った高級なインタフェースでプロセスを起動した場合は、Processフィールドにos.Process構造体が格納されているので、この変数経由で送信できます。

cmd := exec.Command("child")
cmd.Start()
// シグナル送信
cmd.Process.Signal(os.Interrupt)

シグナルの応用例(Server::Starter)

いきなりシャットダウンしてしまうと、アクセス中のユーザーに正しく結果を返すことができません。かといって、自然にユーザーが途切れるまで待つわけにもいきません。複数台のサーバーを利用している場合には、さらに難しくなります。この課題はグレイスフル・リスタートと呼ばれています。 グレイスフル・リスタートを実現するための補助ツールとして広く利用されている仕組みに、奥一穂さんが作成したServer::Starterがあります。Server::Starterは、サーバーの再起動が必要になったときに、「新しいサーバーを起動して新しいリクエストをそちらに流しつつ、古いサーバーのリクエストが完了したら正しく終了させる」ための仕組みです。Server::Starterを利用できるようにサーバーを作れば、サービス停止時間ゼロでサーバーの再起動が可能になります。

Go版のServer::Starterインストール

go get github.com/lestrrat/go-server-starter/cmd/start_server

Server::Starterの使い方

カレントディレクトリにあるserverというサーバープログラムを、Server::Starter で起動するには、次のようにstart_serverというコマンドを使います。

start_server --port 8080 --pid-file app.pid -- ./server

Server::Starter 対応のウェブサーバーのための最小限のコード

サーバーをgoroutineで起動し、SIGTERMシグナルを受け取ったら外部から停止するメソッドを呼び出すようにすれば、簡単に実現できます。

package main

import (
    "context"
    "fmt"
    "github.com/lestrrat/go-server-starter/listener"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    signals := make(chan os.Signal, 1)
    signal.Notify(signals, syscall.SIGTERM)

    listeners, err := listener.ListenAll()
    if err != nil {
        panic(err)
    }

    server := http.Server{
        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            fmt.Fprintf(w, "server pid: %d %v\n", os.Getpid(), os.Environ())
        }),
    }
    go server.Serve(listeners[0])

    <-signals
    server.Shutdown(context.Background())
}

Go言語ランタイムにおけるシグナルの内部実装

マルチスレッドのプログラムだと、シグナルはその中のどれかのスレッドに届けられます。マルチスレッドのプログラムでは、リソースアクセス時にロックを取得するスレッドがどれかわからないと容易にブロックしてプログラムがおかしくなってしまうため、シグナル処理用のスレッドとそれ以外のスレッドを分けるのが定石です。Go言語でもそのようになっています。主なコードはruntime/signal_unix.goにあります。