Go1.21で追加されたWithoutCancelでcontext canceledの発生を防ぐ

こんにちは、Voicyエンジニアの窪田です。 この記事はVoicyエンジニア GWブログリレー 2025の3日目の投稿になります。


GoのWebフレームワークであるEchoを使ってサーバーを開発している際、クライアントがブラウザのタブを閉じると、サーバー側で context canceled エラーが発生することがあります。これは、リクエスト処理中にクライアントとの接続が切断されたために起こります。

Go1.21で導入された context.WithoutCancel 関数を利用することで、この context canceled エラーを回避し、サーバー側の処理を継続させることが可能になります。本記事では、その具体的な方法と注意点について解説します。

context canceled エラーが発生する状況

Echoでは、リクエストハンドラーに渡される echo.Context は、Goの context を内包しています。クライアントがタブを閉じたり、リクエストを中断したりすると、このコンテキストがキャンセルされ、それ以降の処理でエラーが発生する場合があります。

例えば、以下のようなEchoのハンドラーがあったとします。

func handler(c echo.Context) error {
    ctx := c.Request().Context()
    <-ctx.Done() // クライアントが接続を切ると、このチャネルが閉じられる
    fmt.Println("クライアントが接続を切断しました")
    return nil
}

このハンドラーでは、クライアントが接続を切断すると ctx.Done() が閉じられ、メッセージが出力されます。しかし、このコンテキストを利用してデータベースアクセスや外部API呼び出しなどの時間のかかる処理を行っている場合、それらの処理も途中で中断され、エラーが発生する可能性があります。

context.WithoutCancel の導入

Go1.21で追加された context.WithoutCancel 関数は、親コンテキストから派生する新しいコンテキストを作成しますが、親コンテキストのキャンセルシグナルを受け継ぎません。つまり、親コンテキストがキャンセルされても、 context.WithoutCancel で作成されたコンテキストはキャンセルされません。

これを利用することで、クライアントからの接続が切断されても、サーバー側で実行したい処理を継続させることができます。

Echoのハンドラーで context.WithoutCancel を使用する例を見てみましょう。

func handler(c echo.Context) error {
    // キャンセルされないコンテキストを作成
    ctx := context.WithoutCancel(c.Request().Context())

    // 時間のかかる処理をバックグラウンドで実行
    go func(ctx context.Context) {
        fmt.Println("バックグラウンド処理を開始します...")
        select {
        case <-time.After(5 * time.Second):
            fmt.Println("バックグラウンド処理が完了しました")
        case <-ctx.Done():
            // これは表示されない
            fmt.Println("バックグラウンド処理がキャンセルされました")
        }
    }(ctx)

    return c.String(http.StatusOK, "リクエスト処理完了")
}

この例では、リクエストハンドラー内で context.WithoutCancel を使って新しいコンテキスト ctx を作成し、このコンテキストを並列処理に渡しています。クライアントがタブを閉じても、元のリクエストコンテキストはキャンセルされますが、ctx はキャンセルされないため、5秒間の time.After による処理は中断されずに完了します。

context.WithoutCancel の注意点とタイムアウト設定

context.WithoutCancel を使用する際の注意点として、タイムアウトの設定が無効になることが挙げられます。親コンテキストに設定されていたタイムアウトやデッドラインは、 context.WithoutCancel で作成されたコンテキストには引き継がれません。

もし、キャンセルされないコンテキスト上でタイムアウトを設定したい場合は、 context.WithTimeoutcontext.WithDeadlinecontext.WithoutCancel の結果に対して明示的に適用する必要があります。

func handlerWithTimeout(c echo.Context) error {
    // キャンセルされないコンテキストを作成
    withoutCancelCtx := context.WithoutCancel(c.Request().Context())

    // 新たにタイムアウトを設定
    ctx, _ := context.WithTimeout(withoutCancelCtx, 10*time.Second)

    // 時間のかかる処理をバックグラウンドで実行
    go func(ctx context.Context) {
        fmt.Println("バックグラウンド処理(タイムアウト付き)を開始します...")
        select {
        case <-time.After(15 * time.Second):
            fmt.Println("バックグラウンド処理(タイムアウト付き)が完了しました")
        case <-ctx.Done():
            fmt.Println("バックグラウンド処理(タイムアウト付き)がタイムアウトしました")
        }
    }(ctx)

    return c.String(http.StatusOK, "リクエスト処理完了。タブを閉じてもバックグラウンドで処理が継続される(最大10秒)")
}

上記の例では、 handlerWithTimeout 関数内で context.WithoutCancel で作成したコンテキストに対して、さらに context.WithTimeout を適用しています。これにより、クライアントがタブを閉じても処理は継続されますが、最長で10秒のタイムアウトが設定されます。

まとめ

Go1.21で導入された context.WithoutCancel は、EchoなどのWebサーバーアプリケーションにおいて、クライアントの切断による context canceled エラーを回避し、サーバー側で必要な処理を継続させることができます。

ただし、 context.WithoutCancel を使用すると親コンテキストのタイムアウト設定が無効になるため、必要に応じて明示的にタイムアウトを設定することを忘れないようにしましょう。