[インデックス 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/op | new ns/op | delta |
|---|---|---|---|
| BenchmarkTCP4Persistent | 27669 | 23341 | -15.64% |
| BenchmarkTCP4Persistent-2 | 18173 | 12558 | -30.90% |
| BenchmarkTCP4Persistent-4 | 10390 | 7319 | -29.56% |
この変更は、各ビルダが読み書きごとにどれだけの割り当てを行うかを確認するために、意図的にすべてのビルダを壊すことになります。これはすぐに修正される予定です。
変更の背景
Goのネットワークスタック、特にWindows環境におけるI/O操作では、非同期I/O(Overlapped I/O)が多用されます。従来の設計では、各I/O操作(読み込み、書き込み、接続、Acceptなど)ごとにanOpやbufOpといった構造体がヒープに割り当てられていました。これらの構造体は、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インターフェースのReadやWriteなどのブロッキングメソッドを実装しています。
3. syscallパッケージとWindows API
Goのsyscallパッケージは、OSのシステムコールや低レベルAPIへのアクセスを提供します。Windowsの場合、syscallパッケージを通じてWSARecv、WSASend、AcceptEx、TransmitFileなどのWinsock APIや、CancelIoEx、GetQueuedCompletionStatusなどのI/O関連APIを呼び出すことができます。これらのAPIは、非同期I/O操作を開始したり、その結果を取得したりするために使用されます。
4. メモリ割り当てとガベージコレクション (GC)
Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムが実行中にメモリを動的に割り当てると(例: makeやnewを使用)、そのメモリはヒープに配置されます。不要になったメモリはGCによって自動的に解放されます。
- ヒープ割り当て: プログラムの実行中に動的に確保されるメモリ領域です。
- スタック割り当て: 関数呼び出し時にローカル変数などのために確保されるメモリ領域です。関数が終了すると自動的に解放されます。スタック割り当てはヒープ割り当てよりも高速です。
- ガベージコレクション (GC): 不要になったヒープメモリを自動的に回収するプロセスです。GCはプログラムの実行を一時停止させることがあり(Stop-the-World)、これがパフォーマンスのボトルネックになることがあります。GCの頻度や負荷を減らすためには、ヒープ割り当ての回数を減らすことが有効です。
このコミットは、I/O操作ごとにヒープに割り当てられていたanOpやbufOpのような構造体を、netFD構造体内に直接埋め込むことで、スタック割り当てに近い形で再利用可能にし、ヒープ割り当ての回数を削減しています。
5. sync.Mutexと排他制御
sync.MutexはGo言語で提供されるミューテックス(相互排他ロック)です。複数のゴルーチンが共有リソースに同時にアクセスする際に、データ競合を防ぎ、一貫性を保つために使用されます。このコミットでは、netFD内のoperation構造体(ropとwop)へのアクセスを保護するためにsync.Mutexが使用されています。これにより、読み込み操作と書き込み操作が同時に進行しても、それぞれのoperation構造体の状態が正しく管理されるようになります。
技術的詳細
このコミットの核心は、WindowsにおけるネットワークI/O操作の内部実装において、メモリ割り当てのパターンを変更することにあります。
変更前: ヒープ割り当てされるI/O操作構造体
変更前は、各I/O操作(Read、Write、Accept、Connect、SendFileなど)が実行されるたびに、それぞれに対応するanOp、bufOp、readOp、writeOp、acceptOp、connectOp、sendfileOpといった構造体がヒープに割り当てられていました。これらの構造体は、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構造体は、以前のanOpやbufOpの機能を統合し、非同期I/Oに必要なすべてのデータ(syscall.Overlapped、errno、qty、buf、sa、rsa、handle、flagsなど)を保持します。
これにより、ReadやWriteなどの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.ropとfd.wopへのアクセスがそれぞれ排他的に保護されます。これにより、同じnetFDインスタンスに対して複数のゴルーチンが同時に読み込みや書き込みを行おうとした場合でも、データ競合が発生しないようにしています。以前はfd.rioとfd.wioという2つのsync.MutexがnetFDに直接存在し、それぞれ読み込みと書き込みのI/O操作全体をロックしていましたが、変更後はoperation構造体内部のmuがその役割を担います。
iosrv.ExecIOの変更
iosrv.ExecIO関数も変更され、以前はanOpIfaceインターフェースを受け取っていましたが、変更後は*operationポインタと、I/O操作を実行するためのsubmit関数(クロージャ)を受け取るようになりました。これにより、各I/O操作の具体的なWindows API呼び出し(WSARecv、WSASendなど)がExecIOの呼び出し元で定義され、operation構造体のフィールドを直接利用できるようになります。
ioSrvのチャネル変更
ioSrv構造体内のチャネルも変更されています。以前はsubmchanとcanchanという2つのanOpIface型のチャネルがありましたが、これらはreqという単一のioSrvReq型のチャネルに統合されました。ioSrvReqは*operationとsubmit関数(またはキャンセルを示す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ランタイムの間で構造体のメモリレイアウトが同期している必要があるためです。
コアとなるコードの変更箇所
主要な変更は以下のファイルに集中しています。
-
src/pkg/net/fd_windows.go:anOpIfaceインターフェース、anOp構造体、bufOp構造体、readOp、writeOp、connectOp、acceptOp、readFromOp、writeToOpといったI/O操作ごとの構造体が削除され、代わりに汎用的なoperation構造体が導入されました。operation構造体は、syscall.Overlapped、syscall.WSABuf、syscall.Sockaddr、syscall.RawSockaddrAny、syscall.Handle、uint32型のflagsなど、すべてのI/O操作に必要なフィールドを内包します。また、排他制御のためのsync.Mutex(mu) とエラーチャネルerrcも含まれます。netFD構造体にrop operationとwop operationが直接埋め込まれました。newFD関数内で、netFDの初期化時にropとwopのmode、fd、runtimeCtx、errcが設定されるようになりました。ioSrv構造体のsubmchanとcanchanがreq chan ioSrvReqに統合されました。ExecIO関数が*operationとsubmit関数(クロージャ)を受け取るように変更され、各I/O操作の具体的なAPI呼び出しがExecIOの引数として渡されるようになりました。netFDのRead,Write,ReadFrom,WriteTo,Connect,Acceptメソッドが、それぞれfd.ropまたはfd.wopを再利用し、ExecIOを呼び出すように変更されました。netFD.Close()におけるロックの取得・解放が、fd.rio,fd.wioからfd.rop.mu,fd.wop.muに変更されました。
-
src/pkg/net/sendfile_windows.go:sendfileOp構造体が削除され、sendFile関数がfd.wopを再利用するように変更されました。
-
src/pkg/net/tcp_test.go:TestTCPReadWriteMallocsという新しいテストが追加されました。このテストは、TCPの読み書き操作におけるメモリ割り当ての回数を計測し、特定のOS(Windows)で期待される割り当て回数(0回)を超えていないかを確認します。これは、このコミットの主要な目的であるメモリ割り当て削減が正しく達成されていることを検証するためのものです。
-
src/pkg/runtime/netpoll_windows.c:- C言語側の
net_anOp構造体の定義がnet_opに変更され、Go言語側のnet.operation構造体と同期するように修正されました。
- C言語側の
コアとなるコードの解説
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: 読み書きするデータバッファを記述します。WSARecvやWSASendなどで使用されます。sa syscall.Sockaddr,rsa *syscall.RawSockaddrAny,rsan int32: ソケットアドレス情報です。WSARecvFromやWSASendto、AcceptExなどで使用されます。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
}
netFDにropとwopが直接埋め込まれることで、I/O操作ごとにヒープ割り当てが不要になります。newFDでnetFDが作成される際に、これらの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環境ではmaxMallocsが0に設定されており、これはこのコミットの変更によって、TCPの読み書き操作中にヒープ割り当てが一切発生しないことを期待していることを示しています。このテストは、変更が意図通りにメモリ割り当てを削減したことを検証する重要な役割を果たします。
関連リンク
- Go言語の
netパッケージ: https://pkg.go.dev/net - Go言語の
syscallパッケージ: https://pkg.go.dev/syscall - WindowsのI/O完了ポート (IOCP) について: https://learn.microsoft.com/ja-jp/windows/win32/fileio/i-o-completion-ports
OVERLAPPED構造体: https://learn.microsoft.com/ja-jp/windows/win32/api/minwinbase/ns-minwinbase-overlappedWSARecv関数: https://learn.microsoft.com/ja-jp/windows/win32/api/winsock2/nf-winsock2-wsarecvWSASend関数: https://learn.microsoft.com/ja-jp/windows/win32/api/winsock2/nf-winsock2-wsasendAcceptEx関数: https://learn.microsoft.com/ja-jp/windows/win32/api/mswsock/nf-mswsock-acceptexTransmitFile関数: https://learn.microsoft.com/ja-jp/windows/win32/api/mswsock/nf-mswsock-transmitfile
参考にした情報源リンク
- Goのコミット履歴: https://github.com/golang/go/commit/04b1cfa94635f18462b8a076cebacc5e08d92631
- Goのコードレビューシステム (Gerrit): https://golang.org/cl/12413043
- GoのIssue Tracker: https://github.com/golang/go/issues (関連するIssueがある場合)
- Goのドキュメントとブログ記事 (メモリ管理、GC、ネットワークI/Oに関するもの)
- Microsoft Learn (Windows APIドキュメント)
- Goのソースコード (特に
src/pkg/netとsrc/pkg/runtimeディレクトリ) testing.AllocsPerRunのドキュメント: https://pkg.go.dev/testing#AllocsPerRun- Goのガベージコレクションに関する一般的な情報源 (例: Goの公式ブログ記事や技術カンファレンスの発表)
- Goのネットワークプログラミングに関する書籍やオンラインリソース# [インデックス 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/op | new ns/op | delta |
|---|---|---|---|
| BenchmarkTCP4Persistent | 27669 | 23341 | -15.64% |
| BenchmarkTCP4Persistent-2 | 18173 | 12558 | -30.90% |
| BenchmarkTCP4Persistent-4 | 10390 | 7319 | -29.56% |
この変更は、各ビルダが読み書きごとにどれだけの割り当てを行うかを確認するために、意図的にすべてのビルダを壊すことになります。これはすぐに修正される予定です。
変更の背景
Goのネットワークスタック、特にWindows環境におけるI/O操作では、非同期I/O(Overlapped I/O)が多用されます。従来の設計では、各I/O操作(読み込み、書き込み、接続、Acceptなど)ごとにanOpやbufOpといった構造体がヒープに割り当てられていました。これらの構造体は、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インターフェースのReadやWriteなどのブロッキングメソッドを実装しています。
3. syscallパッケージとWindows API
Goのsyscallパッケージは、OSのシステムコールや低レベルAPIへのアクセスを提供します。Windowsの場合、syscallパッケージを通じてWSARecv、WSASend、AcceptEx、TransmitFileなどのWinsock APIや、CancelIoEx、GetQueuedCompletionStatusなどのI/O関連APIを呼び出すことができます。これらのAPIは、非同期I/O操作を開始したり、その結果を取得したりするために使用されます。
4. メモリ割り当てとガベージコレクション (GC)
Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムが実行中にメモリを動的に割り当てると(例: makeやnewを使用)、そのメモリはヒープに配置されます。不要になったメモリはGCによって自動的に解放されます。
- ヒープ割り当て: プログラムの実行中に動的に確保されるメモリ領域です。
- スタック割り当て: 関数呼び出し時にローカル変数などのために確保されるメモリ領域です。関数が終了すると自動的に解放されます。スタック割り当てはヒープ割り当てよりも高速です。
- ガベージコレクション (GC): 不要になったヒープメモリを自動的に回収するプロセスです。GCはプログラムの実行を一時停止させることがあり(Stop-the-World)、これがパフォーマンスのボトルネックになることがあります。GCの頻度や負荷を減らすためには、ヒープ割り当ての回数を減らすことが有効です。
このコミットは、I/O操作ごとにヒープに割り当てられていたanOpやbufOpのような構造体を、netFD構造体内に直接埋め込むことで、スタック割り当てに近い形で再利用可能にし、ヒープ割り当ての回数を削減しています。
5. sync.Mutexと排他制御
sync.MutexはGo言語で提供されるミューテックス(相互排他ロック)です。複数のゴルーチンが共有リソースに同時にアクセスする際に、データ競合を防ぎ、一貫性を保つために使用されます。このコミットでは、netFD内のoperation構造体(ropとwop)へのアクセスを保護するためにsync.Mutexが使用されています。これにより、読み込み操作と書き込み操作が同時に進行しても、それぞれのoperation構造体の状態が正しく管理されるようになります。
技術的詳細
このコミットの核心は、WindowsにおけるネットワークI/O操作の内部実装において、メモリ割り当てのパターンを変更することにあります。
変更前: ヒープ割り当てされるI/O操作構造体
変更前は、各I/O操作(Read、Write、Accept、Connect、SendFileなど)が実行されるたびに、それぞれに対応するanOp、bufOp、readOp、writeOp、acceptOp、connectOp、sendfileOpといった構造体がヒープに割り当てられていました。これらの構造体は、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構造体は、以前のanOpやbufOpの機能を統合し、非同期I/Oに必要なすべてのデータ(syscall.Overlapped、errno、qty、buf、sa、rsa、handle、flagsなど)を保持します。
これにより、ReadやWriteなどの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.ropとfd.wopへのアクセスがそれぞれ排他的に保護されます。これにより、同じnetFDインスタンスに対して複数のゴルーチンが同時に読み込みや書き込みを行おうとした場合でも、データ競合が発生しないようにしています。以前はfd.rioとfd.wioという2つのsync.MutexがnetFDに直接存在し、それぞれ読み込みと書き込みのI/O操作全体をロックしていましたが、変更後はoperation構造体内部のmuがその役割を担います。
iosrv.ExecIOの変更
iosrv.ExecIO関数も変更され、以前はanOpIfaceインターフェースを受け取っていましたが、変更後は*operationポインタと、I/O操作を実行するためのsubmit関数(クロージャ)を受け取るようになりました。これにより、各I/O操作の具体的なWindows API呼び出し(WSARecv、WSASendなど)がExecIOの呼び出し元で定義され、operation構造体のフィールドを直接利用できるようになります。
ioSrvのチャネル変更
ioSrv構造体内のチャネルも変更されています。以前はsubmchanとcanchanという2つのanOpIface型のチャネルがありましたが、これらはreqという単一のioSrvReq型のチャネルに統合されました。ioSrvReqは*operationとsubmit関数(またはキャンセルを示す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ランタイムの間で構造体のメモリレイアウトが同期している必要があるためです。
コアとなるコードの変更箇所
主要な変更は以下のファイルに集中しています。
-
src/pkg/net/fd_windows.go:anOpIfaceインターフェース、anOp構造体、bufOp構造体、readOp、writeOp、connectOp、acceptOp、readFromOp、writeToOpといったI/O操作ごとの構造体が削除され、代わりに汎用的なoperation構造体が導入されました。operation構造体は、syscall.Overlapped、syscall.WSABuf、syscall.Sockaddr、syscall.RawSockaddrAny、syscall.Handle、uint32型のflagsなど、すべてのI/O操作に必要なフィールドを内包します。また、排他制御のためのsync.Mutex(mu) とエラーチャネルerrcも含まれます。netFD構造体にrop operationとwop operationが直接埋め込まれました。newFD関数内で、netFDの初期化時にropとwopのmode、fd、runtimeCtx、errcが設定されるようになりました。ioSrv構造体のsubmchanとcanchanがreq chan ioSrvReqに統合されました。ExecIO関数が*operationとsubmit関数(クロージャ)を受け取るように変更され、各I/O操作の具体的なAPI呼び出しがExecIOの引数として渡されるようになりました。netFDのRead,Write,ReadFrom,WriteTo,Connect,Acceptメソッドが、それぞれfd.ropまたはfd.wopを再利用し、ExecIOを呼び出すように変更されました。netFD.Close()におけるロックの取得・解放が、fd.rio,fd.wioからfd.rop.mu,fd.wop.muに変更されました。
-
src/pkg/net/sendfile_windows.go:sendfileOp構造体が削除され、sendFile関数がfd.wopを再利用するように変更されました。
-
src/pkg/net/tcp_test.go:TestTCPReadWriteMallocsという新しいテストが追加されました。このテストは、TCPの読み書き操作におけるメモリ割り当ての回数を計測し、特定のOS(Windows)で期待される割り当て回数(0回)を超えていないかを確認します。これは、このコミットの主要な目的であるメモリ割り当て削減が正しく達成されていることを検証するためのものです。
-
src/pkg/runtime/netpoll_windows.c:- C言語側の
net_anOp構造体の定義がnet_opに変更され、Go言語側のnet.operation構造体と同期するように修正されました。
- C言語側の
コアとなるコードの解説
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: 読み書きするデータバッファを記述します。WSARecvやWSASendなどで使用されます。sa syscall.Sockaddr,rsa *syscall.RawSockaddrAny,rsan int32: ソケットアドレス情報です。WSARecvFromやWSASendto、AcceptExなどで使用されます。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
}
netFDにropとwopが直接埋め込まれることで、I/O操作ごとにヒープ割り当てが不要になります。newFDでnetFDが作成される際に、これらの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環境ではmaxMallocsが0に設定されており、これはこのコミットの変更によって、TCPの読み書き操作中にヒープ割り当てが一切発生しないことを期待していることを示しています。このテストは、変更が意図通りにメモリ割り当てを削減したことを検証する重要な役割を果たします。
関連リンク
- Go言語の
netパッケージ: https://pkg.go.dev/net - Go言語の
syscallパッケージ: https://pkg.go.dev/syscall - WindowsのI/O完了ポート (IOCP) について: https://learn.microsoft.com/ja-jp/windows/win32/fileio/i-o-completion-ports
OVERLAPPED構造体: https://learn.microsoft.com/ja-jp/windows/win32/api/minwinbase/ns-minwinbase-overlappedWSARecv関数: https://learn.microsoft.com/ja-jp/windows/win32/api/winsock2/nf-winsock2-wsarecvWSASend関数: https://learn.microsoft.com/ja-jp/windows/win32/api/winsock2/nf-winsock2-wsasendAcceptEx関数: https://learn.microsoft.com/ja-jp/windows/win32/api/mswsock/nf-mswsock-acceptexTransmitFile関数: https://learn.microsoft.com/ja-jp/windows/win32/api/mswsock/nf-mswsock-transmitfile
参考にした情報源リンク
- Goのコミット履歴: https://github.com/golang/go/commit/04b1cfa94635f18462b8a076cebacc5e08d92631
- Goのコードレビューシステム (Gerrit): https://golang.org/cl/12413043
- GoのIssue Tracker: https://github.com/golang/go/issues (関連するIssueがある場合)
- Goのドキュメントとブログ記事 (メモリ管理、GC、ネットワークI/Oに関するもの)
- Microsoft Learn (Windows APIドキュメント)
- Goのソースコード (特に
src/pkg/netとsrc/pkg/runtimeディレクトリ) testing.AllocsPerRunのドキュメント: https://pkg.go.dev/testing#AllocsPerRun- Goのガベージコレクションに関する一般的な情報源 (例: Goの公式ブログ記事や技術カンファレンスの発表)
- Goのネットワークプログラミングに関する書籍やオンラインリソース