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

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

このコミットは、Go言語のネットワークパッケージ(net)におけるWindows環境でのI/O操作時のメモリ割り当てを削減することを目的としています。具体的には、非同期I/O操作に必要なデータをnetFD構造体に直接埋め込むことで、ヒープ割り当てを減らし、パフォーマンスを向上させています。

コミット

commit 04b1cfa94635f18462b8a076cebacc5e08d92631
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Tue Aug 6 14:40:10 2013 +0400

    net: reduce number of memory allocations during IO operations
    Embed all data necessary for read/write operations directly into netFD.
    
    benchmark                    old ns/op    new ns/op    delta
    BenchmarkTCP4Persistent          27669        23341  -15.64%
    BenchmarkTCP4Persistent-2        18173        12558  -30.90%
    BenchmarkTCP4Persistent-4        10390         7319  -29.56%
    
    This change will intentionally break all builders to see
    how many allocations they do per read/write.
    This will be fixed soon afterwards.
    
    R=golang-dev, alex.brainman
    CC=golang-dev
    https://golang.org/cl/12413043

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

https://github.com/golang/go/commit/04b1cfa94635f18462b8a076cebacc5e08d92631

元コミット内容

このコミットは、GoのnetパッケージにおけるWindows環境でのI/O操作中のメモリ割り当てを削減することを目的としています。具体的には、読み書き操作に必要なすべてのデータをnetFD構造体内に直接埋め込むことで、ヒープ割り当てを減らしています。

ベンチマーク結果は以下の通りです。

ベンチマークold ns/opnew ns/opdelta
BenchmarkTCP4Persistent2766923341-15.64%
BenchmarkTCP4Persistent-21817312558-30.90%
BenchmarkTCP4Persistent-4103907319-29.56%

この変更は、各ビルダが読み書きごとにどれだけの割り当てを行うかを確認するために、意図的にすべてのビルダを壊すことになります。これはすぐに修正される予定です。

変更の背景

Goのネットワークスタック、特にWindows環境におけるI/O操作では、非同期I/O(Overlapped I/O)が多用されます。従来の設計では、各I/O操作(読み込み、書き込み、接続、Acceptなど)ごとにanOpbufOpといった構造体がヒープに割り当てられていました。これらの構造体は、Windows APIのOVERLAPPED構造体を含み、I/O完了ポート(IOCP)を通じて非同期I/Oの結果を受け取るために使用されます。

しかし、頻繁なネットワークI/Oにおいて、これらの小さな構造体のヒープ割り当てと解放は、ガベージコレクション(GC)の負荷を増加させ、全体的なパフォーマンスのボトルネックとなる可能性がありました。特に、高スループットが求められるサーバーアプリケーションなどでは、このオーバーヘッドが顕著になります。

このコミットの目的は、このメモリ割り当てのオーバーヘッドを削減し、I/O操作の効率を向上させることにあります。具体的には、netFD(ネットワークファイルディスクリプタ)構造体自体に、読み込みと書き込みのためのoperation構造体を直接埋め込むことで、I/O操作ごとに新しい構造体をヒープに割り当てる必要をなくしています。これにより、GCの頻度と負荷が軽減され、ベンチマーク結果に示されるように、I/Oパフォーマンスが大幅に改善されます。

コミットメッセージにある「This change will intentionally break all builders to see how many allocations they do per read/write. This will be fixed soon afterwards.」という記述は、この変更がメモリ割り当ての挙動に大きな影響を与えるため、既存のテストやビルド環境で予期せぬ割り当てが発生しないかを確認するための意図的な措置であることを示しています。これは、Goのランタイム開発における厳格な品質管理とパフォーマンスへのコミットメントを反映しています。

前提知識の解説

このコミットを理解するためには、以下の概念についての知識が必要です。

1. Go言語のnetパッケージとネットワークI/O

Go言語のnetパッケージは、TCP/IP、UDP、Unixドメインソケットなどのネットワーク通信機能を提供します。GoのI/Oモデルは、通常、ブロッキングI/Oのように見えますが、内部的にはOSの非同期I/Oメカニズム(Linuxのepoll、macOS/FreeBSDのkqueue、WindowsのIOCPなど)を利用して効率的なノンブロッキングI/Oを実現しています。これにより、多数の同時接続を効率的に処理できます。

2. Windowsの非同期I/O (Overlapped I/O) とI/O完了ポート (IOCP)

Windowsでは、高パフォーマンスなネットワークI/Oを実現するために「Overlapped I/O」と「I/O完了ポート(IOCP)」が広く利用されます。

  • Overlapped I/O: I/O操作が即座に完了しない場合でも、呼び出し元のスレッドをブロックせずに処理を続行できるメカニズムです。I/O操作の完了は、OVERLAPPED構造体を通じて通知されます。この構造体には、I/O操作の状態や結果を格納するための情報が含まれます。
  • I/O完了ポート (IOCP): 複数の非同期I/O操作の完了通知を一元的に処理するための効率的なメカニズムです。アプリケーションは、I/O完了ポートを作成し、ソケットやファイルハンドルをそれに関連付けます。I/O操作が完了すると、システムは完了通知をIOCPのキューに追加し、アプリケーションはGetQueuedCompletionStatus関数を呼び出して通知を取得します。これにより、少数のスレッドで多数のI/O操作を効率的に処理できます。

GoのWindowsネットワークスタックは、このIOCPモデルを内部的に利用して、net.ConnインターフェースのReadWriteなどのブロッキングメソッドを実装しています。

3. syscallパッケージとWindows API

Goのsyscallパッケージは、OSのシステムコールや低レベルAPIへのアクセスを提供します。Windowsの場合、syscallパッケージを通じてWSARecvWSASendAcceptExTransmitFileなどのWinsock APIや、CancelIoExGetQueuedCompletionStatusなどのI/O関連APIを呼び出すことができます。これらのAPIは、非同期I/O操作を開始したり、その結果を取得したりするために使用されます。

4. メモリ割り当てとガベージコレクション (GC)

Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムが実行中にメモリを動的に割り当てると(例: makenewを使用)、そのメモリはヒープに配置されます。不要になったメモリはGCによって自動的に解放されます。

  • ヒープ割り当て: プログラムの実行中に動的に確保されるメモリ領域です。
  • スタック割り当て: 関数呼び出し時にローカル変数などのために確保されるメモリ領域です。関数が終了すると自動的に解放されます。スタック割り当てはヒープ割り当てよりも高速です。
  • ガベージコレクション (GC): 不要になったヒープメモリを自動的に回収するプロセスです。GCはプログラムの実行を一時停止させることがあり(Stop-the-World)、これがパフォーマンスのボトルネックになることがあります。GCの頻度や負荷を減らすためには、ヒープ割り当ての回数を減らすことが有効です。

このコミットは、I/O操作ごとにヒープに割り当てられていたanOpbufOpのような構造体を、netFD構造体内に直接埋め込むことで、スタック割り当てに近い形で再利用可能にし、ヒープ割り当ての回数を削減しています。

5. sync.Mutexと排他制御

sync.MutexはGo言語で提供されるミューテックス(相互排他ロック)です。複数のゴルーチンが共有リソースに同時にアクセスする際に、データ競合を防ぎ、一貫性を保つために使用されます。このコミットでは、netFD内のoperation構造体(ropwop)へのアクセスを保護するためにsync.Mutexが使用されています。これにより、読み込み操作と書き込み操作が同時に進行しても、それぞれのoperation構造体の状態が正しく管理されるようになります。

技術的詳細

このコミットの核心は、WindowsにおけるネットワークI/O操作の内部実装において、メモリ割り当てのパターンを変更することにあります。

変更前: ヒープ割り当てされるI/O操作構造体

変更前は、各I/O操作(ReadWriteAcceptConnectSendFileなど)が実行されるたびに、それぞれに対応するanOpbufOpreadOpwriteOpacceptOpconnectOpsendfileOpといった構造体がヒープに割り当てられていました。これらの構造体は、WindowsのOVERLAPPED構造体を含み、非同期I/Oのコンテキストを保持していました。

例えば、Read操作ではreadOpが、Write操作ではwriteOpが、それぞれfd.rio.Lock()fd.wio.Lock()で保護されたクリティカルセクション内でvar o readOpのように宣言され、iosrv.ExecIO(&o)に渡されていました。これは、I/O操作ごとに新しいオブジェクトが作成され、ヒープに割り当てられることを意味します。I/Oが完了すると、これらのオブジェクトは不要になり、GCの対象となります。高頻度なI/Oでは、これがGCの負荷を増大させる要因となっていました。

変更後: netFDへのI/O操作構造体の埋め込み

このコミットでは、netFD構造体自体に、読み込み操作用のrop(read operation)と書き込み操作用のwop(write operation)という2つのoperation構造体を直接埋め込んでいます。

type netFD struct {
    // ... 既存のフィールド ...
    rop operation // read operation
    wop operation // write operation
    // ...
}

新しいoperation構造体は、以前のanOpbufOpの機能を統合し、非同期I/Oに必要なすべてのデータ(syscall.Overlappederrnoqtybufsarsahandleflagsなど)を保持します。

これにより、ReadWriteなどのI/Oメソッドが呼び出された際に、ヒープに新しい構造体を割り当てる代わりに、netFDに既に存在するfd.ropまたはfd.wopを再利用するようになります。

// 変更前: var o readOp; o.Init(fd, buf, 'r'); n, err := iosrv.ExecIO(&o)
// 変更後: o := &fd.rop; o.mu.Lock(); defer o.mu.Unlock(); o.InitBuf(buf); n, err := iosrv.ExecIO(o, "WSARecv", func(...) { ... })

operation構造体には独自のsync.Mutex (mu) が追加され、fd.ropfd.wopへのアクセスがそれぞれ排他的に保護されます。これにより、同じnetFDインスタンスに対して複数のゴルーチンが同時に読み込みや書き込みを行おうとした場合でも、データ競合が発生しないようにしています。以前はfd.riofd.wioという2つのsync.MutexnetFDに直接存在し、それぞれ読み込みと書き込みのI/O操作全体をロックしていましたが、変更後はoperation構造体内部のmuがその役割を担います。

iosrv.ExecIOの変更

iosrv.ExecIO関数も変更され、以前はanOpIfaceインターフェースを受け取っていましたが、変更後は*operationポインタと、I/O操作を実行するためのsubmit関数(クロージャ)を受け取るようになりました。これにより、各I/O操作の具体的なWindows API呼び出し(WSARecvWSASendなど)がExecIOの呼び出し元で定義され、operation構造体のフィールドを直接利用できるようになります。

ioSrvのチャネル変更

ioSrv構造体内のチャネルも変更されています。以前はsubmchancanchanという2つのanOpIface型のチャネルがありましたが、これらはreqという単一のioSrvReq型のチャネルに統合されました。ioSrvReq*operationsubmit関数(またはキャンセルを示すnil)を保持します。これにより、I/O操作の開始とキャンセルがより統一された方法で処理されるようになります。

パフォーマンスへの影響

この変更により、I/O操作ごとにヒープ割り当てが発生しなくなるため、ガベージコレクションの頻度が大幅に減少します。特に、多数の小さなI/O操作が連続して行われるようなシナリオ(例: HTTPサーバーでのリクエスト/レスポンス処理)では、GCによる一時停止(Stop-the-World)の回数が減り、全体的なスループットとレイテンシが改善されます。ベンチマーク結果が示すように、TCPの永続接続におけるパフォーマンスが最大30%以上向上しています。

runtime/netpoll_windows.cの変更

C言語で実装されているruntime/netpoll_windows.cファイルも、net.anOp構造体のC言語側の対応物であるnet_anOpの定義を、新しいnet.operation構造体に対応するnet_opに変更しています。これは、GoランタイムとCランタイムの間で構造体のメモリレイアウトが同期している必要があるためです。

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

主要な変更は以下のファイルに集中しています。

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

    • anOpIfaceインターフェース、anOp構造体、bufOp構造体、readOpwriteOpconnectOpacceptOpreadFromOpwriteToOpといったI/O操作ごとの構造体が削除され、代わりに汎用的なoperation構造体が導入されました。
    • operation構造体は、syscall.Overlappedsyscall.WSABufsyscall.Sockaddrsyscall.RawSockaddrAnysyscall.Handleuint32型のflagsなど、すべてのI/O操作に必要なフィールドを内包します。また、排他制御のためのsync.Mutex (mu) とエラーチャネルerrcも含まれます。
    • netFD構造体にrop operationwop operationが直接埋め込まれました。
    • newFD関数内で、netFDの初期化時にropwopmodefdruntimeCtxerrcが設定されるようになりました。
    • ioSrv構造体のsubmchancanchanreq chan ioSrvReqに統合されました。
    • ExecIO関数が*operationsubmit関数(クロージャ)を受け取るように変更され、各I/O操作の具体的なAPI呼び出しがExecIOの引数として渡されるようになりました。
    • netFDRead, Write, ReadFrom, WriteTo, Connect, Acceptメソッドが、それぞれfd.ropまたはfd.wopを再利用し、ExecIOを呼び出すように変更されました。
    • netFD.Close()におけるロックの取得・解放が、fd.rio, fd.wioからfd.rop.mu, fd.wop.muに変更されました。
  2. src/pkg/net/sendfile_windows.go:

    • sendfileOp構造体が削除され、sendFile関数がfd.wopを再利用するように変更されました。
  3. src/pkg/net/tcp_test.go:

    • TestTCPReadWriteMallocsという新しいテストが追加されました。このテストは、TCPの読み書き操作におけるメモリ割り当ての回数を計測し、特定のOS(Windows)で期待される割り当て回数(0回)を超えていないかを確認します。これは、このコミットの主要な目的であるメモリ割り当て削減が正しく達成されていることを検証するためのものです。
  4. src/pkg/runtime/netpoll_windows.c:

    • C言語側のnet_anOp構造体の定義がnet_opに変更され、Go言語側のnet.operation構造体と同期するように修正されました。

コアとなるコードの解説

src/pkg/net/fd_windows.go

operation構造体

type operation struct {
	// Used by IOCP interface, it must be first field
	// of the struct, as our code rely on it.
	o syscall.Overlapped

	// fields used by runtime
	mode       int32 // 'r' for read, 'w' for write
	runtimeCtx uintptr

	// fields used only by net package
	mu     sync.Mutex
	fd     *netFD
	errc   chan error
	buf    syscall.WSABuf
	sa     syscall.Sockaddr
	rsa    *syscall.RawSockaddrAny
	rsan   int32
	handle syscall.Handle
	flags  uint32
}

このoperation構造体が、以前の複数のI/O操作固有の構造体(anOp, bufOp, readOpなど)の機能を統合したものです。

  • o syscall.Overlapped: Windowsの非同期I/Oで必須となるOVERLAPPED構造体です。これが構造体の最初のフィールドである必要があります。
  • mode int32, runtimeCtx uintptr: ランタイムがI/O操作のタイプやコンテキストを管理するために使用します。
  • mu sync.Mutex: このoperationインスタンスへのアクセスを保護するためのミューテックスです。これにより、同じnetFDインスタンスの読み書き操作が同時に行われても安全性が保たれます。
  • fd *netFD: この操作が関連付けられているnetFDへのポインタです。
  • errc chan error: I/O操作の完了エラーを通知するためのチャネルです。
  • buf syscall.WSABuf: 読み書きするデータバッファを記述します。WSARecvWSASendなどで使用されます。
  • sa syscall.Sockaddr, rsa *syscall.RawSockaddrAny, rsan int32: ソケットアドレス情報です。WSARecvFromWSASendtoAcceptExなどで使用されます。
  • handle syscall.Handle: AcceptExで新しいソケットハンドルを保持したり、TransmitFileでファイルハンドルを保持したりするために使用されます。
  • flags uint32: I/O操作に関連するフラグです。

netFD構造体への埋め込みと初期化

type netFD struct {
    // ...
    rop operation // read operation
    wop operation // write operation
    // ...
}

func newFD(sysfd syscall.Handle, family, sotype int, net string) (*netFD, error) {
    // ...
    fd := &netFD{
        sysfd:  sysfd,
        family: family,
        sotype: sotype,
        net:    net,
    }
    // ...
    fd.rop.mode = 'r'
    fd.wop.mode = 'w'
    fd.rop.fd = fd
    fd.wop.fd = fd
    fd.rop.runtimeCtx = fd.pd.runtimeCtx
    fd.wop.runtimeCtx = fd.pd.runtimeCtx
    if !canCancelIO {
        fd.rop.errc = make(chan error)
        fd.wop.errc = make(chan error) // ここは typo で fd.wop.errc = make(chan error) が正しい
    }
    return fd, nil
}

netFDropwopが直接埋め込まれることで、I/O操作ごとにヒープ割り当てが不要になります。newFDnetFDが作成される際に、これらのoperation構造体の基本的なフィールドが初期化されます。

ExecIO関数の変更

func (s *ioSrv) ExecIO(o *operation, name string, submit func(o *operation) error) (int, error) {
    fd := o.fd
    // Notify runtime netpoll about starting IO.
    err := fd.pd.Prepare(int(o.mode))
    if err != nil {
        return 0, &OpError{name, fd.net, fd.laddr, err}
    }
    // Start IO.
    if canCancelIO {
        err = submit(o) // submit関数(クロージャ)を実行
    } else {
        s.req <- ioSrvReq{o, submit}
        err = <-o.errc
    }
    // ...
}

ExecIOは、具体的なI/O操作のロジックをsubmitという関数(クロージャ)として受け取るようになりました。これにより、ExecIO自体は汎用的なI/O実行ロジック(ランタイムへの通知、キャンセル処理、完了待ちなど)に集中し、各I/O操作のWindows API呼び出しは呼び出し元で定義できるようになりました。

Readメソッドの変更例

func (fd *netFD) Read(buf []byte) (int, error) {
    // ...
    o := &fd.rop // netFDに埋め込まれたropを再利用
    o.mu.Lock()  // ropへのアクセスをロック
    defer o.mu.Unlock() // 関数終了時にロックを解放
    o.InitBuf(buf) // バッファ情報をoperationに設定
    n, err := iosrv.ExecIO(o, "WSARecv", func(o *operation) error {
        // WSARecvの呼び出しロジックをクロージャとして渡す
        return syscall.WSARecv(o.fd.sysfd, &o.buf, 1, &o.qty, &o.flags, &o.o, nil)
    })
    // ...
}

Readメソッドでは、fd.ropという既存のoperation構造体へのポインタを取得し、そのミューテックスをロックします。そして、InitBufで読み込みバッファを設定した後、iosrv.ExecIOを呼び出します。ExecIOには、WSARecvを呼び出す具体的なロジックをクロージャとして渡しています。これにより、ヒープ割り当てなしでI/O操作を実行できるようになりました。

src/pkg/net/tcp_test.go

TestTCPReadWriteMallocs

func TestTCPReadWriteMallocs(t *testing.T) {
	maxMallocs := 0
	switch runtime.GOOS {
	// Add other OSes if you know how many mallocs they do.
	case "windows":
		maxMallocs = 0
	}
	// ... TCP接続の確立 ...
	var buf [128]byte
	mallocs := testing.AllocsPerRun(1000, func() {
		_, err := server.Write(buf[:])
		if err != nil {
			t.Fatalf("Write failed: %v", err)
		}
		_, err = io.ReadFull(client, buf[:])
		if err != nil {
			t.Fatalf("Read failed: %v", err)
		}
	})
	if int(mallocs) > maxMallocs {
		t.Fatalf("Got %v allocs, want %v", mallocs, maxMallocs)
	}
}

このテストは、testing.AllocsPerRunを使用して、指定された関数(ここではTCPの書き込みと読み込み)が実行される間に発生するメモリ割り当ての回数を計測します。Windows環境ではmaxMallocs0に設定されており、これはこのコミットの変更によって、TCPの読み書き操作中にヒープ割り当てが一切発生しないことを期待していることを示しています。このテストは、変更が意図通りにメモリ割り当てを削減したことを検証する重要な役割を果たします。

関連リンク

参考にした情報源リンク

このコミットは、Go言語のネットワークパッケージ(net)におけるWindows環境でのI/O操作時のメモリ割り当てを削減することを目的としています。具体的には、非同期I/O操作に必要なデータをnetFD構造体に直接埋め込むことで、ヒープ割り当てを減らし、パフォーマンスを向上させています。

コミット

commit 04b1cfa94635f18462b8a076cebacc5e08d92631
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Tue Aug 6 14:40:10 2013 +0400

    net: reduce number of memory allocations during IO operations
    Embed all data necessary for read/write operations directly into netFD.
    
    benchmark                    old ns/op    new ns/op    delta
    BenchmarkTCP4Persistent          27669        23341  -15.64%
    BenchmarkTCP4Persistent-2        18173        12558  -30.90%
    BenchmarkTCP4Persistent-4        10390         7319  -29.56%
    
    This change will intentionally break all builders to see
    how many allocations they do per read/write.
    This will be fixed soon afterwards.
    
    R=golang-dev, alex.brainman
    CC=golang-dev
    https://golang.org/cl/12413043

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

https://github.com/golang/go/commit/04b1cfa94635f18462b8a076cebacc5e08d92631

元コミット内容

このコミットは、GoのnetパッケージにおけるWindows環境でのI/O操作中のメモリ割り当てを削減することを目的としています。具体的には、読み書き操作に必要なすべてのデータをnetFD構造体内に直接埋め込むことで、ヒープ割り当てを減らしています。

ベンチマーク結果は以下の通りです。

ベンチマークold ns/opnew ns/opdelta
BenchmarkTCP4Persistent2766923341-15.64%
BenchmarkTCP4Persistent-21817312558-30.90%
BenchmarkTCP4Persistent-4103907319-29.56%

この変更は、各ビルダが読み書きごとにどれだけの割り当てを行うかを確認するために、意図的にすべてのビルダを壊すことになります。これはすぐに修正される予定です。

変更の背景

Goのネットワークスタック、特にWindows環境におけるI/O操作では、非同期I/O(Overlapped I/O)が多用されます。従来の設計では、各I/O操作(読み込み、書き込み、接続、Acceptなど)ごとにanOpbufOpといった構造体がヒープに割り当てられていました。これらの構造体は、Windows APIのOVERLAPPED構造体を含み、I/O完了ポート(IOCP)を通じて非同期I/Oの結果を受け取るために使用されます。

しかし、頻繁なネットワークI/Oにおいて、これらの小さな構造体のヒープ割り当てと解放は、ガベージコレクション(GC)の負荷を増加させ、全体的なパフォーマンスのボトルネックとなる可能性がありました。特に、高スループットが求められるサーバーアプリケーションなどでは、このオーバーヘッドが顕著になります。

このコミットの目的は、このメモリ割り当てのオーバーヘッドを削減し、I/O操作の効率を向上させることにあります。具体的には、netFD(ネットワークファイルディスクリプタ)構造体自体に、読み込みと書き込みのためのoperation構造体を直接埋め込むことで、I/O操作ごとに新しい構造体をヒープに割り当てる必要をなくしています。これにより、GCの頻度と負荷が軽減され、ベンチマーク結果に示されるように、I/Oパフォーマンスが大幅に改善されます。

コミットメッセージにある「This change will intentionally break all builders to see how many allocations they do per read/write. This will be fixed soon afterwards.」という記述は、この変更がメモリ割り当ての挙動に大きな影響を与えるため、既存のテストやビルド環境で予期せぬ割り当てが発生しないかを確認するための意図的な措置であることを示しています。これは、Goのランタイム開発における厳格な品質管理とパフォーマンスへのコミットメントを反映しています。

前提知識の解説

このコミットを理解するためには、以下の概念についての知識が必要です。

1. Go言語のnetパッケージとネットワークI/O

Go言語のnetパッケージは、TCP/IP、UDP、Unixドメインソケットなどのネットワーク通信機能を提供します。GoのI/Oモデルは、通常、ブロッキングI/Oのように見えますが、内部的にはOSの非同期I/Oメカニズム(Linuxのepoll、macOS/FreeBSDのkqueue、WindowsのIOCPなど)を利用して効率的なノンブロッキングI/Oを実現しています。これにより、多数の同時接続を効率的に処理できます。

2. Windowsの非同期I/O (Overlapped I/O) とI/O完了ポート (IOCP)

Windowsでは、高パフォーマンスなネットワークI/Oを実現するために「Overlapped I/O」と「I/O完了ポート(IOCP)」が広く利用されます。

  • Overlapped I/O: I/O操作が即座に完了しない場合でも、呼び出し元のスレッドをブロックせずに処理を続行できるメカニズムです。I/O操作の完了は、OVERLAPPED構造体を通じて通知されます。この構造体には、I/O操作の状態や結果を格納するための情報が含まれます。
  • I/O完了ポート (IOCP): 複数の非同期I/O操作の完了通知を一元的に処理するための効率的なメカニズムです。アプリケーションは、I/O完了ポートを作成し、ソケットやファイルハンドルをそれに関連付けます。I/O操作が完了すると、システムは完了通知をIOCPのキューに追加し、アプリケーションはGetQueuedCompletionStatus関数を呼び出して通知を取得します。これにより、少数のスレッドで多数のI/O操作を効率的に処理できます。

GoのWindowsネットワークスタックは、このIOCPモデルを内部的に利用して、net.ConnインターフェースのReadWriteなどのブロッキングメソッドを実装しています。

3. syscallパッケージとWindows API

Goのsyscallパッケージは、OSのシステムコールや低レベルAPIへのアクセスを提供します。Windowsの場合、syscallパッケージを通じてWSARecvWSASendAcceptExTransmitFileなどのWinsock APIや、CancelIoExGetQueuedCompletionStatusなどのI/O関連APIを呼び出すことができます。これらのAPIは、非同期I/O操作を開始したり、その結果を取得したりするために使用されます。

4. メモリ割り当てとガベージコレクション (GC)

Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムが実行中にメモリを動的に割り当てると(例: makenewを使用)、そのメモリはヒープに配置されます。不要になったメモリはGCによって自動的に解放されます。

  • ヒープ割り当て: プログラムの実行中に動的に確保されるメモリ領域です。
  • スタック割り当て: 関数呼び出し時にローカル変数などのために確保されるメモリ領域です。関数が終了すると自動的に解放されます。スタック割り当てはヒープ割り当てよりも高速です。
  • ガベージコレクション (GC): 不要になったヒープメモリを自動的に回収するプロセスです。GCはプログラムの実行を一時停止させることがあり(Stop-the-World)、これがパフォーマンスのボトルネックになることがあります。GCの頻度や負荷を減らすためには、ヒープ割り当ての回数を減らすことが有効です。

このコミットは、I/O操作ごとにヒープに割り当てられていたanOpbufOpのような構造体を、netFD構造体内に直接埋め込むことで、スタック割り当てに近い形で再利用可能にし、ヒープ割り当ての回数を削減しています。

5. sync.Mutexと排他制御

sync.MutexはGo言語で提供されるミューテックス(相互排他ロック)です。複数のゴルーチンが共有リソースに同時にアクセスする際に、データ競合を防ぎ、一貫性を保つために使用されます。このコミットでは、netFD内のoperation構造体(ropwop)へのアクセスを保護するためにsync.Mutexが使用されています。これにより、読み込み操作と書き込み操作が同時に進行しても、それぞれのoperation構造体の状態が正しく管理されるようになります。

技術的詳細

このコミットの核心は、WindowsにおけるネットワークI/O操作の内部実装において、メモリ割り当てのパターンを変更することにあります。

変更前: ヒープ割り当てされるI/O操作構造体

変更前は、各I/O操作(ReadWriteAcceptConnectSendFileなど)が実行されるたびに、それぞれに対応するanOpbufOpreadOpwriteOpacceptOpconnectOpsendfileOpといった構造体がヒープに割り当てられていました。これらの構造体は、WindowsのOVERLAPPED構造体を含み、非同期I/Oのコンテキストを保持していました。

例えば、Read操作ではreadOpが、Write操作ではwriteOpが、それぞれfd.rio.Lock()fd.wio.Lock()で保護されたクリティカルセクション内でvar o readOpのように宣言され、iosrv.ExecIO(&o)に渡されていました。これは、I/O操作ごとに新しいオブジェクトが作成され、ヒープに割り当てられることを意味します。I/Oが完了すると、これらのオブジェクトは不要になり、GCの対象となります。高頻度なI/Oでは、これがGCの負荷を増大させる要因となっていました。

変更後: netFDへのI/O操作構造体の埋め込み

このコミットでは、netFD構造体自体に、読み込み操作用のrop(read operation)と書き込み操作用のwop(write operation)という2つのoperation構造体を直接埋め込んでいます。

type netFD struct {
    // ... 既存のフィールド ...
    rop operation // read operation
    wop operation // write operation
    // ...
}

新しいoperation構造体は、以前のanOpbufOpの機能を統合し、非同期I/Oに必要なすべてのデータ(syscall.Overlappederrnoqtybufsarsahandleflagsなど)を保持します。

これにより、ReadWriteなどのI/Oメソッドが呼び出された際に、ヒープに新しい構造体を割り当てる代わりに、netFDに既に存在するfd.ropまたはfd.wopを再利用するようになります。

// 変更前: var o readOp; o.Init(fd, buf, 'r'); n, err := iosrv.ExecIO(&o)
// 変更後: o := &fd.rop; o.mu.Lock(); defer o.mu.Unlock(); o.InitBuf(buf); n, err := iosrv.ExecIO(o, "WSARecv", func(...) { ... })

operation構造体には独自のsync.Mutex (mu) が追加され、fd.ropfd.wopへのアクセスがそれぞれ排他的に保護されます。これにより、同じnetFDインスタンスに対して複数のゴルーチンが同時に読み込みや書き込みを行おうとした場合でも、データ競合が発生しないようにしています。以前はfd.riofd.wioという2つのsync.MutexnetFDに直接存在し、それぞれ読み込みと書き込みのI/O操作全体をロックしていましたが、変更後はoperation構造体内部のmuがその役割を担います。

iosrv.ExecIOの変更

iosrv.ExecIO関数も変更され、以前はanOpIfaceインターフェースを受け取っていましたが、変更後は*operationポインタと、I/O操作を実行するためのsubmit関数(クロージャ)を受け取るようになりました。これにより、各I/O操作の具体的なWindows API呼び出し(WSARecvWSASendなど)がExecIOの呼び出し元で定義され、operation構造体のフィールドを直接利用できるようになります。

ioSrvのチャネル変更

ioSrv構造体内のチャネルも変更されています。以前はsubmchancanchanという2つのanOpIface型のチャネルがありましたが、これらはreqという単一のioSrvReq型のチャネルに統合されました。ioSrvReq*operationsubmit関数(またはキャンセルを示すnil)を保持します。これにより、I/O操作の開始とキャンセルがより統一された方法で処理されるようになります。

パフォーマンスへの影響

この変更により、I/O操作ごとにヒープ割り当てが発生しなくなるため、ガベージコレクションの頻度が大幅に減少します。特に、多数の小さなI/O操作が連続して行われるようなシナリオ(例: HTTPサーバーでのリクエスト/レスポンス処理)では、GCによる一時停止(Stop-the-World)の回数が減り、全体的なスループットとレイテンシが改善されます。ベンチマーク結果が示すように、TCPの永続接続におけるパフォーマンスが最大30%以上向上しています。

runtime/netpoll_windows.cの変更

C言語で実装されているruntime/netpoll_windows.cファイルも、net.anOp構造体のC言語側の対応物であるnet_anOpの定義を、新しいnet.operation構造体に対応するnet_opに変更しています。これは、GoランタイムとCランタイムの間で構造体のメモリレイアウトが同期している必要があるためです。

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

主要な変更は以下のファイルに集中しています。

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

    • anOpIfaceインターフェース、anOp構造体、bufOp構造体、readOpwriteOpconnectOpacceptOpreadFromOpwriteToOpといったI/O操作ごとの構造体が削除され、代わりに汎用的なoperation構造体が導入されました。
    • operation構造体は、syscall.Overlappedsyscall.WSABufsyscall.Sockaddrsyscall.RawSockaddrAnysyscall.Handleuint32型のflagsなど、すべてのI/O操作に必要なフィールドを内包します。また、排他制御のためのsync.Mutex (mu) とエラーチャネルerrcも含まれます。
    • netFD構造体にrop operationwop operationが直接埋め込まれました。
    • newFD関数内で、netFDの初期化時にropwopmodefdruntimeCtxerrcが設定されるようになりました。
    • ioSrv構造体のsubmchancanchanreq chan ioSrvReqに統合されました。
    • ExecIO関数が*operationsubmit関数(クロージャ)を受け取るように変更され、各I/O操作の具体的なAPI呼び出しがExecIOの引数として渡されるようになりました。
    • netFDRead, Write, ReadFrom, WriteTo, Connect, Acceptメソッドが、それぞれfd.ropまたはfd.wopを再利用し、ExecIOを呼び出すように変更されました。
    • netFD.Close()におけるロックの取得・解放が、fd.rio, fd.wioからfd.rop.mu, fd.wop.muに変更されました。
  2. src/pkg/net/sendfile_windows.go:

    • sendfileOp構造体が削除され、sendFile関数がfd.wopを再利用するように変更されました。
  3. src/pkg/net/tcp_test.go:

    • TestTCPReadWriteMallocsという新しいテストが追加されました。このテストは、TCPの読み書き操作におけるメモリ割り当ての回数を計測し、特定のOS(Windows)で期待される割り当て回数(0回)を超えていないかを確認します。これは、このコミットの主要な目的であるメモリ割り当て削減が正しく達成されていることを検証するためのものです。
  4. src/pkg/runtime/netpoll_windows.c:

    • C言語側のnet_anOp構造体の定義がnet_opに変更され、Go言語側のnet.operation構造体と同期するように修正されました。

コアとなるコードの解説

src/pkg/net/fd_windows.go

operation構造体

type operation struct {
	// Used by IOCP interface, it must be first field
	// of the struct, as our code rely on it.
	o syscall.Overlapped

	// fields used by runtime
	mode       int32 // 'r' for read, 'w' for write
	runtimeCtx uintptr

	// fields used only by net package
	mu     sync.Mutex
	fd     *netFD
	errc   chan error
	buf    syscall.WSABuf
	sa     syscall.Sockaddr
	rsa    *syscall.RawSockaddrAny
	rsan   int32
	handle syscall.Handle
	flags  uint32
}

このoperation構造体が、以前の複数のI/O操作固有の構造体(anOp, bufOp, readOpなど)の機能を統合したものです。

  • o syscall.Overlapped: Windowsの非同期I/Oで必須となるOVERLAPPED構造体です。これが構造体の最初のフィールドである必要があります。
  • mode int32, runtimeCtx uintptr: ランタイムがI/O操作のタイプやコンテキストを管理するために使用します。
  • mu sync.Mutex: このoperationインスタンスへのアクセスを保護するためのミューテックスです。これにより、同じnetFDインスタンスの読み書き操作が同時に行われても安全性が保たれます。
  • fd *netFD: この操作が関連付けられているnetFDへのポインタです。
  • errc chan error: I/O操作の完了エラーを通知するためのチャネルです。
  • buf syscall.WSABuf: 読み書きするデータバッファを記述します。WSARecvWSASendなどで使用されます。
  • sa syscall.Sockaddr, rsa *syscall.RawSockaddrAny, rsan int32: ソケットアドレス情報です。WSARecvFromWSASendtoAcceptExなどで使用されます。
  • handle syscall.Handle: AcceptExで新しいソケットハンドルを保持したり、TransmitFileでファイルハンドルを保持したりするために使用されます。
  • flags uint32: I/O操作に関連するフラグです。

netFD構造体への埋め込みと初期化

type netFD struct {
    // ...
    rop operation // read operation
    wop operation // write operation
    // ...
}

func newFD(sysfd syscall.Handle, family, sotype int, net string) (*netFD, error) {
    // ...
    fd := &netFD{
        sysfd:  sysfd,
        family: family,
        sotype: sotype,
        net:    net,
    }
    // ...
    fd.rop.mode = 'r'
    fd.wop.mode = 'w'
    fd.rop.fd = fd
    fd.wop.fd = fd
    fd.rop.runtimeCtx = fd.pd.runtimeCtx
    fd.wop.runtimeCtx = fd.pd.runtimeCtx
    if !canCancelIO {
        fd.rop.errc = make(chan error)
        fd.wop.errc = make(chan error) // ここは typo で fd.wop.errc = make(chan error) が正しい
    }
    return fd, nil
}

netFDropwopが直接埋め込まれることで、I/O操作ごとにヒープ割り当てが不要になります。newFDnetFDが作成される際に、これらのoperation構造体の基本的なフィールドが初期化されます。

ExecIO関数の変更

func (s *ioSrv) ExecIO(o *operation, name string, submit func(o *operation) error) (int, error) {
    fd := o.fd
    // Notify runtime netpoll about starting IO.
    err := fd.pd.Prepare(int(o.mode))
    if err != nil {
        return 0, &OpError{name, fd.net, fd.laddr, err}
    }
    // Start IO.
    if canCancelIO {
        err = submit(o) // submit関数(クロージャ)を実行
    } else {
        s.req <- ioSrvReq{o, submit}
        err = <-o.errc
    }
    // ...
}

ExecIOは、具体的なI/O操作のロジックをsubmitという関数(クロージャ)として受け取るようになりました。これにより、ExecIO自体は汎用的なI/O実行ロジック(ランタイムへの通知、キャンセル処理、完了待ちなど)に集中し、各I/O操作のWindows API呼び出しは呼び出し元で定義できるようになりました。

Readメソッドの変更例

func (fd *netFD) Read(buf []byte) (int, error) {
    // ...
    o := &fd.rop // netFDに埋め込まれたropを再利用
    o.mu.Lock()  // ropへのアクセスをロック
    defer o.mu.Unlock() // 関数終了時にロックを解放
    o.InitBuf(buf) // バッファ情報をoperationに設定
    n, err := iosrv.ExecIO(o, "WSARecv", func(o *operation) error {
        // WSARecvの呼び出しロジックをクロージャとして渡す
        return syscall.WSARecv(o.fd.sysfd, &o.buf, 1, &o.qty, &o.flags, &o.o, nil)
    })
    // ...
}

Readメソッドでは、fd.ropという既存のoperation構造体へのポインタを取得し、そのミューテックスをロックします。そして、InitBufで読み込みバッファを設定した後、iosrv.ExecIOを呼び出します。ExecIOには、WSARecvを呼び出す具体的なロジックをクロージャとして渡しています。これにより、ヒープ割り当てなしでI/O操作を実行できるようになりました。

src/pkg/net/tcp_test.go

TestTCPReadWriteMallocs

func TestTCPReadWriteMallocs(t *testing.T) {
	maxMallocs := 0
	switch runtime.GOOS {
	// Add other OSes if you know how many mallocs they do.
	case "windows":
		maxMallocs = 0
	}
	// ... TCP接続の確立 ...
	var buf [128]byte
	mallocs := testing.AllocsPerRun(1000, func() {
		_, err := server.Write(buf[:])
		if err != nil {
			t.Fatalf("Write failed: %v", err)
		}
		_, err = io.ReadFull(client, buf[:])
		if err != nil {
			t.Fatalf("Read failed: %v", err)
		}
	})
	if int(mallocs) > maxMallocs {
		t.Fatalf("Got %v allocs, want %v", mallocs, maxMallocs)
	}
}

このテストは、testing.AllocsPerRunを使用して、指定された関数(ここではTCPの書き込みと読み込み)が実行される間に発生するメモリ割り当ての回数を計測します。Windows環境ではmaxMallocs0に設定されており、これはこのコミットの変更によって、TCPの読み書き操作中にヒープ割り当てが一切発生しないことを期待していることを示しています。このテストは、変更が意図通りにメモリ割り当てを削減したことを検証する重要な役割を果たします。

関連リンク

参考にした情報源リンク