contextパッケージ
.NET (C#) の経験がある開発者向けに、キャンセル、タイムアウト、リクエストスコープ値の伝播を解説します。C# の CancellationToken に慣れていれば考え方の多くは流用できますが、Go では「キャンセル」「期限」「リクエスト単位の値」が context.Context という 1 つの型にまとめられている点が大きく異なります。
1. このドキュメントで扱うこと
- 第一引数に ctx を渡す慣習
- WithCancel / WithTimeout / WithDeadline
- context.Value の使いどころと注意点
第一引数に ctx を渡す慣習
Go では、キャンセルや期限を扱う可能性のある関数は、第一引数に ctx context.Context を受け取るのが慣習です。C# では CancellationToken を最後の任意引数にすることが多いので、位置が逆になります。
func GetUser(ctx context.Context, id int) (*User, error)
- 引数名は
ctxとします。 Contextを構造体のフィールドに保存せず、関数の引数として明示的に渡します。nilは渡しません。何を渡すか決まっていないときはcontext.TODO()、最上位(mainやテストの起点)ではcontext.Background()を使います。
WithCancel / WithTimeout / WithDeadline
親の Context から子の Context を派生させます。いずれも新しい Context と cancel 関数を返します。
| 関数 | 用途 |
|---|---|
context.WithCancel(parent) | 明示的に cancel() を呼んでキャンセルする |
context.WithTimeout(parent, d) | 指定した時間が経過すると自動でキャンセルされる |
context.WithDeadline(parent, t) | 指定した時刻に達すると自動でキャンセルされる |
WithTimeout(parent, d) は WithDeadline(parent, time.Now().Add(d)) と同等です。
キャンセルは親から子へ伝播します。親がキャンセルされると、そこから派生したすべての子もキャンセルされます。子をキャンセルしても親には影響しません。
context.Value の使いどころと注意点
context.WithValue でリクエストスコープの値(リクエスト ID、認証済みユーザー、トレース情報など)を運べます。ただし用途は限定的です。
- 関数の通常の入力パラメーターを運ぶ手段として使いません。引数で明示的に渡します。
- キーには、独自に定義した非公開の型を使います。
stringなどの組み込み型は他パッケージのキーと衝突するおそれがあります。 - 値は読み取り専用として扱います。上書きではなく、
WithValueで新しい子Contextを作ります。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
| CancellationToken | context.Context |
| CancellationTokenSource.CancelAfter | context.WithTimeout / WithCancel |
| HttpContext.Items | context.WithValue (用途は限定的) |
上の表を補う対応関係です。
| .NET (C#) | Go |
|---|---|
CancellationTokenSource.Cancel() | WithCancel が返す cancel() |
token.IsCancellationRequested | ctx.Err() != nil |
token.ThrowIfCancellationRequested() | if err := ctx.Err(); err != nil { return err } |
OperationCanceledException | context.Canceled / context.DeadlineExceeded (エラー値) |
token.Register(callback) | context.AfterFunc(ctx, f) (Go 1.21 以降) |
CancellationTokenSource.CreateLinkedTokenSource | 親から WithCancel などで派生させる (自動的に連結される) |
HttpContext.RequestAborted | r.Context() (*http.Request) |
Task.Delay(ms, token) | select で ctx.Done() と time.After / time.Timer を待つ |
3. C# 開発者がつまずきやすいポイント
キャンセルは協調的である
C# と同様、Go のキャンセルも強制ではありません。cancel() を呼んだだけでゴルーチンが止まるわけではなく、処理側が ctx.Done() を監視するか、ctx を受け取る API(http.NewRequestWithContext、database/sql の QueryContext など)に渡して初めて効果があります。ctx を受け取っても使わなければ、キャンセルは無視されます。
待機は select と ctx.Done() で書く
C# では await Task.Delay(ms, token) のように API がトークンを受け取って例外を投げます。Go ではチャネルベースなので、select で ctx.Done() と他のイベントを同時に待つ書き方が基本です。
cancel は必ず呼ぶ
WithCancel、WithTimeout、WithDeadline が返す cancel は、処理が終わったら必ず呼びます。呼ばないと、タイマーや親への登録などのリソースが期限まで解放されません。通常は取得の直後に defer cancel() と書きます。go vet の lostcancel チェックが呼び忘れを検出してくれます。
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
C# の using var cts = new CancellationTokenSource(...)(Dispose を保証する)に近い感覚です。
キャンセル理由はエラー値で判定する
キャンセルは例外ではなく、ctx.Err() が返すエラー値で表現されます。判定には errors.Is を使います。
| 状態 | ctx.Err() |
|---|---|
| 未キャンセル | nil |
cancel() が呼ばれた | context.Canceled |
| 期限を過ぎた | context.DeadlineExceeded |
Go 1.20 以降は context.WithCancelCause と context.Cause(ctx) で、より詳しい原因も取得できます。
Token と Source が分かれている点は似ているが、Context は不変
C# の CancellationToken (読み取り側) と CancellationTokenSource (操作側) の分離に似て、Go でも ctx (読み取り側) と cancel (操作側) は別の値です。ctx を受け取った関数は、そのキャンセルを勝手に実行できません。一方、Context は不変で、WithTimeout などは既存の ctx を変更せず新しい子を返します。C# の CancelAfter のように、既存のソースに対して後から期限を設定することはできません。
context.Value を DI コンテナや HttpContext.Items の代わりにしない
HttpContext.Items の感覚で何でも Value に入れると、関数の依存関係が型から読み取れなくなります。データベース接続、ロガー、設定などの依存はコンストラクターや引数で渡し、Value はリクエスト ID や認証情報のような、プロセス境界を越えて横断的に流れる値に絞ります。
取り出し時の型アサーションとキー型
ctx.Value(key) は any を返すため、型アサーションが必要です。キーは非公開の独自型にし、取得用のヘルパー関数を公開するのが定石です(次節の例を参照)。
親のキャンセルを切り離したい場合
リクエストがキャンセルされても続けたい後処理(監査ログの書き込みなど)では、Go 1.21 以降の context.WithoutCancel(ctx) を使うと、値は引き継ぎつつキャンセルと期限だけを切り離せます。意図的に使う場合に限定してください。
4. コード例
例 1: タイムアウト付きの HTTP リクエスト
C#:
public static async Task<string> FetchAsync(HttpClient client, string url, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2));
using var response = await client.GetAsync(url, cts.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cts.Token);
}
Go:
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
)
func fetch(ctx context.Context, url string) (string, error) {
// 親 ctx から 2 秒のタイムアウト付き子 ctx を派生させる
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // 必ず解放する
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func main() {
_, err := fetch(context.Background(), "https://example.com")
switch {
case errors.Is(err, context.DeadlineExceeded):
fmt.Println("タイムアウトしました")
case errors.Is(err, context.Canceled):
fmt.Println("キャンセルされました")
case err != nil:
fmt.Println("エラー:", err)
}
}
CreateLinkedTokenSource に相当する連結は、親 ctx から派生させるだけで自動的に行われます。
例 2: バックグラウンドワーカーのキャンセル
C#:
static async Task WorkerAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(500, ct); // キャンセルされると OperationCanceledException
Console.WriteLine("tick");
}
}
using var cts = new CancellationTokenSource();
var task = WorkerAsync(cts.Token);
await Task.Delay(1600);
cts.Cancel();
Go:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) error {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// キャンセル理由 (Canceled / DeadlineExceeded) を返す
return ctx.Err()
case <-ticker.C:
fmt.Println("tick")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- worker(ctx) }()
time.Sleep(1600 * time.Millisecond)
cancel() // ワーカーに停止を通知する
fmt.Println("worker stopped:", <-done)
}
ワーカー側は select で ctx.Done() を待ち、呼び出し側は cancel() を呼ぶだけです。ゴルーチンの終了を待つため、完了通知用のチャネル(または sync.WaitGroup)を併用します。
例 3: OS シグナルによるグレースフルシャットダウン
signal.NotifyContext を使うと、Ctrl+C などのシグナルを Context のキャンセルとして扱えます。Console.CancelKeyPress や IHostApplicationLifetime に近い使い方です。
package main
import (
"context"
"fmt"
"os/signal"
"syscall"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
fmt.Println("running... (Ctrl+C で終了)")
<-ctx.Done()
fmt.Println("shutting down:", ctx.Err())
}
例 4: リクエストスコープ値 (context.WithValue)
C# では HttpContext.Items にリクエスト ID を格納する場面です。
app.Use(async (context, next) =>
{
context.Items["RequestId"] = Guid.NewGuid().ToString();
await next();
});
// 取り出し
var id = (string?)context.Items["RequestId"];
Go では、非公開のキー型と、設定・取得用のヘルパー関数をセットで用意します。
package main
import (
"context"
"fmt"
"net/http"
)
// キー型は非公開にして、他パッケージとの衝突を防ぐ
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}
// ミドルウェア: リクエスト ID を ctx に付与する
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-12345" // 実際には UUID などを生成する
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handler(w http.ResponseWriter, r *http.Request) {
id, _ := RequestID(r.Context())
fmt.Fprintf(w, "request id: %s\n", id)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
http.ListenAndServe(":8080", requestIDMiddleware(mux))
}
r.Context() は、クライアントが切断されたりハンドラーが戻ったりするとキャンセルされます。ハンドラー内で呼ぶ下位の処理(データベースや他サービスへの呼び出し)にはこの ctx を渡し続けると、リクエストの中断に応じて処理も止まります。
例 5: 下位の処理に ctx を渡し続ける
func (s *Service) GetOrder(ctx context.Context, id int) (*Order, error) {
row := s.db.QueryRowContext(ctx, "SELECT id, total FROM orders WHERE id = $1", id)
var o Order
if err := row.Scan(&o.ID, &o.Total); err != nil {
return nil, fmt.Errorf("get order %d: %w", id, err)
}
return &o, nil
}
ctx を受け取った関数は、自分が呼ぶ ...Context 系の API にそのまま渡します。途中で context.Background() に差し替えると、そこから先はキャンセルが伝わりません。
5. まとめ
- Go の
context.Contextは、キャンセル、期限、リクエストスコープ値をまとめて運ぶ仕組みで、C# のCancellationTokenに相当する役割を中心に持ちます。 - 関数の第一引数に
ctxを置き、構造体に保存せず、呼び出しチェーンに沿って渡し続けます。 WithCancel/WithTimeout/WithDeadlineで子Contextを派生させ、返ってくるcancelはdefer cancel()で必ず呼びます。- キャンセルは協調的です。
selectのctx.Done()や...Context系 API を通じて処理側が応えることで初めて機能します。 - キャンセル理由は例外ではなくエラー値で、
errors.Is(err, context.Canceled)やerrors.Is(err, context.DeadlineExceeded)で判定します。 context.Valueは、リクエスト ID や認証情報のような横断的な値に限定し、非公開のキー型とヘルパー関数で扱います。依存関係や通常の引数の代わりにはしません。