フューチャー技術ブログ

Go 1.21 リリース連載 contextパッケージに追加されるWithoutCancelでクライアントとの切断に備えてみる

はじめに

こんにちは。TIG DX ユニット所属、金欠コンサルタントの藤井です。先日、Google Pixel 7aを購入しました。これまでiPhone 7 Plusを使っていたので、使用スマホの時代が7年ほど進みました。Googleは検索エンジンからAI、スマホまで作っていてすごいですね。

ということで今回は、Google発のプログラミング言語であるところのGoの1.21がリリースされることを記念した、Go 1.21 連載 の記事を書きます。

本記事では、いくつか変更の入った、contextパッケージについて記載していきます。

contextそのものについては、フューチャー技術ブログにおいても数多く解説されていますので、詳細な説明は割愛します。数例記載しますので、ぜひご覧ください。

変更点概要

Minor changes to the libraryとして、計4点の更新が加えられています。

  • WithoutCancelの追加
    • 親子context間のキャンセルの伝播を切断し、親のcontextがキャンセル/タイムアウトした場合にも、自身のcontextがキャンセルされない子contextを作成できるようになりました。
    • なお、WithoutCancelという名前ではありますが、文字通りのキャンセルのみではなく、タイムアウトについても伝播されなくなります。
      • contextに設定した変数(ロガーやトレースID)などを利用したいけど、キャンセルは利用したくないなどの場合に利用できるとのことです
  • WithDeadlineCauseおよびWithTimeoutCauseの追加
  • AfterFuncの追加
    • contextがキャンセル/タイムアウトした後に実行する処理を登録するための関数です。timeパッケージのAfterFuncと使い方はほとんど同じですが、使い道は大きく異なり、Exampleにあるように(当然ですが)contextに主軸をおいた物となっており、以下がExampleとして挙げられています。
      • 直感的な使い方として、sync.Condの待ちを中断net.Connの接続を遮断等の、不要な処理を止める使い道
      • 複数のcontextをマージして、いずれかのcontextがキャンセルされると、マージ後のcontextもキャンセルされる、新たなcontextを作成する使い道
        • proposalによると、この使い道が発端のようです。contextの持ち方自体に新しい手法が生まれるので、アイデア次第で化けそうです。
  • 最適化の結果として、contextどうしを比較したときの振る舞いに変更が発生した
    • 比較の仕方によって、contextどうしの比較結果が1.20以前と異なる場合があるようです。ただし、今までもこれからも、contextどうしの比較は明確に定義されていないため、いずれにせよcontextどうしの比較結果に依存した実装は危険かもしれません。
    • こちらのissueで指摘が挙がったようです。

個人的にはWithoutCancelAfterFuncがインパクト大きめのアップデートかなと思います。本記事ではWithoutCancelに着目して、実際に試してみた結果を記載していきます。

試す内容

contextを引き回すケースとして、Web APIサーバというのは代表例の1つかと思います。
私の所属しているプロジェクトでも、リクエストスコープごとにcontextを引き回しています。
そこで、こんなコードを考えてみます。

package main

import (
"fmt"
"net/http"
"time"
)

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

url := "http://localhost:8080/heavy"
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return
}

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
fmt.Println("COMPLETE!!")
}

func heavyHandler(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second)
return
}

func main() {
http.HandleFunc("/api", handler)
http.HandleFunc("/heavy", heavyHandler)
http.ListenAndServe(":8080", nil)
}

/apiというエンドポイントにリクエストを受けると、http.Requestから受け取ったcontextを引き回し、そのまま違うAPI /heavy にリクエストを送っています。
/heavyはその名の通り重い処理を担当しているとし、常に10秒の処理時間がかかるようにしています。
今回は極めて簡易的に作っているため、自身に対してリクエストを送っています(さらにこちらではcontext未使用)が、異なるサーバであっても本質には影響しません。

さて、このサーバに対してCURL等で実際にリクエストを送ってから10秒待ってみると、特にレスポンス等も定義していないため、10秒後にログにCOMPLETE!!と出る以外全く何も起こりません。
しかし、耐えかねてCURLSIGINTを送ってみたところ

$ go run .
Post "http://localhost:8080/heavy": context canceled

怒られてしまいました。どうやらどこかでcontextがキャンセルされてしまったようです。
net/httpパッケージの仕様を見てみると、クライアントとの接続が切断されると、contextはキャンセルされる、とあり、これの仕業のようです。
サービスによってはクライアントの通信環境が不安定(スマートフォンがメインターゲットで、トンネルに入るたびに圏外になるなど)な場合もあり、クライアントとの接続状況には十分な考慮が必要です。

今回の例では、実質的には何の処理も行っていないため、クライアントとの切断によりcontextがキャンセルされても困りませんが、実際には結構困る場面があります。
proposalでは、以下が例として挙げられています。

  • rollback/cleanupの処理はトリガーイベント(HTTPリクエスト等)のキャンセル状況によらず継続する必要がある
  • トリガーイベント(HTTPリクエスト等)の方が早く処理が完了した場合にも、処理を完遂したい

上記以外にも、システム間連携が多いことなどを原因として、リクエストからレスポンスまでの処理においてAtomicityが担保されていない場合、contextがキャンセルされるタイミングによってはデータ不整合が発生する可能性があります。
システム間連携も含めてAtomicであることが望ましいのは間違いないのですが、規模や要件によって、常に理想の設計ができるとは限りません。

image.png

ということで、今回はWithoutCancelを用いて、クライアントとの接続が切断した場合も、(サーバ側で問題が起きない限り)処理を継続するよう、改造してみます。

試してみた

対応内容は極めて単純で、http.Requestからcontextを受け取る箇所を、以下のように変更し、WithoutCancelで新しいcontextを作成するだけです。
必要に応じてWithCancelWithTimeout等を重ねて使ってももちろんOKです。

ctx := context.WithoutCancel(r.Context())

この状態で再度CURLを叩き、すぐさま切断すると…

$ go run .
COMPLETE!!

contextはキャンセルされているにも関わらず、10秒後に正しく処理が完了したようです。
これで、クライアントとの接続状況に左右されず、処理を完遂できるようになりました。

おわりに

Go 1.21でcontextパッケージに追加されるWithoutCancelを試してみました。
伝播してほしくない親のキャンセルを極めて簡単に無視できるようになった、という話なのですが、しかしながら問答無用で無視して良いものなのか、というところは慎重に考える必要があるかと思います。
キャンセルやタイムアウトの伝播は、それこそAtomicityやConsistencyを担保するために非常に有用なものであり、無闇に伝播を切断すると、実質的にcontextが大量に生まれてしまい、非常に難解なものになるリスクがあります。
proposalへのコメントにも、contextの単なるKVSとしての利用を促進しかねない、といった旨の危惧が記載されています。
導入前に、改めて「本当にこの時点で親contextのキャンセルは無視して良いのか?」を精査するべきかと思います。

明日は谷村さんの記事で、とうとうGoにmin/max関数が追加されるようです。お楽しみに!