プログラミング
Goにおけるゼロコピー:sendfile、splice、そしてio.Copyのコスト
Zero-copy in Go: sendfile, splice, and the cost of io.Copy (segflow.github.io)
要約
Go言語でファイルサーバーを運用する際、`io.Copy`の内部実装がカーネルレベルのゼロコピー最適化(`sendfile`や`splice`)をどのように利用するか、また、どのような場合にその最適化が失われるかについて解説しています。`*os.File`を直接渡す場合は最適化が適用されますが、`io.Reader`でラップするとカーネルとユーザー空間間のデータコピーが発生し、CPU使用率とレイテンシが増加するコストについて具体例を挙げて説明しています。
全文翻訳
私の小さなファイルサービングサービスが、ある午後、「無害な」ミドルウェアの変更後に遅くなりました。サーバーボックスのCPU使用率は倍増し、スループットは約半分になりました。
差分は1行でした。`io.Copy`に`*os.File`を渡す代わりに、誰かがそれを小さなロギングリーダーでラップしてバイト数をカウントしていました。
その1回のラップが、`sendfile(2)`を静かに無効にしてしまいました。
この記事は、その高速パスについてです。Goが無料で何をしてくれるのか、それが実際に機能していることをどのように確認できるのか、そしてそれを失う驚くほど簡単な方法についてです。
セットアップ
Linux 6.6 / Ubuntu 24.04 (WSL2)、AMD Ryzen 5 9600X、16 GiB RAM
Go 1.22.1
512 MiBのランダムバイトファイル、ページキャッシュウォーム
以下のすべてのベンチマークは、同じマシン上のGoクライアントにプレーンTCPで同じbig.binファイルをサーブします。
サーバーはCPU 0に、クライアントはCPU 1にピン留めされているため、サーバー側で`/usr/bin/time`を読み取り、リンゴとリンゴを比較できます。
システムコールのカウントは、バニラの`strace -c -e trace=read,write,sendfile,splice`から取得します。
`sendfile`が実際に行うこと
通常の「ファイルを送信する」は次のようになります。
ディスク -> ページキャッシュ -> `read()`でユーザーバッファへ -> `write()`でソケットバッファへ -> NIC
^ コピー1 ^ コピー2
`sendfile(2)`は、これらの2つのコピーをカーネル内転送1回に集約します。
ディスク -> ページキャッシュ --(`sendfile`)--> ソケットバッファ -> NIC
^ ユーザー空間への往復なし
読み取りも書き込みも、32 KiBバッファのやり取りもありません。カーネルはページキャッシュページをソケットの送信キューに直接スプライスするだけです。
ソケット間転送の場合、同等の操作は`splice(2)`であり、これはバイトをユーザーメモリにまったくマテリアライズすることなくカーネルパイプを介して移動します。
Goでは、これらのいずれかを直接呼び出すことはありません。標準ライブラリが、可能な場合はそれを行います。
高速パス
Goランタイムは`*net.TCPConn`に`ReadFrom`メソッドを提供します。
`io.Copy(conn, f)`
`io.Copy`は、宛先が`io.ReaderFrom`を実装しているかどうかを確認します。
`*net.TCPConn`は実装しているため、呼び出しはその`ReadFrom`にディスパッチされます。
そのメソッドの最初のジョブはソースを確認することです。それは`*os.File`ですか?それは`*os.File`をラップした`*io.LimitedReader`ですか?もしそうなら、それは内部の`internal/poll.SendFile`を呼び出し、ファイルがなくなるまで`sendfile(2)`をループします。
検出チェーン全体は2つのファイルにあります:`net/sendfile_linux.go`と`os/zero_copy_linux.go`。
大まかには次のようになります。
// (簡略化、net/sendfile_linux.go内)
lr, ok := r.(*io.LimitedReader)
if ok {
remain, r = lr.N, lr.R
}
f, ok := r.(*os.File)
if !ok {
return 0, nil, false // フォールバック
}
// ... sendfileループ ...
2つの型アサーションと1つのシステムコールループ。
それがすべてです。
3つのハンドラー、1つのファイル
比較したい3つのリーダー形状を以下に示します。
これら3つすべてが、プレーンTCP経由で同じ512 MiBファイルをサーブします。
唯一の違いは、`io.Copy`に渡されるものです。
// raw: io.Copyに直接*os.Fileを渡す。
_, _ = io.Copy(conn, f)
// wrapped: *os.Fileを「単なるio.Reader」構造体の背後に隠す。
type justReader struct { r io.Reader }
func (j justReader) Read(p []byte) (int, error) { return j.r.Read(p) }
_, _ = io.Copy(conn, justReader{r: f})
// limit: *io.LimitedReaderでラップする。ランタイムが検出する唯一のラッパー。
_, _ = io.Copy(conn, io.LimitReader(f, fileSize))
`justReader`は何もしません。
「バイト数を数えたいだけ」や「トレーススパンを注入したいだけ」など、ファイルの前部に`io.Reader`を配置するだけの無害な理由の最小限の例です。
型システムに関しては、値はもはや単なる`io.Reader`です。
ランタイムの`*os.File`に対する型スイッチは失敗し、最適化は失われます。
`io.LimitReader`もラップしているように見えますが、ランタイムは`*io.LimitedReader`を明示的にチェックしてから諦め、アンラップして続行します。
そのため、高速パスが維持されます。
3つのハンドラー、3つの異なる内部動作。
`strace`で確認する
各ハンドラーを`strace -c -e trace=read,write,sendfile,splice`の下で実行し、512 MiBの転送を5回実行し、概要を確認します。
raw (io.Copy(conn, f)):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.79 0.231981 78 2958 860 sendfile
0.15 0.000359 51 7 1 write
0.05 0.000126 18 7 read
------ ----------- ----------- --------- --------- ----------------
100.00 0.232466 78 2972 861 total
2,958回の`sendfile`呼び出し、7回の読み取り、7回の書き込み。
読み取りと書き込みは、ファイルデータではなく、受け入れ/セットアップのチャッターです。
860件の「エラー」は、ソケットバッファがいっぱいになり、ランタイムポーラーがバックバウンスしたときのEAGAIN戻り値であり、`sendfile`下では正常です。
wrapped (io.Copy(conn, justReader{f})):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
56.67 3.202339 48 65546 3 write
43.33 2.448353 37 65547 read
------ ----------- ----------- --------- --------- ----------------
100.00 5.650692 43 131093 3 total
ゼロ`sendfile`。
約131千回の読み取りと書き込みシステムコールが組み合わされており、すべてがユーザー空間バッファを介してデータをバウンスする32 KiBチャンクです。
この32 KiBという数値は、`io.copyBuffer`のデフォルトです。
システムコールで費やされた壁時間は、高速パスの約24倍です。
負荷下のラップされたハンドラーのCPUプロファイルは、呼び出しチェーンを明確にします。
実行中に収集された1,670件のCPUサンプルのうち、1,362件(約82%)はシステムコール内でした。
ソケットへの書き込みで761件、ファイルから次の32 KiBを読み取るために522件。
各ホットスタックの最上部にあるフレームシーケンスは、`io.Copy -> io.copyBuffer -> (*TCPConn).ReadFrom -> readFrom -> io.Copy -> io.copyBuffer -> ....`です。
このネストされた`io.copyBuffer`が署名です。`*TCPConn.readFrom`は`sendfile`に渡す`*os.File`を見つけられなかったため、作業を`io.copyBuffer`に戻しました。これは現在、手動でバウンスを実行しています。
フレイムグラフは、そのバウンスの各サイクルの行き先を示しています。
limit (io.Copy(conn, io.LimitReader(f, n))):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.84 0.239191 82 2896 893 sendfile
0.14 0.000330 47 7 1 write
0.02 0.000047 6 7 read
rawと区別がつかない。
2,896回の`sendfile`。
`LimitReader`ラップは無料です。なぜなら、ランタイムはその特定の型を認識しているからです。
これがかかるコスト
ループバックのスループットは誤解を招く数値です。受信側とTCPバッファが多くのものを吸収するため、壁時計時間はすべてのモードで似ています(リクエストあたり1.5〜1.7 GiB/s)。
より正直な測定値は、ファイルを送信するためのサーバー自身のCPU時間です。
mode
server user+sys CPU (10 x 512 MiB)
per-GiB CPU
raw
0.27 s
~54 ms
wrapped
0.92 s
~184 ms
limit
0.30 s
~60 ms
ラップされたパスは、バイトあたりのCPUコストが約3.4倍になります。
ローカルホストでは吸収されますが、実際のネットワークや同時負荷の下では、コアを固定し、テールレイテンシを低下させます。
同じ2.5 GiBを移動するために、2,972回のシステムコール対131,093回のシステムコールは、「`io.Reader`が欲しいだけ」と言うことのコストです。
ペイロードのMiB/秒のサーバーCPU消費量として表示された同じデータは、ギャップを際立たせます。
splice for socket-to-socket
`sendfile`はファイルからソケットへの転送です。
対称的なケースはソケットからソケットへの転送であり、これはプロキシの主力です。
そこでは、ランタイムは代わりに`splice(2)`を使用します。
30行のTCPプロキシ:
ln, _ := net.Listen("tcp", ":9100")
for {
c, _ := ln.Accept()
go func(client net.Conn) {
defer client.Close()
server, _ := net.Dial("tcp", upstream)
defer server.Close()
go io.Copy(server, client)
io.Copy(client, server)
}(c)
}
2つの`io.Copy`呼び出し、どちらも`*net.TCPConns`間です。
プロキシを介した1回の転送を`strace`で確認します。
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.98 0.754999 70 10677 481 splice
0.01 0.000109 13 8 read
0.01 0.000065 65 1 write
10,677回の`splice`呼び出し。
データパスでの読み取りまたは書き込みはゼロです。
プロキシはバイトに一切触れません。
`(*TCPConn).readFrom`はソースを見て、別の`*TCPConn`を見て、`spliceFrom`にディスパッチします。
同じ脆弱性のルールが適用されます。どちらかの側を独自の`io.Reader`または`io.Writer`でラップすると、32 KiBバウンスにフォールバックします。
経験則
このすべてを開始したファイルサーバーから得たいくつかの教訓:
避けることができるなら、ラップしないでください。
ファイルと接続の間の`io.Reader`の各レイヤーは、型アサーションによって高速パスから抜け出す機会です。
ランタイムは、