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

[インデックス 10905] ファイルの概要

このコミットは、Go言語の標準ライブラリであるnetパッケージにDialTimeout関数を追加し、ネットワーク接続確立時のタイムアウト機能を提供することを目的としています。これにより、ネットワークの応答がない場合にDial関数が無限にブロックされる問題を解決し、より堅牢なネットワークアプリケーションの構築を可能にします。

コミット

commit 964309e2fdd7f1e1b7b7e0c601446dc85d5d41bf
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Tue Dec 20 13:17:39 2011 -0800

    net: DialTimeout
    
    Fixes #240
    
    R=adg, dsymonds, rsc, r, mikioh.mikioh
    CC=golang-dev
    https://golang.org/cl/5491062

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/964309e2fdd7f1e1b7b7e0c601446dc85d5d41bf

元コミット内容

このコミットは、netパッケージにDialTimeout関数を導入します。この関数は、既存のDial関数と同様にネットワーク接続を確立しますが、指定されたタイムアウト期間内に接続が確立されない場合にエラーを返します。タイムアウトには、名前解決にかかる時間も含まれます。この変更は、Issue #240で報告された、Dialが応答しないホストに対して無限にブロックされる問題を修正します。

変更の背景

Go言語の初期のnetパッケージにおけるDial関数は、ネットワーク接続を試みる際にタイムアウトの概念を持っていませんでした。これは、特に以下のようなシナリオで問題を引き起こしました。

  1. 応答しないホストへの接続試行: ネットワーク上のホストがダウンしている、または特定のポートでリッスンしていない場合、Dial関数は接続が確立されるまで無限に待機し続ける可能性がありました。これにより、アプリケーションがハングアップしたり、リソースが枯渇したりするリスクがありました。
  2. ネットワークの遅延や不安定性: ネットワークが遅延している、または不安定な環境では、接続確立に時間がかかることがあり、その間アプリケーションがブロックされることでユーザーエクスペリエンスが低下しました。
  3. リソース管理の困難さ: 無限にブロックされる接続は、ゴルーチンやファイルディスクリプタなどのシステムリソースを消費し続け、アプリケーション全体の安定性やスケーラビリティに悪影響を及ぼしました。

これらの問題に対処するため、Issue #240 (net: timeout support for Dial) が提起され、Dial関数にタイムアウト機能を追加する必要性が議論されました。このコミットは、その議論の結果として、DialTimeout関数を導入することで、これらの課題を解決し、より堅牢で応答性の高いネットワークアプリケーションの開発を可能にしました。

前提知識の解説

このコミットの変更内容を理解するためには、以下の前提知識が役立ちます。

1. ネットワークプログラミングの基本

  • TCP/IP: インターネットの基盤となるプロトコルスイート。TCP (Transmission Control Protocol) は信頼性の高いコネクション指向の通信を提供し、IP (Internet Protocol) はデータのルーティングを担当します。
  • ソケット: ネットワーク通信のエンドポイント。アプリケーションはソケットを通じてデータを送受信します。
  • 接続 (Connection): クライアントとサーバー間で確立される論理的な通信パス。TCP接続は、3ウェイハンドシェイクと呼ばれるプロセスを経て確立されます。
  • ダイヤル (Dial): クライアントがサーバーへの接続を開始する操作。Go言語のnet.Dial関数は、この操作を抽象化しています。
  • タイムアウト (Timeout): 特定の操作が完了するまでに許容される最大時間。ネットワーク操作においてタイムアウトを設定することは、アプリケーションの応答性と堅牢性を確保するために不可欠です。

2. Go言語の並行処理

Go言語は、軽量なスレッドであるゴルーチン (Goroutine) と、ゴルーチン間の安全な通信を可能にするチャネル (Channel) を用いた並行処理を強力にサポートしています。

  • ゴルーチン: goキーワードを使って関数を呼び出すことで、新しいゴルーチンが生成され、その関数が並行して実行されます。
  • チャネル: ゴルーチン間で値を送受信するための通信メカニズム。チャネルは、データの同期と通信を同時に行い、共有メモリによる競合状態を避けるのに役立ちます。
  • selectステートメント: 複数のチャネル操作を待機し、準備ができた最初の操作を実行するために使用されます。これは、タイムアウト処理や複数のイベントソースからの入力を処理する際に非常に強力です。

3. Go言語のnetパッケージ

netパッケージは、ネットワークI/Oプリミティブへのポータブルなインターフェースを提供します。

  • net.Connインターフェース: 汎用的なネットワーク接続を表すインターフェースで、ReadWriteCloseなどのメソッドを持ちます。
  • net.Addrインターフェース: ネットワークアドレスを表すインターフェース。
  • net.OpError: ネットワーク操作中に発生したエラーを詳細に記述するための構造体。操作の種類、ネットワークタイプ、アドレス、および根本的なエラーを含みます。
  • time.Duration: 時間の長さを表す型。time.Millisecondtime.Secondなどの定数を使って時間を指定できます。
  • time.Timer: 指定された期間が経過した後にチャネルに値を送信するオブジェクト。タイムアウト処理によく使用されます。

技術的詳細

DialTimeout関数の実装は、Go言語の並行処理機能を巧みに利用して、タイムアウトを実現しています。

  1. タイムアウトタイマーの開始: t := time.NewTimer(timeout) 指定されたtimeout期間が経過すると、t.Cチャネルに値が送信されるタイマーを作成します。defer t.Stop()により、関数終了時にタイマーが停止され、リソースが解放されます。

  2. 非同期での接続試行: go func() { ... }() 新しいゴルーチンを起動し、その中で実際のネットワーク接続(名前解決とdialAddr呼び出し)を試みます。このゴルーチンは、接続結果(Connerror)をchチャネルに送信します。また、名前解決が成功した場合は、解決されたアドレスをresolvedAddrチャネルに送信します。

  3. selectによるタイムアウトと接続結果の待機: select { ... } selectステートメントは、t.Cチャネル(タイムアウト)とchチャネル(接続結果)のいずれかから値が受信されるのを待機します。

    • case <-t.C: (タイムアウト発生): タイマーが期限切れになった場合、このケースが実行されます。 接続試行中に名前解決が完了していた場合(resolvedAddrチャネルに値がある場合)、そのアドレスを使用してOpErrorを構築します。そうでない場合は、元のネットワークとアドレス文字列からstringAddrを作成して使用します。 最終的に、&timeoutError{}を内部エラーとして持つOpErrorを返します。timeoutErrorは、Timeout() boolメソッドを持つことで、エラーがタイムアウトによるものであることを示すマーカーインターフェースを実装しています。

    • case p := <-ch: (接続結果の受信): 接続試行ゴルーチンから結果がchチャネルに送信された場合、このケースが実行されます。 受信したConnerrorのペアをそのまま返します。

この設計により、DialTimeoutは、接続試行とタイムアウト監視を並行して行い、どちらかのイベントが先に発生した時点で適切な処理を行うことができます。

dialAddr関数の導入

元のDial関数から、実際のアドレス解決後の接続処理部分がdialAddrという新しい内部関数に切り出されています。これにより、DialDialTimeoutの両方で共通の接続ロジックを再利用できるようになり、コードの重複が避けられています。

stringAddr構造体

stringAddrは、net.Addrインターフェースを実装するシンプルな構造体です。これは、DialTimeoutがタイムアウトした場合に、名前解決が完了していなかったとしても、エラーメッセージに元のネットワークとアドレス文字列を含めるために使用されます。これにより、エラー情報がより詳細になります。

コアとなるコードの変更箇所

このコミットでは、主に以下の2つのファイルが変更されています。

  1. src/pkg/net/dial.go:

    • timeパッケージのインポートが追加されました。
    • Dial関数のシグネチャがfunc Dial(net, addr string) (Conn, error)に変更され、戻り値の型が明示されました。
    • Dial関数から、アドレス解決後の実際の接続ロジックがdialAddrという新しい内部関数に切り出されました。
    • DialTimeout関数が新しく追加されました。この関数は、タイムアウト機能を持つネットワーク接続を確立します。
    • stringAddrという新しい型と、そのNetwork()およびString()メソッドが追加されました。これは、タイムアウトエラー発生時にアドレス情報を提供するために使用されます。
  2. src/pkg/net/dial_test.go:

    • DialTimeout関数の動作を検証するための新しいテストファイルが追加されました。
    • TestDialTimeoutというテスト関数が含まれており、異なるオペレーティングシステム(Linux, Darwinなど)でのタイムアウト動作をテストするためのプラットフォーム固有のロジックが含まれています。特に、LinuxではTCPのバックログを埋めることでタイムアウトを誘発し、Darwinでは到達不能なIPアドレスへの接続を試みることでタイムアウトをテストしています。

コアとなるコードの解説

src/pkg/net/dial.go

package net

import (
	"time" // timeパッケージがインポートされる
)

// Dial関数の実装が変更され、実際の接続処理はdialAddrに委譲される
func Dial(net, addr string) (Conn, error) {
	addri, err := resolveNetAddr("dial", net, addr)
	if err != nil {
		return nil, err
	}
	return dialAddr(net, addr, addri) // dialAddrを呼び出す
}

// dialAddrは、アドレス解決後の実際の接続処理を行う内部関数
func dialAddr(net, addr string, addri Addr) (c Conn, err error) {
	switch ra := addri.(type) {
	case *TCPAddr:
		c, err = DialTCP(net, nil, ra)
	case *UDPAddr:
		c, err = DialUDP(net, nil, ra)
	case *IPAddr:
		c, err = DialIP(net, nil, ra)
	case *UnixAddr:
		c, err = DialUnix(net, nil, ra)
	default:
		return nil, &OpError{"dial", net, addri, ErrUnknownNetwork}
	}
	return
}

// DialTimeoutは、タイムアウト付きでネットワーク接続を確立する
// タイムアウトには名前解決も含まれる
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()              // 関数終了時にタイマーを停止

	type pair struct { // 接続結果を保持する構造体
		Conn
		error
	}
	ch := make(chan pair, 1)       // 接続結果を送信するためのチャネル
	resolvedAddr := make(chan Addr, 1) // 名前解決されたアドレスを送信するためのチャネル

	go func() { // 新しいゴルーチンで非同期に接続を試みる
		addri, err := resolveNetAddr("dial", net, addr) // 名前解決
		if err != nil {
			ch <- pair{nil, err} // エラーがあればチャネルに送信
			return
		}
		resolvedAddr <- addri // 名前解決されたアドレスを送信
		c, err := dialAddr(net, addr, addri) // 実際の接続処理
		ch <- pair{c, err} // 接続結果をチャネルに送信
	}()

	select { // タイムアウトまたは接続結果のいずれかを待機
	case <-t.C: // タイムアウトが発生した場合
		// タイムアウト前に名前解決が完了していれば、そのアドレスを使用
		var addri Addr
		select {
		case a := <-resolvedAddr:
			addri = a
		default:
			addri = &stringAddr{net, addr} // 名前解決が完了していなければ、文字列アドレスを使用
		}
		err := &OpError{ // OpErrorを構築し、timeoutErrorを内部エラーとして設定
			Op:   "dial",
			Net:  net,
			Addr: addri,
			Err:  &timeoutError{}, // タイムアウトエラーを示す
		}
		return nil, err
	case p := <-ch: // 接続結果が受信された場合
		return p.Conn, p.error // 接続結果を返す
	}
	panic("unreachable") // ここには到達しないはず
}

// stringAddrは、net.Addrインターフェースを実装するシンプルな構造体
// タイムアウトエラー発生時にアドレス情報を提供するために使用される
type stringAddr struct {
	net, addr string
}

func (a stringAddr) Network() string { return a.net }
func (a stringAddr) String() string  { return a.addr }

src/pkg/net/dial_test.go

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package net

import (
	"runtime"
	"testing"
	"time"
)

// newLocalListenerは、テスト用のローカルリスナーを作成するヘルパー関数
func newLocalListener(t *testing.T) Listener {
	ln, err := Listen("tcp", "127.0.0.1:0")
	if err != nil {
		ln, err = Listen("tcp6", "[::1]:0") // IPv6も試す
	}
	if err != nil {
		t.Fatal(err)
	}
	return ln
}

// TestDialTimeoutはDialTimeout関数の動作をテストする
func TestDialTimeout(t *testing.T) {
	ln := newLocalListener(t) // ローカルリスナーを作成
	defer ln.Close()          // テスト終了時にリスナーを閉じる

	errc := make(chan error) // エラーを送信するためのチャネル

	const SOMAXCONN = 0x80 // syscallからコピーされたSOMAXCONN (バックログサイズ)
	const numConns = SOMAXCONN + 10 // バックログを埋めるための接続数

	// TODO(bradfitz): It's hard to test this in a portable
	// way. This is unforunate, but works for now.
	switch runtime.GOOS { // OSごとに異なるテストロジック
	case "linux":
		// Linuxでは、カーネルのバックログを埋めることでタイムアウトを誘発
		// ユーザー空間が接続を受け入れる前にTCP接続を受け入れ始めるため、
		// 多数の接続を起動してカーネルのバックログを埋める。
		// その後、タイムアウトエラーが発生することを確認する。
		for i := 0; i < numConns; i++ {
			go func() {
				_, err := DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
				errc <- err
			}()
		}
	case "darwin":
		// Darwin (OS X 10.7以降) では、listenのバックログを無視して任意の数の接続を受け入れる傾向があるため、
		// 意図的に到達不能なIPアドレス (127.0.71.111:80) への接続を試みることでタイムアウトをテスト。
		go func() {
			_, err := DialTimeout("tcp", "127.0.71.111:80", 200*time.Millisecond)
			errc <- err
		}()
	default:
		// その他のOSではテストをスキップ (Windowsなど)
		t.Logf("skipping test on %q; untested.", runtime.GOOS)
		return
	}

	connected := 0
	for {
		select {
		case <-time.After(15 * time.Second): // 15秒でタイムアウト
			t.Fatal("too slow")
		case err := <-errc: // エラーが受信された場合
			if err == nil { // 接続が成功した場合
				connected++
				if connected == numConns {
					t.Fatal("all connections connected; expected some to time out") // 全ての接続が成功した場合、エラー (タイムアウトを期待)
				}
			} else { // エラーが発生した場合
				terr, ok := err.(timeout) // エラーがtimeoutインターフェースを実装しているか確認
				if !ok {
					t.Fatalf("got error %q; want error with timeout interface", err)
				}
				if !terr.Timeout() {
					t.Fatalf("got error %q; not a timeout", err)
				}
				// タイムアウトエラーが確認されたので、テストは成功
				return
			}
		}
	}
}

関連リンク

参考にした情報源リンク