Help us understand the problem. What is going on with this article?

初心者がGo言語のcontextを爆速で理解する ~ cancel編 ~

More than 1 year has passed since last update.

はじめに

久川善法です。
しばらく放置していたcontextについて勉強してみました。

この記事ではcontextの用途から、具体的な使い方までを自分用にまとめました。
※ 勉強中、参考になる記事が多くあったので、記事の最後にURLを載せています。

コンテキストとは?

contextはGo1.7で標準パッケージに仲間入りしたライブラリです。

Go言語による並行処理によると、
contextが仲間入りする以前のGo言語での並行処理では、
doneチャネルを使って並行処理を終了させていました。

しかし、doneチャネルのみを使った方法だとキャンセル理由の伝搬やタイムアウトなど複雑になる箇所がありました。
そこでcontextが実験的に生まれて、最終的に標準パッケージとして活用されるようになったそうです。

コンテキストの主な2つの用途

contextの主な用途は2つあります。

  • Goroutineの適切なキャンセル
  • リクエスト情報の伝搬

この記事では1つ目のGoroutineの適切なキャンセルにフォーカスします。
リクエスト情報の伝搬については、また次の記事でまとめます。

Goroutineの適切なキャンセル

contextのキャンセルの役割として、
タイムアウトやデッドラインを設定し後続の処理が停滞するのを防ぎ、不要になったGoroutineはキャンセルしてリソースの解放をする役割があります。

タイムアウトやデッドラインを設定しないと、接続などの問題で遅い処理があった際に、後続の処理を停滞させてしまいます。
そこで、contextで事前にタイムアウト時間3秒などの設定すると、リトライ処理や他の手段を選択できるようになります。

また、Goroutineはキャンセルせずに放っておくと、消えずに残ってリソースを消費することがあります。
これもcontextでちゃんとキャンセルしてリソースを解放して、他の処理にそのリソースを使えるようにできます。

また、Goroutineをキャンセルしたいパターンは3つあると思います。

  1. 全てのGoroutineをキャンセルしたい場合
  2. 分岐した先のGoroutineをキャンセルしたい場合
  3. ブロック(遅い処理)している処理をキャンセルしたい場合

ここから具体的にコードをみながら、これらの場合の対応をまとめます。

コンテキストの使い方ざっくり

まずはドキュメントからcontextの使い方を学んでいきます。
contextを読んだ方が早いですが一応説明をします。

Contextの型

type Context interface {
    // Deadlineが設定されている場合はその時刻を返却。ok==falseの時は未設定
    Deadline() (deadline time.Time, ok bool)

    // このコンテキストがキャンセルされたりタイムアウトした場合にcloseされます。
    Done() <-chan struct{}

    // Doneチャンネルが閉じた後なぜこのコンテキストがキャンセルされたかを知らせます。
    Err() error

    // Valueはkeyに紐付いた値を返し、設定した値がない場合はnilを返します。
    Value(key interface{}) interface{}
}

Functionたち

  • func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancelはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
イメージで例えると木構造になっていて、 生成元のContextがキャンセルされたときに、そこから派生した子のContextもすべてキャンセルされます。

  • func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

WithDeadlineはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
Contextのdeadlineは現在時刻+timeoutか親の期限(木構造になってるから親が優先)のどちらか早いほうに設定されます。
アラームの時刻を設定してまつようなイメージ

  • func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeoutはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
Contextのtimeoutは指定した時間か親の期限(木構造になってるから親が優先)のどちらか早いほうに設定されます。
ストップウォッチで3秒まつようなイメージ

  • func Background() Contextfunc TODO() Context

どちらも空のContextを生成します。
基本的にBackground()を使うイメージですが、TODOを使う時がわかっていません。
(nilにはしたくないけど、あとで何か設定するといった時につかうのでしょうか?)

  • type CancelFunc = context.CancelFunc

Contextをキャンセルします。

具体的なコードベース

それでは、パターン別の対応を実際のコードと一緒にみていきます。

  1. 全てのGoroutineをキャンセルしたい場合
  2. 分岐した先のGoroutineをキャンセルしたい場合
  3. ブロック(遅い処理)している処理をキャンセルしたい場合

1. 全てのGoroutineをキャンセルしたい場合

func main() {
    // contextを生成
    ctx := context.Background()

    // 親のcontextを生成し、parentに渡す
    ctxParent, cancel := context.WithCancel(ctx)
    go parent(ctxParent, "Hello-parent")

    // parentのcontextをキャンセル。mainを先に終了させないように1秒待ってから終了
    cancel()
    time.Sleep(1 * time.Second)
    fmt.Println("main end")
}

func parent(ctx context.Context, str string) {
    // parentからcontextを生成し、childに渡す
    childCtx, cancel := context.WithCancel(ctx)
    go child(childCtx, "Hello-child")
    defer cancel()
    // 無限ループ
    for {
        select {
        case <-ctx.Done():
            fmt.Println(ctx.Err(), str)
            return
        }
    }
}

func child(ctx context.Context, str string) {
    // 無限ループ
    for {
        select {
        case <-ctx.Done():
            fmt.Println(ctx.Err(), str)
            return
        }
    }
}
// context canceled Hello-child
// context canceled Hello-parent
// main end

ちゃんと木構造のように親のcontextがcancelされて、次に子のcontextが・・・と伝搬されているのがわかります。

2. 分岐した先のGoroutineをキャンセルしたい場合

func main() {
    // contextを生成
    ctx := context.Background()

    // 親のcontextを生成し、parentに渡す
    ctxParent, cancel := context.WithCancel(ctx)
    go func() {
        err := child(ctxParent)
        if err != nil {
            fmt.Println("*parent err*")
            cancel()
            return
        }
    }()

    // 無限ループ
    for {
        select {
        case <-ctxParent.Done():
            fmt.Println(ctxParent.Err(), "*parent done*")
            return
        default:
            fmt.Println("parent process")
        }
        time.Sleep(1 * time.Second)
    }
}

func child(ctx context.Context) error {
    // parentからcontextを生成し、childに渡す
    childCtx, cancel := context.WithCancel(ctx)
    go func() {
        if err := getErr(); err != nil {
            fmt.Println("*child err*")
            // childCtxをcancel
            cancel()
        }
    }()

    // 無限ループ
    for {
        select {
        case <-childCtx.Done():
            fmt.Println(childCtx.Err(), "*child done*")
            time.Sleep(4 * time.Second)
            return errors.New("")
        default:
            fmt.Println("child process")
        }
        time.Sleep(1 * time.Second)
    }
}

func getErr() error {
    time.Sleep(2 * time.Second)
    return errors.New("")
}

// parent process
// child process
// child process
// parent process
// *child err*
// context canceled *child done*
// parent process
// parent process
// parent process
// parent process
// *parent err*
// context canceled *parent done*

コードの流れを説明します。
parent process と child processを同時に動かします。
2秒後にcancel()を呼び出しchild processは終了させ、そのあとにparent processをcancel()しています。
このように、分岐先で問題があった場合は、分岐先のgoroutineのみ終了させることができます。

3. ブロック(遅い処理)している処理をキャンセルしたい場合

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

    // 3秒後をデッドラインにする
    ctx, cancel := context.WithDeadline(ctx, time.Now().Add(3 * time.Second))
    defer cancel()

    go sayDeadLine(ctx)

    select {
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    }
}

func sayDeadLine(ctx context.Context) {
    for {
        fmt.Println(ctx.Deadline())
        time.Sleep(1 * time.Second)
    }
}

// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// context deadline exceeded

コードの流れを説明します。
3秒後にcancelが呼ばれるようにdeadlineを設定します。
1秒に一回無限ループを回し、deadlineの設定を表示します。
3秒後にcancelが呼ばれ処理が終了します。

注意すること

  • contextを構造体の中に定義することはだめ
    例えば、contextを含んだpublicな構造体を定義すると、いろんなところからcontexを使えます。
    しかし、意図しないcancelや親子関係が複雑になってしまいます。
    なので第一引数で渡していく方法が推奨されています。

  • contextを第一引数に
    引数に渡す際は必ず第一引数に渡すのが決まりのようです。

最後に

contextの用途や具体的な使い方を学びました。

今回の記事では「Goroutineの適切なキャンセル」について学んだので
次回は「リクエスト情報の伝搬」について学んでいきます。

参考資料

Goの並行パターン:コンテキスト (Go Concurrency Pattern: Context)
Go1.7のcontextパッケージ
golangでcontextパッケージを使う
Go言語による並行処理

yoshinori_hisakawa
Go言語/クリーンアーキテクチャ/DDD/Docker/OOP/Angular/Java/設計/ Fringe81に興味ある方は、メールください! yoshinori_hisakawa@fringe81.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away