Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goのcontextパッケージを理解する

はじめに

こんにちは、サーバサイドエンジニアの中川(@tkdev0728)です。
2023年も早いもので1ヶ月がすぎようとしていますね。2023年は何よりも姿勢にこだわろうと思っています。いい猫背解消の方法があればぜひコメントにて教えてください。

さて、今回はタイトル通りGoのcontextパッケージについて公式ドキュメントをベースにやっていることを追ってみようと思います。実は私は恥ずかしながらヤプリに入社するまでGoのコードをまともに読んだことがなく、初めてGoのコードを読んだときによくメソッドの第一引数にcontextの値が指定されていて、これはどういう役割を担っているんだろうと感じたことを覚えています。
今までなんとなく使っていたのでこの機会にしっかりと役割を理解しようと思います!

以下で公式ドキュメントの記述をみていきますが、英語で書かれている記述を日本語に自動翻訳しているため一部不自然な表記があることを事前にご了承ください。

contextとは

公式ドキュメントpkg.go.devでは次のように記載されています。

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

日本語に訳すと次の通りです。

Package contextはContext型を定義し、デッドライン、キャンセルシグナル、その他のリクエストに対応した値をAPI境界やプロセス間で伝達します。

なぜcontextを使うのか

そんなcontextですが、使うと何が嬉しいのでしょうか?

Incoming requests to a server should create a Context, and outgoing calls to servers should accept a Context. The chain of function calls between them must propagate the Context, optionally replacing it with a derived Context created using WithCancel, WithDeadline, WithTimeout, or WithValue. When a Context is canceled, all Contexts derived from it are also canceled.

こちらも日本語に訳すと次の通りです。

サーバーへのリクエストはContextを作成する必要があり、サーバーへの発信はContext を受け入れる必要があります。これらの関数呼び出しの連鎖によってContextは伝播され、オプションでWithCancel、WithDeadline、WithTimeout、WithValueを使って作成した派生Contextに置き換わる必要があります。Contextがキャンセルされると、そこから派生したすべてのContextもキャンセルされます。

リクエスト/レスポンスの送受信にはContextを使うべきだという記述があります。そこでサーバとクライアントのやりとりを提供しているnet/httpパッケージの記述を覗いてみるとnet/httpパッケージもcontextメソッドを提供していることがわかりました。

pkg.go.dev

net/httpパッケージのContextの説明は以下です。

Context returns the request's context. To change the context, use Clone or WithContext.

The returned context is always non-nil; it defaults to the background context.

For outgoing client requests, the context controls cancellation.

For incoming server requests, the context is canceled when the client's connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.

日本語に訳すと次の通りです。

Context はリクエストのコンテキストを返します。コンテキストを変更するには、Clone または WithContext を使用します。

返されるコンテキストは常に non-nil で、デフォルトはバックグラウンドコンテキストです。

発信するクライアントリクエストは、このコンテキストによってキャンセルされます。

サーバへリクエストがきた場合、クライアントの接続が切れたとき、 リクエストがキャンセルされたとき (HTTP/2 の場合)、 ServeHTTP メソッドがreturnされたときにコンテキストはキャンセルされます。

どうやらクライアントからのリクエストにはcontextの値が含まれており、クライアントからのリクエストがキャンセルされた場合はこのcontextを使ってキャンセル処理を行うようです。 net/httpパッケージについては弊社の喜田が記事を執筆しているので興味のある方はそちらもご覧になってみてください。

tech.yappli.io

contextパッケージの話に戻ります。クライアントからのキャンセルはnet/httpパッケージ経由で行うことがわかりましたが、もしキャンセルやタイムアウトが発生したにも関わらずサーバ側の処理が進むとどうなるでしょうか?
クライアントにはキャンセル画面やタイムアウトした旨を通知することになると思いますが、サーバ側でデータ更新処理などを実施していた場合データ不整合に繋がってしまうため大変なことになるかと思います。 キャンセルやタイムアウトを受け付け、それを伝搬してサーバ側の処理もキャンセルするためにcontextが必要だとわかりました。contextの伝搬のためにWithCancel、WithDeadline、WithTimeout、WithCancelCause などの関数が使えそうですが、これらの関数はどうやって使うのでしょうか?

使い方

WithCancel

公式ドキュメントに記載されているWithCancelメソッドの説明はこちらです。

WithCancel returns a copy of parent with a new Done channel. The returned context's Done channel is closed when the returned cancel function is called or when the parent context's Done channel is closed, whichever happens first.

Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete.

日本語に訳すとこんな感じです。

WithCancel は、新しい Done チャンネルを持つ親コンテキストのコピーを返します。返されたコンテキストの Done チャンネルは、返されたキャンセル関数が呼ばれたとき、または親コンテキストの Done チャンネルが閉じられたときの、どちらか先に起こったときに閉じられます。

このコンテキストをキャンセルすると、それに関連するリソースが解放されるため、コードはこのコンテキストで実行中の操作が完了するとすぐに cancel を呼び出す必要があります。

テキストで書かれてもいまいち理解しづらいので簡単なサンプルコードを用意してみました。

package main

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

func parentFunc(ctx context.Context) {
    fmt.Println("start parentFunc")
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("parentFunc canceled")
            return
        default:
            time.Sleep(1 * time.Second)
        }
    }
    // cancelされると到達しない
    fmt.Println("parentFunc finished")
}

func childFunc(ctx context.Context) {
    fmt.Println("start childFunc")
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("childFunc canceled")
            return
        default:
            time.Sleep(1 * time.Second)
        }
    }
    // parentのcancelはここにも伝搬するので到達しない
    fmt.Println("childFunc finished")
}

func main() {
    fmt.Println("start main")
    defer fmt.Println("done main")
    ctx := context.Background()

    parent, cancel := context.WithCancel(ctx)
    go parentFunc(parent)

    child, _ := context.WithCancel(parent)
    go childFunc(child)

    time.Sleep(1 * time.Second)

    cancel()

    time.Sleep(3 * time.Second)
}

実行結果

start main
start parentFunc
start childFunc
childFunc canceled
parentFunc canceled
done main

Program exited.

main関数内の3行目でcontextを生成し、5行目でwithCancel関数を呼びだしています。親のcontextをparentFunc関数に渡した後にそのまま親のcontextを使って子のcontextを生成します。同じように子のcontextをchildFunc関数に渡した後に親のキャンセル処理を実行します。main関数の8行目で実行したWithCancel関数のキャンセルの返り値は捨てていますが、実行結果の方を見るとchildFunc関数もキャンセルされています。
このことから親のキャンセル処理は子へ伝搬されていることがわかるかと思いますが、親から子へのキャンセル処理の伝搬はできても子から親への伝搬はできないことに注意が必要です。
(この辺りを説明しようとするとそれだけで記事1本書けるくらいのボリュームになりそうなので本記事では割愛させていただきます。)

WithCancelCause

WithCancelCauseは執筆時点での最新バージョンである1.20で新しく追加された関数です。
tip.golang.org

WithCancelCause behaves like WithCancel but returns a CancelCauseFunc instead of a CancelFunc.
Calling cancel with a non-nil error (the "cause") records that error in ctx; it can then be retrieved using Cause(ctx). Calling cancel with nil sets the cause to Canceled.

日本語訳はこちら

WithCancelCauseはWithCancelのように振る舞いますが、CancelFuncの代わりにCancelCauseFuncを返します。
nilでないエラー("エラー原因")でcancelを呼び出すと、そのエラーがctxに記録されます。そしてそれはCause(ctx)を使って取得することができます。nilでcancelを呼び出すと、原因がCanceledに設定されます。

package main

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

func loop(ctx context.Context) {
    fmt.Println("start loop")
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            return
        default:
            fmt.Println("loop doing")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    fmt.Println("start main")
    defer fmt.Println("done main")
    ctx := context.Background()
    ctx, cancel := context.WithCancelCause(ctx)

    go loop(ctx)
    time.Sleep(2 * time.Second)
    cancel(errors.New("err ocucured"))
    defer fmt.Println(context.Cause(ctx))
}

実行結果

start main
start loop
loop doing
loop doing
loop doing
err ocucured
done main

Program exited.

このようにエラーを引数にしてキャンセル関数を実行すると、context.Cause()でエラー内容を取得できます。処理のどこでタイムアウトなどが発生したかがわかるようになります。

WithDeadline

WithDeadline returns a copy of the parent context with the deadline adjusted to be no later than d. If the parent's deadline is already earlier than d, WithDeadline(parent, d) is semantically equivalent to parent. The returned context's Done channel is closed when the deadline expires, when the returned cancel function is called, or when the parent context's Done channel is closed, whichever happens first.

Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete.

日本語に訳すとこんな感じです。

WithDeadline は、デッドラインが d よりも遅くならないように調整された親コンテキストのコピーを返します。 親のデッドラインが既に d よりも早い場合、 WithDeadline(parent, d) は意味的には親のデッドラインと同じです。返されたコンテキストの Done チャンネルは、デッドラインが切れたとき、返されたキャンセル関数が呼ばれたとき、 あるいは親コンテキストの Done チャンネルが閉じられたときのうち、いずれか早く起こったときに閉じられます。

このコンテキストをキャンセルすると、それに関連するリソースが解放されるので、コードはこの Context で実行中の処理が完了するとすぐに cancel を呼び出す必要があります。

ちょっと機械翻訳だとわかりづらいですが、要は指定した時間になったら自動でキャンセルしてくれる関数です。もしWithDeadline関数で指定された時間より先にキャンセル関数が呼ばれた場合、先のキャンセル関数が優先されます。

package main

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

func loop(ctx context.Context) {
    fmt.Println("start loop")
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            return
        default:
            fmt.Println(time.Now())
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    fmt.Println("start main")
    defer fmt.Println("done main")
    ctx := context.Background()

    // Go PlayGround(https://go.dev/play/)を使用している関係でtime.Now()の結果が2009年になっているのでデッドラインの日付も2009年を指定する
    d := time.Date(2009, 11, 10, 23, 00, 05, 0, time.UTC)
    ctx, cancel := context.WithDeadline(ctx, d)
    defer cancel()

    go loop(ctx)

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

実行結果

start main
start loop
2009-11-10 23:00:00 +0000 UTC m=+0.000000001
2009-11-10 23:00:01 +0000 UTC m=+1.000000001
2009-11-10 23:00:02 +0000 UTC m=+2.000000001
2009-11-10 23:00:03 +0000 UTC m=+3.000000001
2009-11-10 23:00:04 +0000 UTC m=+4.000000001
done
done main

Program exited.

WithTimeout

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

Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete:

日本語に訳すとこんな感じです。

WithTimeoutはWithDeadline(parent, time.Now().Add(timeout)) を返します。

このContextをキャンセルすると、それに関連するリソースが解放されるので、コードは、このContextで実行中の操作が完了したらすぐにcancelを呼び出す必要があります。

WithTimeoutとWithDeadlineの違いはWithDeadlineが指定した時刻になるとキャンセル関数が呼び出されるのに対し、WithTimeoutは指定した時間後にキャンセル関数が呼び出される点です。

package main

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

func loop(ctx context.Context) {
    fmt.Println("start loop")
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            return
        default:
            fmt.Println(time.Now())
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    fmt.Println("start main")
    defer fmt.Println("done main")
    ctx := context.Background()

    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    go loop(ctx)

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

実行結果

start main
start loop
2009-11-10 23:00:00 +0000 UTC m=+0.000000001
2009-11-10 23:00:01 +0000 UTC m=+1.000000001
2009-11-10 23:00:02 +0000 UTC m=+2.000000001
2009-11-10 23:00:03 +0000 UTC m=+3.000000001
2009-11-10 23:00:04 +0000 UTC m=+4.000000001
2009-11-10 23:00:05 +0000 UTC m=+5.000000001
done
done main

Program exited.

わかったこと

クライアントから来たキャンセルのリクエストやタイムアウトが発生した際、キャンセル処理の伝搬やタイムアウトの設定を適切に行うことでデータ不整合などを防ぐ狙いがあることが理解できました。
Go1.20から使えるようになったWithCancelCause()によってキャンセル理由も取得できるようになったのでこちらも積極的に使っていきたいですね。

まとめ

contextとは何かとその使い方について書いてみました。
今回は公式ドキュメントに沿って各関数の使い方を見ていっただけでしたが、今度はパッケージ内の実コードを読んでみようと思います。
ありがとうございました。
ヤプリに興味をもったという方や、もう少し具体的な話が聞いてみたいと思った方はぜひカジュアル面談にお越しください。