メインコンテンツまでスキップ

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
CancellationTokencontext.Context
CancellationTokenSource.CancelAftercontext.WithTimeout / WithCancel
HttpContext.Itemscontext.WithValue (用途は限定的)

上の表を補う対応関係です。

.NET (C#)Go
CancellationTokenSource.Cancel()WithCancel が返す cancel()
token.IsCancellationRequestedctx.Err() != nil
token.ThrowIfCancellationRequested()if err := ctx.Err(); err != nil { return err }
OperationCanceledExceptioncontext.Canceled / context.DeadlineExceeded (エラー値)
token.Register(callback)context.AfterFunc(ctx, f) (Go 1.21 以降)
CancellationTokenSource.CreateLinkedTokenSource親から WithCancel などで派生させる (自動的に連結される)
HttpContext.RequestAbortedr.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 や認証情報のような横断的な値に限定し、非公開のキー型とヘルパー関数で扱います。依存関係や通常の引数の代わりにはしません。

参考資料​