Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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と連携するように修正されたことです。

  1. DialTimeoutの新しい実装:

    • 従来のDialTimeoutは、接続試行を別のゴルーチンで実行し、メインのゴルーチンでタイマーを使ってタイムアウトを監視する「ゴルーチン競合(goroutine-racing)」モデルを採用していました。タイムアウトが発生した場合、接続試行中のゴルーチンはキャンセルされず、完了するまでリソース(FD)を保持し続ける可能性がありました。
    • 新しい実装では、DialTimeoutは接続のデッドライン(タイムアウト時刻)を計算し、このデッドラインをネットワーク操作のより深い層(resolveNetAddr, dialAddr、そして最終的にはinternetSocketsocket関数)に渡すように変更されました。
    • これにより、接続試行中にpollserverがデッドラインを認識し、タイムアウトが発生した際に、関連するFDをより積極的に閉じる(eagerly close)ことが可能になります。
  2. pollserverとの統合:

    • src/pkg/net/fd_unix.go内のpollServerCheckDeadlines関数が、FDの読み書きデッドラインをより効率的にチェックし、期限切れのFDを適切に処理するように修正されています。
    • netFD構造体(ファイルディスクリプタをラップするGoの内部構造体)にwdeadline(書き込みデッドライン)が設定され、connectシステムコールがEINPROGRESS(非同期接続が進行中)を返した場合に、pollserver.WaitWriteで待機する際にこのデッドラインが考慮されるようになりました。タイムアウトが発生すると、errTimeoutが返され、FDが閉じられます。
  3. プラットフォーム固有の挙動:

    • 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.」とあり、将来的にはこれらのプラットフォームでも新しい実装に移行する意図が示唆されています。
  4. 名前解決のタイムアウト:

    • src/pkg/net/lookup.golookupHostDeadline関数が追加され、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関数の主要なロジックが変更され、デッドラインを内部関数に渡すようになりました。また、resolveNetAddrdialAddrなどのヘルパー関数もデッドライン引数を受け取るように変更されています。
  • 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)を計算し、このdeadlineresolveNetAddrdialAddrといった下位の関数に伝播させます。これにより、ネットワーク操作のより深い層でタイムアウトが処理されるようになります。
  • 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)を呼び出して書き込み可能になるのを待ちます。
  • hadTimeoutfd.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.wdeadline0にリセットされます。

これらの変更により、DialTimeoutで設定されたタイムアウトがネットワークスタックのより深い層に伝播され、タイムアウト発生時にファイルディスクリプタがより迅速かつ確実に閉じられるようになりました。

関連リンク

参考にした情報源リンク

  • Go言語のコミット情報 (./commit_data/14357.txt)
  • Go言語のGitHubリポジトリ
  • Go言語のIssueトラッカー (Issue #2631に関する現在の情報と、コミット当時の文脈との乖離について)
  • ファイルディスクリプタ、I/O多重化、epoll/kqueueに関する一般的な知識