[インデックス 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のネットワークプログラミングに関する書籍やオンラインリソース