[インデックス 14357] ファイルの概要
このコミットは、Go言語のnet
パッケージにおけるDialTimeout
関数の挙動を改善し、タイムアウト時にファイルディスクリプタ(FD)が早期に閉じられるようにするものです。これにより、特に多数の接続試行がタイムアウトするシナリオでのFDリークを防ぎ、リソースの枯渇を抑制します。また、Goの内部的なポーリングサーバー(pollserver
)との連携を強化することで、より効率的なリソース管理を実現しています。
コミット
commit ef6806fb13b1db44a57e4f26908803d55ed28e81
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Thu Nov 8 10:35:16 2012 -0600
net: close fds eagerly in DialTimeout
Integrates with the pollserver now.
Uses the old implementation on windows and plan9.
Fixes #2631
R=paul, iant, adg, bendaglish, rsc
CC=golang-dev
https://golang.org/cl/6815049
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/ef6806fb13b1db44a57e4f26908803d55ed28e81
元コミット内容
このコミットは、Go言語の標準ライブラリであるnet
パッケージにおいて、DialTimeout
関数がタイムアウトした場合に、関連するファイルディスクリプタ(FD)をより積極的に(eagerly)閉じるように変更します。これにより、リソースのリークを防ぎます。この改善は、Goの内部的なI/Oポーリング機構であるpollserver
との統合によって実現されています。ただし、WindowsおよびPlan 9オペレーティングシステムでは、既存の(古い)実装が引き続き使用されます。この変更は、Issue #2631を修正するものです。
変更の背景
このコミットの主な背景には、net.DialTimeout
関数を使用する際に発生する可能性のあるファイルディスクリプタ(FD)リークの問題がありました。従来のDialTimeout
の実装では、指定されたタイムアウト期間内に接続が確立できなかった場合、接続試行のために開かれたFDがすぐに閉じられず、ガベージコレクションによって後で回収されるまで開いたままになる可能性がありました。
特に、多数の接続試行がタイムアウトするようなシナリオ(例:到達不能なホストへの繰り返し接続、ネットワークの不安定な環境での接続)では、未クローズのFDが蓄積され、システムのリソースを枯渇させる「FDリーク」という問題を引き起こす可能性がありました。FDは有限なシステムリソースであり、その枯渇は新たな接続の確立を妨げたり、アプリケーション全体のパフォーマンス低下やクラッシュにつながる可能性があります。
このコミットは、このFDリークの問題に対処し、タイムアウトが発生した際にFDを「eagerly」(積極的に、即座に)閉じることで、リソースの早期解放とシステムの安定性向上を目指しています。コミットメッセージにあるFixes #2631
は、このFDリーク問題に関するGoのIssueトラッカー上の課題を指しています。
補足: Fixes #2631
という記述がありますが、現在のGoのIssueトラッカーで#2631
を検索すると、2024年に報告されたgithub.com/go-jose/go-jose
ライブラリの脆弱性(CVE-2024-28180、JWEのDoS脆弱性)に関する情報が表示されます。しかし、このコミットのタイムスタンプ(2012年11月8日)から判断すると、このコミットが修正しているのは、当時のGoのnet
パッケージにおけるFDリークに関する別のIssue #2631であると考えられます。GoのIssue番号は再利用されることがあるため、このような食い違いが発生することがあります。
前提知識の解説
Goのnet
パッケージ
net
パッケージは、Go言語の標準ライブラリの一部であり、ネットワークI/Oプリミティブへのポータブルなインターフェースを提供します。TCP/IP、UDP、Unixドメインソケットなどのネットワークプロトコルを扱うための機能が含まれています。クライアントとサーバーの両方のネットワークプログラミングをサポートし、接続の確立、データの送受信、リスニングなどの基本的な操作を提供します。
DialTimeout
関数
net.DialTimeout
関数は、指定されたネットワークアドレスへの接続を試みる際に、タイムアウトを設定できる関数です。通常のnet.Dial
関数とは異なり、接続試行が指定されたtime.Duration
以内に完了しない場合、関数はエラーを返して終了します。これは、ネットワークの遅延や到達不能なホストへの接続試行によってアプリケーションが長時間ブロックされるのを防ぐために重要です。
ファイルディスクリプタ (FD) とは
ファイルディスクリプタ(File Descriptor, FD)は、Unix系オペレーティングシステムにおいて、プロセスが開いているファイルやソケットなどのI/Oリソースを識別するために使用される抽象的なハンドルです。プログラムがファイルを開いたり、ネットワーク接続を確立したりすると、カーネルは対応するFDをプロセスに割り当てます。プログラムは、このFDを使用して、そのリソースに対する読み書きなどの操作を行います。FDは有限なリソースであり、システム全体で利用可能なFDの数には上限があります。
FDリークの問題
FDリークは、プログラムがFDを使い終わった後に適切に閉じない場合に発生する問題です。FDが閉じられないまま放置されると、システムが利用可能なFDの数を使い果たし、新たなファイルを開いたり、ネットワーク接続を確立したりできなくなります。これにより、アプリケーションの機能不全、パフォーマンス低下、最終的にはクラッシュにつながる可能性があります。特に、短期間に多数のI/O操作を行うアプリケーションや、エラー処理が不十分なアプリケーションで発生しやすい問題です。
Goのpollserver
(I/O多重化機構)
Goのランタイムには、効率的なネットワークI/Oを実現するための内部的なポーリングサーバー(pollserver
)が存在します。これは、Unix系システムにおけるepoll
(Linux)、kqueue
(FreeBSD/macOS)、poll
/select
などのI/O多重化メカニズムを抽象化したものです。pollserver
は、多数のネットワーク接続からのI/Oイベントを効率的に監視し、準備ができた接続に対してのみゴルーチンをスケジュールすることで、高い並行性とスケーラビリティを実現します。Goのネットワーク操作は、このpollserver
を介して非同期的に実行されます。
技術的詳細
このコミットの主要な技術的変更点は、net.DialTimeout
の内部実装がpollserver
と連携するように修正されたことです。
-
DialTimeout
の新しい実装:- 従来の
DialTimeout
は、接続試行を別のゴルーチンで実行し、メインのゴルーチンでタイマーを使ってタイムアウトを監視する「ゴルーチン競合(goroutine-racing)」モデルを採用していました。タイムアウトが発生した場合、接続試行中のゴルーチンはキャンセルされず、完了するまでリソース(FD)を保持し続ける可能性がありました。 - 新しい実装では、
DialTimeout
は接続のデッドライン(タイムアウト時刻)を計算し、このデッドラインをネットワーク操作のより深い層(resolveNetAddr
,dialAddr
、そして最終的にはinternetSocket
やsocket
関数)に渡すように変更されました。 - これにより、接続試行中に
pollserver
がデッドラインを認識し、タイムアウトが発生した際に、関連するFDをより積極的に閉じる(eagerly close
)ことが可能になります。
- 従来の
-
pollserver
との統合:src/pkg/net/fd_unix.go
内のpollServer
のCheckDeadlines
関数が、FDの読み書きデッドラインをより効率的にチェックし、期限切れのFDを適切に処理するように修正されています。netFD
構造体(ファイルディスクリプタをラップするGoの内部構造体)にwdeadline
(書き込みデッドライン)が設定され、connect
システムコールがEINPROGRESS
(非同期接続が進行中)を返した場合に、pollserver.WaitWrite
で待機する際にこのデッドラインが考慮されるようになりました。タイムアウトが発生すると、errTimeout
が返され、FDが閉じられます。
-
プラットフォーム固有の挙動:
const useDialTimeoutRace = runtime.GOOS == "windows" || runtime.GOOS == "plan9"
という定数が導入され、WindowsとPlan 9では、新しいpollserver
統合によるデッドライン処理ではなく、従来のゴルーチン競合によるdialTimeoutRace
関数が引き続き使用されます。- これは、これらのOSのネットワークI/OモデルやポーリングメカニズムがUnix系OSとは異なり、Goの
pollserver
のデッドライン処理を直接統合するのが困難であったためと考えられます。コミットメッセージには「TODO: remove this once those are implemented.」とあり、将来的にはこれらのプラットフォームでも新しい実装に移行する意図が示唆されています。
-
名前解決のタイムアウト:
src/pkg/net/lookup.go
のlookupHostDeadline
関数が追加され、DNS名前解決にもタイムアウトが適用されるようになりました。ただし、コミットメッセージには「TODO(bradfitz): consider pushing the deadline down into the name resolution functions. But that involves fixing it for the native Go resolver, cgo, Windows, etc. In the meantime, just use a goroutine. Most users affected by http://golang.org/issue/2631 are due to TCP connections to unresponsive hosts, not DNS.」とあり、当時はまだ名前解決のタイムアウト処理はゴルーチン競合モデルに依存しており、TCP接続のタイムアウトが主な焦点であったことがわかります。
これらの変更により、DialTimeout
はタイムアウト時に不要なFDをより迅速に解放し、システムリソースの効率的な利用と安定性向上に貢献します。
コアとなるコードの変更箇所
このコミットでは、主に以下のファイルが変更されています。
src/pkg/net/dial.go
:DialTimeout
関数の主要なロジックが変更され、デッドラインを内部関数に渡すようになりました。また、resolveNetAddr
やdialAddr
などのヘルパー関数もデッドライン引数を受け取るように変更されています。src/pkg/net/dial_test.go
:TestDialTimeoutFDLeak
という新しいテストが追加され、FDリークが修正されたことを検証しています。特にLinux環境でのFD数をチェックしています。src/pkg/net/fd_unix.go
: Unix系システムにおけるファイルディスクリプタの管理とpollserver
のデッドライン処理が修正されています。connect
関数内でタイムアウト時のエラー処理が追加されました。src/pkg/net/iprawsock.go
,src/pkg/net/iprawsock_plan9.go
,src/pkg/net/iprawsock_posix.go
: IPソケット関連の関数がデッドライン引数を受け取るように変更されています。src/pkg/net/ipsock.go
,src/pkg/net/ipsock_posix.go
: IPソケットの共通処理およびPOSIX固有の処理で、デッドライン引数が追加されています。src/pkg/net/lookup.go
: DNS名前解決にタイムアウトを適用するためのlookupHostDeadline
関数が追加されました。src/pkg/net/net.go
:noDeadline
というtime.Time{}
の定数が追加され、デッドラインがないことを示すために使用されます。src/pkg/net/sock_posix.go
:socket
関数がデッドライン引数を受け取るようになり、接続時にfd.wdeadline
を設定するロジックが追加されました。src/pkg/net/tcpsock.go
,src/pkg/net/tcpsock_plan9.go
,src/pkg/net/tcpsock_posix.go
: TCPソケット関連の関数がデッドライン引数を受け取るように変更されています。Plan 9ではデッドラインが未実装である旨のpanic
が追加されています。src/pkg/net/udpsock.go
,src/pkg/net/udpsock_plan9.go
,src/pkg/net/udpsock_posix.go
: UDPソケット関連の関数がデッドライン引数を受け取るように変更されています。Plan 9ではデッドラインが未実装である旨のpanic
が追加されています。src/pkg/net/unixsock_plan9.go
,src/pkg/net/unixsock_posix.go
: Unixドメインソケット関連の関数がデッドライン引数を受け取るように変更されています。Plan 9ではデッドラインが未実装である旨のpanic
が追加されています。
コアとなるコードの解説
src/pkg/net/dial.go
の変更
最も重要な変更はDialTimeout
関数とその周辺です。
// Old:
// func DialTimeout(net, addr string, timeout time.Duration) (Conn, error) {
// // TODO(bradfitz): the timeout should be pushed down into the
// // net package's event loop, so on timeout to dead hosts we
// // don't have a goroutine sticking around for the default of
// // ~3 minutes.
// t := time.NewTimer(timeout)
// defer t.Stop()
// // ... goroutine-racing implementation ...
// }
// New:
const useDialTimeoutRace = runtime.GOOS == "windows" || runtime.GOOS == "plan9"
func DialTimeout(net, addr string, timeout time.Duration) (Conn, error) {
if useDialTimeoutRace {
// On windows and plan9, use the relatively inefficient
// goroutine-racing implementation of DialTimeout that
// doesn't push down deadlines to the pollster.
// TODO: remove this once those are implemented.
return dialTimeoutRace(net, addr, timeout)
}
deadline := time.Now().Add(timeout) // タイムアウト時刻を計算
_, addri, err := resolveNetAddr("dial", net, addr, deadline) // デッドラインを渡す
if err != nil {
return nil, err
}
return dialAddr(net, addr, addri, deadline) // デッドラインを渡す
}
// dialTimeoutRace is the old implementation of DialTimeout, still used
// on operating systems where the deadline hasn't been pushed down
// into the pollserver.
// TODO: fix this on Windows and plan9.
func dialTimeoutRace(net, addr string, timeout time.Duration) (Conn, error) {
t := time.NewTimer(timeout)
defer t.Stop()
type pair struct {
conn Conn
err error
}
ch := make(chan pair, 1)
resolvedAddr := make(chan Addr, 1)
go func() {
_, addri, err := resolveNetAddr("dial", net, addr, noDeadline) // noDeadlineを渡す
if err != nil {
ch <- pair{nil, err}
return
}
resolvedAddr <- addri // in case we need it for OpError
c, err := dialAddr(net, addr, addri, noDeadline) // noDeadlineを渡す
ch <- pair{c, err}
}()
select {
case <-t.C:
return nil, &OpError{"dial", net + " " + addr, nil, errTimeout}
case r := <-ch:
return r.conn, r.err
}
}
// resolveNetAddr, dialAddr などの関数も time.Time deadline 引数を受け取るように変更
func resolveNetAddr(op, net, addr string, deadline time.Time) (afnet string, a Addr, err error) { ... }
func dialAddr(net, addr string, addri Addr, deadline time.Time) (c Conn, err error) { ... }
DialTimeout
は、useDialTimeoutRace
フラグに基づいて、新しいデッドラインベースの実装か、古いゴルーチン競合ベースのdialTimeoutRace
実装のどちらかを使用するように分岐します。- 新しい実装では、
time.Now().Add(timeout)
で具体的なタイムアウト時刻(deadline
)を計算し、このdeadline
をresolveNetAddr
やdialAddr
といった下位の関数に伝播させます。これにより、ネットワーク操作のより深い層でタイムアウトが処理されるようになります。 dialTimeoutRace
は、WindowsとPlan 9向けに残された古い実装です。ここではnoDeadline
(ゼロ値のtime.Time
)が渡され、デッドラインが内部的に処理されないことを示します。
src/pkg/net/fd_unix.go
の変更
Unix系システムでのFD管理とpollserver
の連携に関する変更です。
func (fd *netFD) connect(ra syscall.Sockaddr) error {
err := syscall.Connect(fd.sysfd, ra)
hadTimeout := fd.wdeadline > 0 // 以前に書き込みデッドラインが設定されていたか
if err == syscall.EINPROGRESS {
if err = fd.pollServer.WaitWrite(fd); err != nil {
return err
}
if hadTimeout && fd.wdeadline < 0 { // タイムアウトが発生し、デッドラインが期限切れになった場合
return errTimeout // errTimeoutを返す
}
var e int
e, err = syscall.GetsockoptInt(fd.sysfd, syscall.SOL_SOCKET, syscall.SO_ERROR)
if err != nil {
return err
}
// ...
}
return err
}
connect
関数は、非同期接続(syscall.EINPROGRESS
)の場合にfd.pollServer.WaitWrite(fd)
を呼び出して書き込み可能になるのを待ちます。hadTimeout
とfd.wdeadline < 0
のチェックが追加されました。これは、接続試行中にデッドラインが設定されており、かつWaitWrite
が返ってきた時点でデッドラインが期限切れ(-1
)になっていた場合に、errTimeout
を返すことで、タイムアウトを明示的に伝播させ、FDを早期に閉じさせるためのロジックです。
src/pkg/net/sock_posix.go
の変更
ソケット作成時にデッドラインを設定するロジックが追加されました。
// Old:
// func socket(net string, f, t, p int, ipv6only bool, ulsa, ursa syscall.Sockaddr, toAddr func(syscall.Sockaddr) Addr) (fd *netFD, err error) { ... }
// New:
func socket(net string, f, t, p int, ipv6only bool, ulsa, ursa syscall.Sockaddr, deadline time.Time, toAddr func(syscall.Sockaddr) Addr) (fd *netFD, err error) {
// ...
if ursa != nil { // リモートアドレスがある場合(接続の場合)
if !deadline.IsZero() { // デッドラインが設定されている場合
fd.wdeadline = deadline.UnixNano() // fdの書き込みデッドラインを設定
}
if err = fd.connect(ursa); err != nil {
closesocket(s)
fd.Close()
return nil, err
}
fd.isConnected = true
fd.wdeadline = 0 // 接続成功後、デッドラインをリセット
}
// ...
}
socket
関数はdeadline time.Time
引数を受け取るようになりました。- リモートアドレス(
ursa
)が指定されている場合(つまり、接続を確立する場合)、deadline
がゼロ値でない(有効なデッドラインが設定されている)ならば、fd.wdeadline
にそのデッドラインのUnixナノ秒値を設定します。 - これにより、
fd.connect
が呼び出された際に、pollserver
がこのデッドラインを考慮して接続試行を監視できるようになります。接続が成功した場合は、fd.wdeadline
は0
にリセットされます。
これらの変更により、DialTimeout
で設定されたタイムアウトがネットワークスタックのより深い層に伝播され、タイムアウト発生時にファイルディスクリプタがより迅速かつ確実に閉じられるようになりました。
関連リンク
- Go Gerrit Change-ID: https://golang.org/cl/6815049
参考にした情報源リンク
- Go言語のコミット情報 (
./commit_data/14357.txt
) - Go言語のGitHubリポジトリ
- Go言語のIssueトラッカー (Issue #2631に関する現在の情報と、コミット当時の文脈との乖離について)
- ファイルディスクリプタ、I/O多重化、
epoll
/kqueue
に関する一般的な知識