GoにおけるWorkerパターンとGraceful Shutdownを理解する
GoにおけるWorkerパターンとGraceful Shutdownを理解する
Goでバックグラウンド処理とGraceful Shutdownを実装する機会があり、実際のコードを読みながら理解を深めたので、その内容をまとめます。
はじめに
Webアプリケーションを開発していると、こんな処理が必要になることがあります:
- 期限切れのセッションを定期的に削除したい
- キューに溜まったジョブを順次処理したい
- キャッシュを定期的に更新したい
これらは「バックグラウンドジョブ」として実装されます。しかし、単純にgoroutineで動かすだけでは、アプリケーション終了時に処理途中のジョブが強制終了されてしまいます。
Goでは goroutine + channel + context で、安全に停止できるWorkerを実装できます。
他言語との対応
C#ならBackgroundService+CancellationToken、JavaならExecutorService+shutdown()にあたります。
題材:期限切れ予約のクリーナー
今回実装したのは、チケット予約システムで「15分経っても支払いが完了しない仮予約を自動キャンセルする」Workerです。
Workerの実装
基本構造
package worker
import (
"context"
"time"
)
type ExpiredReservationCleaner struct {
reservationService ReservationCleaner
interval time.Duration // 実行間隔
expireAfter time.Duration // 期限
stopCh chan struct{} // 停止シグナル
doneCh chan struct{} // 完了通知
}
ポイント: 2つのチャネル
| チャネル | 役割 |
|---|---|
stopCh |
「止まれ」というシグナルを受け取る |
doneCh |
「止まった」という完了を通知する |
Startメソッド
func (c *ExpiredReservationCleaner) Start(ctx context.Context) {
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
defer close(c.doneCh) // 終了時に完了を通知
for {
select {
case <-ctx.Done():
// アプリケーションからの停止シグナル
return
case <-c.stopCh:
// 明示的な停止シグナル
return
case <-ticker.C:
// 一定時間経過 → 処理実行
c.cleanup(ctx)
}
}
}
select 文
select は複数のチャネルを同時に待つ構文です。どれかのチャネルからデータが届いた時点で、該当するcaseが実行されます。
select {
case msg := <-ch1:
// ch1 からデータが来た
case msg := <-ch2:
// ch2 からデータが来た
case <-time.After(5 * time.Second):
// 5秒経過(タイムアウト)
}
time.NewTicker
指定した間隔で、チャネルに現在時刻を送り続けるタイマーです。
ticker := time.NewTicker(1 * time.Minute)
for {
<-ticker.C // 1分ごとにブロック解除
fmt.Println("1分経過")
}
Stopメソッド
func (c *ExpiredReservationCleaner) Stop() {
close(c.stopCh) // 停止シグナルを送る
<-c.doneCh // 完了を待つ
}
完了を待つ理由
待たないと、Workerが処理途中でもプログラムが終了してしまいます。
待たない場合:
待つ場合:
Graceful Shutdownの実装
main.go での使い方
func main() {
// 1. キャンセル可能なコンテキストを作成
ctx, cancel := context.WithCancel(context.Background())
// 2. Workerを作成・開始
cleaner := worker.NewExpiredReservationCleaner(
reservationService,
1*time.Minute, // 1分ごとにチェック
15*time.Minute, // 15分経過した予約をキャンセル
)
go cleaner.Start(ctx) // goroutineで開始
// 3. HTTPサーバー起動(別goroutine)
go func() {
if err := e.Start(":8080"); err != nil {
log.Fatal(err)
}
}()
// 4. シグナルを待つ
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit // Ctrl+C または kill を待つ
// 5. シャットダウン開始
log.Println("シャットダウン開始...")
// 6. Workerを停止
cancel() // ctx.Done() を発火
cleaner.Stop() // 完了を待つ
log.Println("Worker停止完了")
// 7. HTTPサーバーを停止
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := e.Shutdown(shutdownCtx); err != nil {
log.Error(err)
}
log.Println("サーバー停止完了")
}
シャットダウンの流れ
context.WithCancel
Goの context パッケージを使うと、キャンセル信号を伝播させることができます。
// 親コンテキストからキャンセル可能な子コンテキストを作成
ctx, cancel := context.WithCancel(context.Background())
// cancel() を呼ぶと...
cancel()
// ctx.Done() チャネルが閉じられる
<-ctx.Done() // ブロック解除
contextを使う理由
- 伝播: 親がキャンセルされると子も自動でキャンセルされる
- 標準: Goのライブラリは context を受け取るのが慣習
-
タイムアウト:
WithTimeoutでタイムアウトも設定できる
// 30秒でタイムアウト
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
select {
case result := <-doSomething(ctx):
fmt.Println(result)
case <-ctx.Done():
fmt.Println("タイムアウト!")
}
stopCh と ctx.Done() の両方がある理由
このコードでは stopCh は冗長です。cancel() を呼べば ctx.Done() が発火するので、stopCh がなくても動きます。
両方ある理由(設計上の意図):
- 将来の拡張性: context を使わずに Worker 単体で停止させたい場合に対応
- 明示性: 「このWorkerには明確なStop()メソッドがある」というAPI設計
- テスタビリティ: 単体テストで context なしでも停止できる
シンプルにするなら:
func (c *ExpiredReservationCleaner) Start(ctx context.Context) {
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
defer close(c.doneCh)
for {
select {
case <-ctx.Done(): // これだけでOK
return
case <-ticker.C:
c.cleanup(ctx)
}
}
}
func (c *ExpiredReservationCleaner) Stop() {
<-c.doneCh // 完了を待つだけ(cancelはmain側で呼ぶ)
}
テスト
Workerのテストでは、実際に時間を待つのではなく、短い間隔でテストします。
func TestExpiredReservationCleaner(t *testing.T) {
// モックサービス
mockService := &MockReservationCleaner{
CancelCount: 0,
}
// 短い間隔でテスト
cleaner := NewExpiredReservationCleaner(
mockService,
10*time.Millisecond, // 10ms間隔
1*time.Second,
)
ctx, cancel := context.WithCancel(context.Background())
go cleaner.Start(ctx)
// 50ms待つ → cleanup が数回実行されるはず
time.Sleep(50 * time.Millisecond)
cancel()
cleaner.Stop()
// 検証
assert.GreaterOrEqual(t, mockService.CancelCount, 3)
}
まとめ
Workerパターンのポイント
-
for+selectでイベントループを作る -
time.Tickerで定期実行 -
context.Done()で停止シグナルを受け取る -
defer close(doneCh)で完了を通知
Graceful Shutdownのポイント
-
シグナルを捕捉:
signal.Notifyで SIGINT/SIGTERM を待つ -
Workerを停止:
cancel()+Stop()で安全に停止 - 完了を待つ: 処理途中のジョブが終わるまで待機
- HTTPサーバーを停止: 接続中のリクエストが終わるまで待機
使用したGoの機能
| 機能 | 用途 |
|---|---|
context.WithCancel |
キャンセル可能なコンテキスト作成 |
time.NewTicker |
定期的なイベント発生 |
select |
複数チャネルの同時待機 |
chan struct{} |
シグナル専用チャネル(データなし) |
signal.Notify |
OSシグナルの捕捉 |
Discussion