[インデックス 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関数