はじめに
こんにちは。TIG DX ユニット所属、金欠コンサルタントの藤井です。先日、Google Pixel 7aを購入しました。これまでiPhone 7 Plusを使っていたので、使用スマホの時代が7年ほど進みました。Googleは検索エンジンからAI、スマホまで作っていてすごいですね。
ということで今回は、Google発のプログラミング言語であるところのGoの1.21がリリースされることを記念した、Go 1.21 連載 の記事を書きます。
本記事では、いくつか変更の入った、contextパッケージについて記載していきます。
contextそのものについては、フューチャー技術ブログにおいても数多く解説されていますので、詳細な説明は割愛します。数例記載しますので、ぜひご覧ください。
- 辻さんのGo1.14のRelease Partyに登壇しました
- 記事の本質ではないですが、context自体の役割など、基礎情報が記載されています。
- 伊藤さんの「Contextを完全に理解する」というテーマでGo Conference 2021 Autumnに登壇しました
- context自体の実装について記載されています。
- 真野さんのGo1.20リリース連載 contextパッケージのWithCancelCauseとCause
- 1つ前の1.20においてcontextに追加された関数についての解説記事です。
変更点概要
Minor changes to the libraryとして、計4点の更新が加えられています。
- WithoutCancelの追加
- 親子
context間のキャンセルの伝播を切断し、親のcontextがキャンセル/タイムアウトした場合にも、自身のcontextがキャンセルされない子contextを作成できるようになりました。 - なお、
WithoutCancelという名前ではありますが、文字通りのキャンセルのみではなく、タイムアウトについても伝播されなくなります。- contextに設定した変数(ロガーやトレースID)などを利用したいけど、キャンセルは利用したくないなどの場合に利用できるとのことです
- 親子
- WithDeadlineCauseおよびWithTimeoutCauseの追加
- 先述のGo1.20リリース連載 contextパッケージのWithCancelCauseとCauseにて解説されている、
WithCancelCauseのDeadlineとTimeout版です。
- 先述のGo1.20リリース連載 contextパッケージのWithCancelCauseとCauseにて解説されている、
- AfterFuncの追加
- contextがキャンセル/タイムアウトした後に実行する処理を登録するための関数です。timeパッケージのAfterFuncと使い方はほとんど同じですが、使い道は大きく異なり、Exampleにあるように(当然ですが)contextに主軸をおいた物となっており、以下がExampleとして挙げられています。
- 直感的な使い方として、sync.Condの待ちを中断・net.Connの接続を遮断等の、不要な処理を止める使い道
- 複数のcontextをマージして、いずれかの
contextがキャンセルされると、マージ後のcontextもキャンセルされる、新たなcontextを作成する使い道- proposalによると、この使い道が発端のようです。
contextの持ち方自体に新しい手法が生まれるので、アイデア次第で化けそうです。
- proposalによると、この使い道が発端のようです。
- contextがキャンセル/タイムアウトした後に実行する処理を登録するための関数です。timeパッケージのAfterFuncと使い方はほとんど同じですが、使い道は大きく異なり、Exampleにあるように(当然ですが)contextに主軸をおいた物となっており、以下がExampleとして挙げられています。
- 最適化の結果として、
contextどうしを比較したときの振る舞いに変更が発生した- 比較の仕方によって、
contextどうしの比較結果が1.20以前と異なる場合があるようです。ただし、今までもこれからも、contextどうしの比較は明確に定義されていないため、いずれにせよcontextどうしの比較結果に依存した実装は危険かもしれません。 - こちらのissueで指摘が挙がったようです。
- 比較の仕方によって、
個人的にはWithoutCancelとAfterFuncがインパクト大きめのアップデートかなと思います。本記事ではWithoutCancelに着目して、実際に試してみた結果を記載していきます。
試す内容
contextを引き回すケースとして、Web APIサーバというのは代表例の1つかと思います。
私の所属しているプロジェクトでも、リクエストスコープごとにcontextを引き回しています。
そこで、こんなコードを考えてみます。
package main |
/apiというエンドポイントにリクエストを受けると、http.Requestから受け取ったcontextを引き回し、そのまま違うAPI /heavy にリクエストを送っています。/heavyはその名の通り重い処理を担当しているとし、常に10秒の処理時間がかかるようにしています。
今回は極めて簡易的に作っているため、自身に対してリクエストを送っています(さらにこちらではcontext未使用)が、異なるサーバであっても本質には影響しません。
さて、このサーバに対してCURL等で実際にリクエストを送ってから10秒待ってみると、特にレスポンス等も定義していないため、10秒後にログにCOMPLETE!!と出る以外全く何も起こりません。
しかし、耐えかねてCURLにSIGINTを送ってみたところ
$ go run . |
怒られてしまいました。どうやらどこかでcontextがキャンセルされてしまったようです。
net/httpパッケージの仕様を見てみると、クライアントとの接続が切断されると、contextはキャンセルされる、とあり、これの仕業のようです。
サービスによってはクライアントの通信環境が不安定(スマートフォンがメインターゲットで、トンネルに入るたびに圏外になるなど)な場合もあり、クライアントとの接続状況には十分な考慮が必要です。
今回の例では、実質的には何の処理も行っていないため、クライアントとの切断によりcontextがキャンセルされても困りませんが、実際には結構困る場面があります。
proposalでは、以下が例として挙げられています。
- rollback/cleanupの処理はトリガーイベント(HTTPリクエスト等)のキャンセル状況によらず継続する必要がある
- トリガーイベント(HTTPリクエスト等)の方が早く処理が完了した場合にも、処理を完遂したい
上記以外にも、システム間連携が多いことなどを原因として、リクエストからレスポンスまでの処理においてAtomicityが担保されていない場合、contextがキャンセルされるタイミングによってはデータ不整合が発生する可能性があります。
システム間連携も含めてAtomicであることが望ましいのは間違いないのですが、規模や要件によって、常に理想の設計ができるとは限りません。
ということで、今回はWithoutCancelを用いて、クライアントとの接続が切断した場合も、(サーバ側で問題が起きない限り)処理を継続するよう、改造してみます。
試してみた
対応内容は極めて単純で、http.Requestからcontextを受け取る箇所を、以下のように変更し、WithoutCancelで新しいcontextを作成するだけです。
必要に応じてWithCancelやWithTimeout等を重ねて使ってももちろんOKです。
ctx := context.WithoutCancel(r.Context()) |
この状態で再度CURLを叩き、すぐさま切断すると…
$ go run . |
親contextはキャンセルされているにも関わらず、10秒後に正しく処理が完了したようです。
これで、クライアントとの接続状況に左右されず、処理を完遂できるようになりました。
おわりに
Go 1.21でcontextパッケージに追加されるWithoutCancelを試してみました。
伝播してほしくない親のキャンセルを極めて簡単に無視できるようになった、という話なのですが、しかしながら問答無用で無視して良いものなのか、というところは慎重に考える必要があるかと思います。
キャンセルやタイムアウトの伝播は、それこそAtomicityやConsistencyを担保するために非常に有用なものであり、無闇に伝播を切断すると、実質的にcontextが大量に生まれてしまい、非常に難解なものになるリスクがあります。
proposalへのコメントにも、contextの単なるKVSとしての利用を促進しかねない、といった旨の危惧が記載されています。
導入前に、改めて「本当にこの時点で親contextのキャンセルは無視して良いのか?」を精査するべきかと思います。
明日は谷村さんの記事で、とうとうGoにmin/max関数が追加されるようです。お楽しみに!