Go の context パッケージを調べる

Go の標準パッケージである context パッケージの利用方法を確認します。

go pkg - context

環境

  • go 1.19.2

context パッケージとは

context パッケージとは、親子関係をもった goroutines の生死を制御、または goroutines 間でのデータをやり取りするもの、と理解しました。

In Go servers, each incoming request is handled in its own goroutine. Request handlers often start additional goroutines to access backends such as databases and RPC services. The set of goroutines working on a request typically needs access to request-specific values such as the identity of the end user, authorization tokens, and the request’s deadline. When a request is canceled or times out, all the goroutines working on that request should exit quickly so the system can reclaim any resources they are using.

At Google, we developed a context package that makes it easy to pass request-scoped values, cancellation signals, and deadlines across API boundaries to all the goroutines involved in handling a request. The package is publicly available as context.

Introduction - Go Concurrency Patterns: Context

そもそも起動された goroutines の制御や、goroutines 間でのデータのやり取りは、goroutines 間での channel を介した処理をもって実現するものであったはずです。この context パッケージは、それらの管理を利用しやすくしてくれたもの、と考えています。

利用のイメージ

以下のようなイメージになります。

  1. Background() 関数で、Context 型のインスタンスを生成する。
  2. 以下の関数を必要に応じて利用し、生成したインスタンスに情報を付与する。またキャンセル関数 CancelFunc を作る。
    1. WithCancel
      1. Context をキャンセル可能にする。
    2. WithDeadline
      1. Context のキャンセル時刻を指定する。
    3. WithTimeout
      1. Context のキャンセルまでの猶予時間を指定する。
    4. WithValue
      1. Context を介して連携したい値を渡す。
  3. 上記で作成した Context 型のインスタンスを引き渡して、goroutine を起動する。
  4. goroutine で動く関数内で、Context 型のメソッド を利用して、処理を行う。
    1. Context.Done
      1. Context のキャンセルを検知して、goroutine のクローズ処理を行う。
    2. Context.Velue
      1. WithValue で渡された値を受け取る。
  5. (必要に応じて)キャンセル関数を実行して、Context をキャンセルする。

各処理

各処理毎にコード記述方法を見ていきます。

WithCanchel

WithCancel 関数は、生成した Context インスタンスに対しキャンセルの通知を可能とするものです。この Context インスタンスを引き渡されて起動した goroutine は、その関数内で Context.Done() メソッドを用いてキャンセル関数 CancelFunc からの通知を検知できるようになり、goroutine の関数を正しく終了できるようなります。

package main

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

func child1(ctx context.Context) {
    for {
        select {
        // Context.Done() はキャンセルを検知するメソッド
        case <-ctx.Done():
            // ここに関数の終了処理を書ける
            // Context.Err() は Context のキャンセル理由を返してくれる
            if err := ctx.Err(); err != nil {
                fmt.Printf("cancel: child1 - %s\n", err.Error())
                return
            }
        default:
            // キャンセルされていない場合、nil を返す
            if err := ctx.Err(); err == nil {
                fmt.Println("working: child1")
            }
            time.Sleep(time.Second)
        }
    }
}

func child2(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            if err := ctx.Err(); err != nil {
                fmt.Printf("cancel: child2 - %s\n", err.Error())
                return
            }
        default:
            if err := ctx.Err(); err == nil {
                fmt.Println("working: child2")
            }
            time.Sleep(time.Second)
        }
    }
}

func main() {
    // Context 型のインスタンスを生成
    ctx := context.Background()
    // インスタンスにキャンセル設定を付与して、CancelFunc 関数を作成
    ctx, cancel := context.WithCancel(ctx)

    // goroutine 実行時に Context インスタンスを引き渡す
    go child1(ctx)
    go child2(ctx)

    time.Sleep(3 * time.Second)
    // CancelFunc 関数を実行
    cancel()
    time.Sleep(time.Second)
}
// outputs:
// working: child2
// working: child1
// working: child1
// working: child2
// working: child2
// working: child1
// cancel: child1 - context canceled
// cancel: child2 - context canceled

https://go.dev/play/p/aeeMcpMUdlg

Context.Done メソッドは、空の構造体の channel を返します。キャンセル関数が実行された際には、この channel がクローズされることになるため、それを受信して関数を停止処理へ進むことができるようなります。

type Context interface {
    Done() <-chan struct{}
   ...
}

The Done method returns a channel that acts as a cancellation signal to functions running on behalf of the Context: when the channel is closed, the functions should abandon their work and return. The Err method returns an error indicating why the Context was canceled.

親ゴールチーンから子ゴールチンに対してキャンセルできるもので、その逆はできないよ、という話。goroutine を呼び出した方が、その呼び出された goroutine の制御に責任を持ちなさいよ、という話ですね。

A Context does not have a Cancel method for the same reason the Done channel is receive-only: the function receiving a cancellation signal is usually not the one that sends the signal. In particular, when a parent operation starts goroutines for sub-operations, those sub-operations should not be able to cancel the parent.

1 つの Context インスタンスを複数の goroutine に引き渡すことができ、安全に同時に動作するよ、という話。

A Context is safe for simultaneous use by multiple goroutines. Code can pass a single Context to any number of goroutines and cancel that Context to signal all of them.

Context - Go Concurrency Patterns: Context

WithDeadline

WithDeadline 関数は、明示的なキャンセル関数を実行せずとも、時刻指定でキャンセル処理を起こすことができるものです。

package main

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

func child1(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            if err := ctx.Err(); err != nil {
                fmt.Printf("cancel: child1 - %s\n", err.Error())
                return
            }
        default:
            if err := ctx.Err(); err == nil {
                fmt.Println("working: child1")
            }
            time.Sleep(time.Second)
        }
    }
}

func child2(ctx context.Context) {
    for {
        select {
        // Context.Done() メソッドはデッドラインによるキャンセルも検知できる
        case <-ctx.Done():
            if err := ctx.Err(); err != nil {
                fmt.Printf("cancel: child2 - %s\n", err.Error())
                // context.Deadline() メソッドはデッドラインとなる設定時刻を返す
                if t, ok := ctx.Deadline(); ok {
                    fmt.Println(t)
                }
                return
            }
        default:
            if err := ctx.Err(); err == nil {
                fmt.Println("working: child2")
            }
            time.Sleep(time.Second)
        }
    }
}

func main() {
    // キャンセル設定を付与した Context インスタンスを作る
    ctx1, cancel1 := context.WithCancel(context.Background())

    // 現在から5秒後の時刻を指定して、デッドライン設定を付与した Context インスタンスを作る
    duration := 5 * time.Second
    d := time.Now().Add(duration)
    ctx2, cancel2 := context.WithDeadline(context.Background(), d)

    go child1(ctx1)  // キャンセル設定の Context を渡す
    go child2(ctx2)  // デッドライン設定の Context を渡す

    time.Sleep(3 * time.Second)
    cancel1()  // キャンセル設定をした Context の CancelFunc を実行
    time.Sleep(8 * time.Second)

    // ctx2 インスタンスの状態を確認する select 文
    select {
    case <-ctx2.Done():
        fmt.Println("already cancelled: ctx2")
    default:
        fmt.Println("not already cancelled: ctx2")
        // デッドライン設定をした Context の CancelFunc を明示的に実行
        // ただし、ctx2 はデッドラインキャンセルされているため、実行されることはない
        cancel2()
    }
}
// outputs:
// working: child2
// working: child1
// working: child1
// working: child2
// working: child2
// working: child1
// cancel: child1 - context canceled
// working: child2
// working: child2
// cancel: child2 - context deadline exceeded
// 2022-10-13 02:39:35.5727308 +0900 JST m=+5.000118501

https://go.dev/play/p/wYPmpKKGwIj

WithTimeout

WithTimeout 関数は、以下の通り WithDeadline 関数をラップしたもので、時刻指定ではなく、猶予時間を指定してキャンセル処理を行えるようにしたものです。

WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).

pkg context.WithTimeout

WithValue

WithValue 関数は、Context インスタンスを介して、親ゴールチンから子ゴルーチンへ Key-Value 型で値を渡せるようにしたものです。

package main

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

// WithValue 関数で key となるユーザー定義型を宣言
type ctxKey string

// 2種類の value を渡すために定数を2つ用意
const (
    key1 ctxKey = "string"
    key2 ctxKey = "map"
)

// 値を受け渡し用の構造体を宣言
type values struct {
    one string
    two string
}

func child(ctx context.Context) {
    // key1 の値を取得する
    // Context.Value メソッドは any 型で返ってくるのでキャストする必要がある
    v, ok := ctx.Value(key1).(string)
    if ok {
        fmt.Println(v)
    }

    // key2 で values 構造体を受け取る
    fmt.Println(ctx.Value(key2).(values).one)
}

func main() {
    ctx := context.Background()

    // key1 で "value" という値を渡す
    ctx = context.WithValue(ctx, key1, "value")

    // key2 で構造体 Values として値を渡す
    ctx = context.WithValue(ctx, key2, values{one: "1", two: "2"})

    go child(ctx)
    time.Sleep(time.Second)
}
// outputs:
// value
// 1

https://go.dev/play/p/SQRqBDOe-6m

context.WithValue で渡すデータは、goroutines 間で透過的なデータのみとすべきで、関数内でパラメーターとして利用されるような値はだめだよ、とのこと。

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

pkg context.WithValue

context.WithValue で利用する key は、組み込み型を利用するのでなく、衝突を避けるため、自分で専用の型定義を作るべきとのこと。

The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys.

pkg context.WithValue

複数の値を同時に渡したいときの方法について。上のコードでは、Value に構造体を渡して複数の値を連携させていますが、構造体のデータが大きい場合、または起動される goroutine の数を予想できない場合、メモリーの利用に懸念が生じると、下記の StackOverflow にはあります。解決方法として、構造体内のパラメーターに辞書を持たせる方法が紹介されていました。なるほど、という感じ。

context.WithValue: how to add several key-value pairs