[インデックス 12911] ファイルの概要
このコミットは、Go言語の標準ライブラリである net/http/httputil パッケージ内の ReverseProxy の改善に関するものです。具体的には、FlushInterval が設定された ReverseProxy を使用する際に発生していた maxLatencyWriter のゴルーチンリークと、それに伴う ResponseWriter がクローズされた後の Flush() 呼び出しによるパニックの問題を解決します。また、maxLatencyWriter の動作をテストで検証できるようにコードがリファクタリングされています。
コミット
commit 5694ebf057889444e8bbe97741004c4ecdcb7785
Author: Colby Ranger <cranger@google.com>
Date: Wed Apr 18 11:33:02 2012 -0700
net/http/httputil: Clean up ReverseProxy maxLatencyWriter goroutines.
When FlushInterval is specified on ReverseProxy, the ResponseWriter is
wrapped with a maxLatencyWriter that periodically flushes in a
goroutine. That goroutine was not being cleaned up at the end of the
request. This resulted in a panic when Flush() was being called on a
ResponseWriter that was closed.
The code was updated to always send the done message to the flushLoop()
goroutine after copying the body. Futhermore, the code was refactored to
allow the test to verify the maxLatencyWriter behavior.
R=golang-dev, bradfitz
CC=golang-dev
https://golang.org/cl/6033043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/5694ebf057889444e8bbe97741004c4ecdcb7785
元コミット内容
net/http/httputil: ReverseProxy の maxLatencyWriter ゴルーチンをクリーンアップします。
ReverseProxy に FlushInterval が指定されている場合、ResponseWriter は maxLatencyWriter でラップされ、ゴルーチン内で定期的にフラッシュされます。このゴルーチンはリクエストの終了時にクリーンアップされていませんでした。これにより、ResponseWriter がクローズされた後に Flush() が呼び出されたときにパニックが発生していました。
ボディのコピー後、flushLoop() ゴルーチンに常に完了メッセージを送信するようにコードが更新されました。さらに、maxLatencyWriter の動作をテストで検証できるようにコードがリファクタリングされました。
変更の背景
この変更の背景には、Go言語の net/http/httputil パッケージが提供する ReverseProxy の特定の利用シナリオにおけるリソースリークと実行時パニックの問題がありました。
ReverseProxy は、HTTPリクエストを別のサーバーに転送し、その応答をクライアントにプロキシする機能を提供します。この際、FlushInterval という設定項目があります。これは、プロキシがバックエンドからの応答をクライアントに転送する際に、指定された間隔で応答バッファを強制的にフラッシュ(送信)する機能です。これは、特にストリーミング応答や、応答が長時間にわたる場合に、クライアントが応答の一部をより早く受け取れるようにするために使用されます。
FlushInterval が設定されると、ReverseProxy は内部的に maxLatencyWriter というカスタムの io.Writer を使用して ResponseWriter をラップします。この maxLatencyWriter は、別のゴルーチン(flushLoop())を起動し、そのゴルーチンがタイマーを使って定期的に ResponseWriter の Flush() メソッドを呼び出します。
問題は、この flushLoop() ゴルーチンがリクエストの終了時に適切に終了されず、リークしていた点にありました。ゴルーチンがリークすると、システムのリソース(メモリ、CPU時間)を不必要に消費し続け、アプリケーション全体のパフォーマンス低下や不安定化につながります。
さらに深刻な問題として、リークしたゴルーチンが、すでにクローズされた ResponseWriter に対して Flush() を呼び出そうとすると、Goのランタイムでパニック(プログラムの異常終了)が発生するというものでした。これは、クローズされたリソースへの不正なアクセスであり、アプリケーションのクラッシュを引き起こします。
このコミットは、これらの問題を解決し、ReverseProxy の堅牢性と信頼性を向上させることを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGo言語およびHTTPプロトコルに関する前提知識が必要です。
-
net/http/httputil.ReverseProxy:- Go言語の標準ライブラリ
net/http/httputilパッケージに含まれる構造体で、HTTPリバースプロキシを実装するためのものです。 - クライアントからのHTTPリクエストを受け取り、それを別の(通常はバックエンドの)HTTPサーバーに転送し、バックエンドからの応答をクライアントに転送します。
ServeHTTPメソッドを実装しており、http.Handlerインターフェースを満たします。FlushIntervalフィールドは、プロキシがバックエンドからの応答をクライアントに転送する際に、指定された時間間隔で応答バッファを強制的にフラッシュするかどうかを制御します。これにより、クライアントは応答の一部をより早く受け取ることができます。
- Go言語の標準ライブラリ
-
http.ResponseWriter:- HTTP応答をクライアントに書き込むためのインターフェースです。
Write([]byte) (int, error)メソッドで応答ボディを書き込みます。WriteHeader(statusCode int)メソッドでHTTPステータスコードを設定します。Header()メソッドで応答ヘッダーにアクセスします。- 一部の
ResponseWriterの実装(例:http.ResponseControllerやhttp.Flusherインターフェースを実装する型)は、Flush()メソッドを提供し、バッファリングされた応答データを強制的にクライアントに送信することができます。
-
io.Writerインターフェース:- Go言語の基本的なI/Oインターフェースの一つで、データを書き込むことができる任意の型が満たすべきものです。
Write([]byte) (n int, err error)メソッドを持ちます。http.ResponseWriterもio.Writerインターフェースを満たします。
-
goroutine(ゴルーチン):- Go言語における軽量な並行実行単位です。OSのスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行することも可能です。
goキーワードを使って関数呼び出しの前に置くことで、その関数を新しいゴルーチンで実行します。- ゴルーチンは、明示的に終了するか、プログラム全体が終了するまで実行され続けます。適切に終了させないと、リソースリークの原因となります。
-
channel(チャネル):- Go言語におけるゴルーチン間の通信手段です。
- チャネルを通じて値を送受信することで、ゴルーチン間で安全にデータを共有し、同期を取ることができます。
make(chan Type)で作成し、ch <- valueで送信、value := <-chで受信します。- このコミットでは、ゴルーチンに終了シグナルを送るために使用されます。
-
panic(パニック):- Go言語における回復不可能なエラー状態です。
- 通常、プログラムのバグや、予期せぬ異常な状態(例: nilポインタのデリファレンス、クローズされたチャネルへの書き込み、クローズされたリソースへのアクセス)が発生した場合に引き起こされます。
- パニックが発生すると、現在のゴルーチンの実行が停止し、遅延関数(
defer)が実行された後、コールスタックを遡りながらパニックが伝播します。最終的に、mainゴルーチンでパニックが処理されない場合、プログラム全体がクラッシュします。
-
deferステートメント:- Go言語のキーワードで、その関数がリターンする直前(またはパニックが発生してスタックがアンワインドされる際)に、指定された関数呼び出しを遅延実行させます。
- リソースのクリーンアップ(ファイルのクローズ、ロックの解放など)によく使用されます。このコミットでは、ゴルーチンを停止させるために使用されています。
-
io.Copy:ioパッケージの関数で、io.Readerからio.Writerへデータをコピーします。- 効率的にデータを転送するために内部的にバッファを使用します。
技術的詳細
このコミットの技術的詳細は、ReverseProxy の FlushInterval 機能の実装におけるゴルーチン管理の不備と、その修正方法に集約されます。
問題点:
-
ゴルーチンリーク:
ReverseProxyがFlushIntervalを持つ場合、応答をクライアントに書き込むResponseWriterはmaxLatencyWriterでラップされます。maxLatencyWriterは、そのWriteメソッドが最初に呼び出されたときにflushLoop()というゴルーチンを起動していました。このflushLoop()は、FlushIntervalごとにResponseWriterのFlush()メソッドを呼び出す役割を担っていました。- しかし、この
flushLoop()ゴルーチンを明示的に終了させるメカニズムが不十分でした。以前のコードでは、Writeメソッド内でエラーが発生した場合にのみm.done <- trueを送信していましたが、正常にコピーが完了した場合や、リクエストが終了した場合にゴルーチンが終了する保証がありませんでした。 - 結果として、リクエストが完了しても
flushLoop()ゴルーチンがバックグラウンドで実行され続け、リソース(特にメモリ)を消費し続ける「ゴルーチンリーク」が発生していました。
-
クローズされた
ResponseWriterへのFlush()呼び出しによるパニック:- ゴルーチンリークにより、リクエストが終了し、基盤となる
ResponseWriterがすでにクローズされているにもかかわらず、リークしたflushLoop()ゴルーチンがFlushIntervalのタイマーによってm.dst.Flush()を呼び出そうとすることがありました。 - クローズされた
ResponseWriterに対してFlush()を呼び出すことは不正な操作であり、Goのランタイムはこれを検知してパニックを引き起こし、アプリケーションがクラッシュしていました。
- ゴルーチンリークにより、リクエストが終了し、基盤となる
解決策:
このコミットは、以下の2つの主要な変更によってこれらの問題を解決します。
-
flushLoop()ゴルーチンの確実な終了:maxLatencyWriterにdoneチャネルを追加し、このチャネルを通じてflushLoop()ゴルーチンに終了シグナルを送るメカニズムを導入しました。ReverseProxy.ServeHTTPメソッド内で、copyResponseという新しいヘルパー関数を導入し、この関数内でmaxLatencyWriterを初期化する際にmlw.doneチャネルを作成します。- 最も重要な変更は、
copyResponse関数内でdefer mlw.stop()を追加したことです。mlw.stop()メソッドはm.done <- trueを実行し、flushLoop()ゴルーチンに終了シグナルを送信します。deferを使用することで、copyResponse関数が正常に終了した場合でも、エラーで終了した場合でも、必ずstop()が呼び出され、ゴルーチンがクリーンアップされることが保証されます。 flushLoop()内のselectステートメントにcase <-m.done: returnを追加し、doneチャネルからの受信を監視することで、終了シグナルを受け取った際にゴルーチンが即座に終了するようにしました。
-
テスト容易性の向上:
beforeCopyResponseというグローバル変数(テスト目的でエクスポートされていない)を追加しました。これは、io.Writerがio.Copyに渡される直前の状態をテストが傍受できるようにするためのコールバック関数です。- これにより、
TestReverseProxyFlushIntervalという新しいテストケースが追加され、maxLatencyWriterが正しく使用され、かつゴルーチンがリークしないことを検証できるようになりました。テストでは、多数のリクエストを処理した後、runtime.NumGoroutine()を使用してゴルーチンの数が大幅に増加していないことを確認しています。
これらの変更により、ReverseProxy の FlushInterval 機能はより堅牢になり、リソースリークやパニックのリスクが排除されました。
コアとなるコードの変更箇所
このコミットにおける主要なコード変更は、src/pkg/net/http/httputil/reverseproxy.go と src/pkg/net/http/httputil/reverseproxy_test.go の2つのファイルにわたります。
src/pkg/net/http/httputil/reverseproxy.go
-
beforeCopyResponseグローバル変数の追加:// beforeCopyResponse is a callback set by tests to intercept the state of the // output io.Writer before the data is copied to it. var beforeCopyResponse func(dst io.Writer)テスト目的で、
io.Copyにデータがコピーされる直前のio.Writerの状態を傍受するためのフックが追加されました。 -
ReverseProxy.ServeHTTPメソッドの変更:- 以前は
res.Bodyのコピーロジックが直接このメソッド内にありました。 - 新しい
copyResponseヘルパー関数を呼び出すように変更されました。 defer res.Body.Close()が追加され、応答ボディが確実にクローズされるようになりました。
- 以前は
-
copyResponseヘルパー関数の追加:func (p *ReverseProxy) copyResponse(dst io.Writer, src io.Reader) { if p.FlushInterval != 0 { if wf, ok := dst.(writeFlusher); ok { mlw := &maxLatencyWriter{ dst: wf, latency: p.FlushInterval, done: make(chan bool), // doneチャネルの初期化 } go mlw.flushLoop() // flushLoopゴルーチンの起動 defer mlw.stop() // deferでstop()を呼び出し、ゴルーチンを確実に終了させる dst = mlw } } if beforeCopyResponse != nil { beforeCopyResponse(dst) // テスト用フック } io.Copy(dst, src) // 実際のデータコピー }応答ボディのコピーと
maxLatencyWriterの管理ロジックがこの関数に分離されました。特に、maxLatencyWriterのdoneチャネルの初期化、flushLoopゴルーチンの起動、そしてdefer mlw.stop()によるゴルーチンの確実な終了がここで行われます。 -
maxLatencyWriter構造体の変更:lk sync.Mutexのコメントが// protects init of done, as well Write + Flushから// protects Write + Flushに変更されました。これは、doneチャネルの初期化がWriteメソッド内ではなく、copyResponse関数で行われるようになったためです。
-
maxLatencyWriter.Writeメソッドの変更:- 以前は
Writeメソッド内でdoneチャネルの初期化とflushLoopゴルーチンの起動を行っていましたが、これらがcopyResponseに移動したため、シンプルにm.dst.Write(p)を呼び出すだけになりました。
- 以前は
-
maxLatencyWriter.flushLoopメソッドの変更:selectステートメント内のcase <-m.done:の位置が変更され、タイマーのケースよりも前に来るようになりました。これにより、終了シグナルが優先的に処理され、ゴルーチンがより早く終了できるようになります。
-
maxLatencyWriter.stopメソッドの追加:func (m *maxLatencyWriter) stop() { m.done <- true }flushLoopゴルーチンに終了シグナルを送信するためのシンプルなヘルパーメソッドが追加されました。
src/pkg/net/http/httputil/reverseproxy_test.go
TestReverseProxyFlushIntervalテスト関数の追加:FlushIntervalが設定されたReverseProxyの動作を検証するための新しいテストケースです。beforeCopyResponseフックを使用して、maxLatencyWriterが正しく使用されていることを確認します。runtime.NumGoroutine()を使用して、多数のリクエスト(100回)を処理した後でもゴルーチンがリークしていないことを検証します。これにより、ゴルーチンリークの問題が解決されたことを確認します。
コアとなるコードの解説
reverseproxy.go の変更点
-
copyResponse関数の導入とdefer mlw.stop(): このコミットの最も重要な変更は、ReverseProxy.ServeHTTPから応答ボディのコピーロジックをcopyResponseという新しい関数に分離したことです。func (p *ReverseProxy) copyResponse(dst io.Writer, src io.Reader) { if p.FlushInterval != 0 { if wf, ok := dst.(writeFlusher); ok { mlw := &maxLatencyWriter{ dst: wf, latency: p.FlushInterval, done: make(chan bool), // 新しいチャネルを作成 } go mlw.flushLoop() // flushLoopゴルーチンを起動 defer mlw.stop() // ★ここが重要★ 関数終了時に必ずstop()を呼び出す dst = mlw } } // ... (beforeCopyResponseとio.Copy) }ここで注目すべきは
defer mlw.stop()です。deferステートメントは、その関数(この場合はcopyResponse)がリターンする直前に指定された関数呼び出しを遅延実行させます。これにより、copyResponseが正常に完了した場合でも、io.Copyでエラーが発生した場合でも、必ずmlw.stop()が呼び出され、maxLatencyWriterのflushLoopゴルーチンに終了シグナルが送信されることが保証されます。これにより、ゴルーチンリークが防止されます。 -
maxLatencyWriter.stop()メソッドの追加:func (m *maxLatencyWriter) stop() { m.done <- true }このシンプルなメソッドは、
maxLatencyWriterのdoneチャネルにtrueを送信します。これは、flushLoopゴルーチンに「もう作業は終わったので終了してよい」というシグナルを送るためのものです。 -
maxLatencyWriter.flushLoop()の改善:func (m *maxLatencyWriter) flushLoop() { t := time.NewTicker(m.latency) defer t.Stop() for { select { case <-m.done: // doneチャネルからの受信を監視 return // シグナルを受け取ったらゴルーチンを終了 case <-t.C: m.lk.Lock() m.dst.Flush() m.lk.Unlock() } } // panic("unreached") は到達しないコードなので削除 }selectステートメント内でm.doneチャネルからの受信を監視するcaseが追加されました。これにより、stop()メソッドが呼び出されてdoneチャネルに値が送信されると、flushLoopゴルーチンは即座にreturnし、適切に終了します。以前のバージョンでは、doneチャネルへの送信はWriteメソッドのエラーパスでのみ行われており、正常終了時のクリーンアップが不足していました。 -
maxLatencyWriter.Write()の簡素化: 以前はWriteメソッド内でflushLoopゴルーチンの起動とdoneチャネルの初期化を行っていましたが、これらはcopyResponse関数に移動したため、Writeメソッドは単に基盤となるResponseWriterにデータを書き込むだけのシンプルな役割になりました。
reverseproxy_test.go の変更点
TestReverseProxyFlushIntervalの追加: この新しいテストは、FlushIntervalが設定されたReverseProxyが正しく動作し、ゴルーチンリークが発生しないことを検証します。beforeCopyResponseグローバル変数に匿名関数を割り当て、maxLatencyWriterがio.Copyに渡されることを確認します。runtime.NumGoroutine()を使用して、テスト開始時と終了時のゴルーチン数を比較します。多数のリクエスト(100回)を処理した後でも、ゴルーチン数の増加が許容範囲内(この場合は50個以内)であることを確認することで、ゴルーチンリークがないことを検証しています。これは、Goのテストでリソースリークを検出する一般的な手法です。
これらの変更により、ReverseProxy の FlushInterval 機能はより堅牢になり、リソースリークやパニックのリスクが排除されました。
関連リンク
- https://golang.org/cl/6033043 (Go Gerrit Code Review)
参考にした情報源リンク
- Go言語の公式ドキュメント (
net/http,io,sync,timeパッケージ) - Go言語の並行処理に関する一般的な知識 (ゴルーチン、チャネル、
defer) - Go言語におけるパニックとエラーハンドリングに関する一般的な知識
- Go言語のテストに関する一般的な知識 (
runtime.NumGoroutine()) - Go言語のdeferについて
- Go言語の並行処理: Goroutines and Channels
- Go言語のnet/httpパッケージ
- Go言語のnet/http/httputilパッケージ
- Go言語のioパッケージ
- Go言語のsyncパッケージ
- Go言語のtimeパッケージ
- Go言語のruntimeパッケージ
- Go言語のテスト
- Go言語におけるリソースリークのデバッグ (一般的な情報源として)
- Go言語におけるパニックの発生と対処 (一般的な情報源として)
- Go言語のReverseProxyのFlushIntervalに関する議論 (関連する可能性のあるGitHub Issue)
- Go言語のhttp.Flusherインターフェース (Flush()機能の背景として)
- Go言語のhttp.ResponseController (Go 1.20以降でResponseWriterの拡張機能を提供するものとして)
- Go言語のio.Copy関数