
2026/07/06 5:24
Goのゼロコピー:sendfile、splice、そしてio.Copyのコスト
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Go アプリケーションにおける中間層(middleware)の軽微な変更が、意図せずしてサーバーパフォーマンスの急激な低下を引き起こしました。それは、カーネルの
sendfile(2) ゼロコピー最適化を無効化することで発生しました。この技術は、データをディスクからネットワークソケットへ直接移動させ、中間バッファを処理するために CPU を消費することを可能にします。根本原因は、独自のカスタムファイルラッパーが、Go ランタイムがこの高速パスを有効にするために使用する特定の内部チェックを無視したことにあります。効率的な直接転送が行われる代わりに、ラップされたハンドラは 1 リクエストあたり 13 万 1,000 回を超える読み取りおよび書き込みシステムコールの実行を強要しました。これに対して、生のハンドラではわずか 3,000 回でした。プロファイリングの分析により、アプリケーションはその実行時間の約 82% を、これらの非効率的なシステムコールを実行することに費やし、ビジネスロジックの実行にはほとんど時間を割いていないことが確認されました。その結果、負荷下では CPU 使用量は倍増し、スループットは半分になりました。このようなスケーラビリティの問題を防ぐため、開発者はカスタムラッパーが io.WriterTo や io.ReaderFrom などの標準インターフェースを実装することを確実にする必要があります。そうすることで、ランタイムがゼロコピーリクエストを正しく転送できるようになります。これらの要件を無視することは、効率的なディスクからソケットへの転送を、大規模なファイル転取理を行うあらゆるシステムにおいて深刻な帯域幅および CPU ボトルネックに変えてしまいます。
Text to translate:
A minor middleware change in a Go application unintentionally caused a dramatic collapse in server performance by disabling the kernel's
sendfile(2) zero-copy optimization. This technique allows data to move directly from disk to network socket without consuming CPU to process intermediate buffers. The root cause was that a custom file wrapper ignored specific internal checks the Go runtime uses to enable this fast path. Instead of efficient direct transfers, the wrapped handler forced the system to execute over 131,000 read and write calls per request compared to just 3,000 for the raw handler. Profiling confirmed that the application spent roughly 82% of its execution time merely performing these inefficient system calls rather than running business logic. Consequently, CPU usage doubled while throughput halved under load. To prevent such scalability issues, developers must ensure custom wrappers implement standard interfaces like io.WriterTo or io.ReaderFrom so the runtime can correctly forward zero-copy requests. Ignoring these requirements turns efficient disk-to-socket transfers into severe bandwidth and CPU bottlenecks for any system handling large file transfers.本文
Go ランタイムにおける「無害」なラッパーが引き起こすパフォーマンス劣化:sendfile 無効化の解説
小型のファイルサーバー開発中に、単一行の変更で CPU 使用率が倍増し、スループットが半減するという深刻な障害が発生しました。原因は、
io.Copy に渡す値をバイト数を計数するためのラッパー(io.LimitedReader)にすることでした。この行為は静的には無害に見えますが、Go の*「高速パス(Fast Path)」である sendfile(2) を無効化し、データのユーザー空間への往復コピーを強いる**という致命的な副作用を持ちます。
この記事では、なぜその仕様が成立するのか、確認方法、そして回避策について解説します。
環境設定 (The setup)
本記事のベンチマークは以下の環境で行っています。
- OS: Linux 6.6 / Ubuntu 24.04 (WSL2)
- CPU: AMD Ryzen 5 9600X
- RAM: 16 GiB
- Go Version: 1.22.1
- ワークロード: 512 MiB のランダムバイトファイル(ページキャッシュがウォーム状態)
測定条件
- サーバーとクライアントは同一マシン上で動作。
- 同じ
ファイルを単純な TCP 経由で配信。big.bin - サーバー: CPU コア 0 にピン留め、クライアント: CPU コア 1 にピン留め。
- 計測ツール:
(サーバーサイド時間計測)。/usr/bin/time - システムコール追跡:
strace -c -e trace=read,write,sendfile,splice
sendfile が実際に何を行うか (What sendfile actually does)
通常の転送(スローパス)
通常の「ファイルを転送する」という動作は、データをユーザー空間のバッファを往復する必要があります。
- ディスク → ページキャッシュ
でユーザーバッファへコピーread()
でソケットバッファへコピーwrite()- NIC
このプロセスでデータが2 回コピーされます(ディスク↔RAM、RAM↔NIC)。
sendfile(2)(高速パス)
sendfile(2) は、ユーザー空間への往復を排除した単一の内部転送を実現します。
- ディスク → ページキャッシュ
でソケットバッファへ直接結合(splice)sendfile()- NIC
- データはアドレス空間を通過しません。
- カーネルがページキャッシュのページをそのままソケット送信キューに結合します。
注意: ソケットからソケットへの転送には
splice(2) が使用されます。Go ランタイムも標準ライブラリを通じてこれを自動で行います。
高速パス (The fast path) の内部動作
Go ランタイムの
io.Copy は、宛先が io.ReaderFrom を実装している場合(*net.TCPConn や *os.File)、そちらのメソッドを呼び出します。
このロジックは
と net/sendfile_linux.go
に実装されており、極めてシンプルです:os/zero_copy_linux.go
// (simplified, in 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 ループを実行 ...
重要な要件:
- スイッチ文によるタイプライド断定(Type Assertion)が成功しなければならない。
- 型システムとしては有効でも、ランタイム側で明示的に
か*os.File
かを検出できない場合、高速パスは利用できません。*io.LimitedReader
3 つのハンドラー比較 (Three handlers, one file)
以下の 3 つのパターンを
io.Copy(conn, destination) に渡す値の違いだけで比較します(すべて同じ 512 MiB ファイル)。
1. raw: 原生ファイル(高速パス使用)
*os.File をそのまま渡す場合。
_, _ = io.Copy(conn, f)
- 結果: 高速パスが維持される。
2. wrapped: 無害なラッパー(高速パス失墜)
単なる
io.Reader を実装する構造体でラップした場合。
type justReader struct { r io.Reader } func (j justReader) Read(p []byte) (int, error) { return j.r.Read(p) } // 無害に見えますが、*os.File は見えないため高速パス失效 _, _ = io.Copy(conn, justReader{r: f})
- 結果: タイプライド断定(
)に失敗し、スローパスへフォールバック。*os.File
3. limit: ランタイム認識のラッパー(高速パス維持)
*io.LimitedReader でラップした場合。
_, _ = io.Copy(conn, io.LimitReader(f, fileSize))
- 結果: ランタイムは
のチェックを内部で実装しており、アンラップして処理するため、高速パスが維持される。*io.LimitedReader
結論: 「ただバイト数を数えたいだけ」というミドルウェアの実装(例:
justReader)は、静的に無害に見えても、ランタイムの型検査を回避してしまいます。これが原因でパフォーマンスが低下します。
strace による監視 (Watching it with strace)
各パターンのシステムコール呼び出しを確認します。512 MiB の転送を 5 回繰り返し、サマリーを比較しました。
raw (io.Copy(conn, f)
)
io.Copy(conn, f)sendfile が主体となり、read/write はsetupのみ。エラーはバッファ満杯時の EAGAIN(通常動作)。
% 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
wrapped (io.Copy(conn, justReader{f})
)
io.Copy(conn, justReader{f})sendfile はゼロ回。全てのデータがユーザー空間を往復します。
% 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
- read: ~6.5 万回、write: ~6.5 万回(合計 13 万回以上の Syscall)。
- データサイズ:
のデフォルトバッファサイズ(32 KiB)のチャンクで往復。io.Copy - CPU コスト: raw に比べて約 24 倍 のウォール時間が必要になります。
- Flamegraph: ヒートスタック上部が
->io.Copy
->io.copyBuffer
と続くことを示しています。syscall.Read/Write
limit (io.Copy(conn, io.LimitReader(f, n))
)
io.Copy(conn, io.LimitReader(f, n))raw と同様に高速パス作動。
% 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
この代償 (What this costs)
CPU 使用時間の比較
(サーバーユーザー+システム CPU / 10 x 512 MiB ファイル転送)
| モード | サーバー CPU 時間 | ガビ/GB (CPU/ms) |
|---|---|---|
| raw | 0.27 秒 | ~54 ms |
| limit | 0.30 秒 | ~60 ms |
| wrapped | 0.92 秒 | ~184 ms |
- 結論:
パターンでは、バイトあたりの CPU コストが約 3.4 倍 に増大します。wrapped - ネットワーク環境や負荷が高くなると、この差はさらに顕著になり、テールレイテンシ(tail latency)を大幅に悪化させます。
スplice の活用(プロキシの場合)
ソケットからソケットへの転送でも同様のルールが適用されます。
io.Copy を介したプロキシで splice が作動します。しかし、片方のエンドポイントをラップすると(例:クライアント側を別のスコープの Reader として扱う)、自動的に read/write の往復にフォールバックします。
% 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
ラップしていない場合は
splice が 10,000 回以上、ラップした場合は数万回の read/write に置き換わります。
指針 (Rules of thumb)
高速パスを維持するための実践的なガイドラインです。
-
🚫 ラップしない(原則):
やio.Reader
を実装してラップすることを避けてください。ファイルと接続の間に層が増えるほど、タイプ断定で高速パスが失われるリスクが高まります。io.Writer -
✅ 必須の場合はインターフェースを実装せよ: ラップせざるを得ない場合でも、転送のディスパッチを
に委ねるためには、ソース側にio.Copy
、宛先側にio.WriterTo
を実装してください。これにより、ランタイムが独自の実装(splice 等)を利用し続けます。io.ReaderFrom -
💡 io.LimitReader は例外なく無料: レンジレスポンスなど、バイト数の制御が必要な場合のみ、標準ライブラリの
を使用してください。ランタイムはこれを特定して高速パスを維持するため、他の自作ラッパーとは異なりコストゼロです。io.LimitReader -
🛠️ strace で検証せよ: 直感に頼らず
を実行してください。strace -c- 正常:
またはsendfile
が主体で、read/write はほぼゼロ。splice - 異常: read/write のペアが数万分の回呼び出されている(32 KiB チャンク)場合、高速パスに乗っていない証拠です。
- 正常:
-
⚠️ 常に高速パスがあると仮定せず: フレームワークや特定のライブラリによって
が裏で別の処理を行っている可能性があります。sendfile ルートを回避する理由を常に確認してください。io.Copy
まとめ:
とsendfileは Go ランタイムが無料で提供する重要な機能ですが、最も良意のある理由(メトリクス測定や型変換など)で追加された「無害なラッパー」によって容易に破壊されます。システムコールのプロファイリングを習慣化し、データコピーのコストを意識してコードを書きましょう。splice