ゼロコピーI/Oの技法(sendfile・splice・mmap)
ファイル送信のCPUとメモリ帯域を食い潰すのは「無駄なコピー」です。sendfile・splice・mmap・MSG_ZEROCOPYがコピー回数とモード切替をどう削るのかを原理から掴み、適材適所で選べるようになります。
- 1.素朴な read+write はカーネル内コピー2回・ユーザー空間コピー2回の計4回と、システムコール2回分のモード切替を払う。ゼロコピーはこの無駄を削る技法群。
- 2.sendfile/splice はデータをユーザー空間に上げず、ページキャッシュからソケットへカーネル内で直結する。splice はパイプバッファをページ参照のハブにして任意のfd間を繋ぐ。
- 3.mmap はページキャッシュをアドレス空間へ写像してコピーを1回減らし、MSG_ZEROCOPY は送信ペイロードをユーザーページのまま直接DMAする。完了は非同期通知で受け取る。
ゼロコピーが解く問題
「ディスク上のファイルをそのままソケットへ送る」——Webサーバーやプロキシが毎秒繰り返すこの動作は、素朴に書くとデータを何度もコピーします。ゼロコピーI/Oとは、この意味のないコピーとモード切替を削る技法の総称です。まず削る対象を正確に数えましょう。
read(file_fd, buf, len); // ファイル → ユーザーバッファ
write(sock_fd, buf, len); // ユーザーバッファ → ソケット
このコードが払うコストは2種類あります。データコピーと**コンテキストスイッチ(モード切替)**です。
| 段階 | コピーの向き | 実行主体 | コスト種別 |
|---|---|---|---|
| read 発行 | ユーザー→カーネルへ制御移行 | syscall | モード切替1回 |
| DMA読み出し | ディスク → ページキャッシュ | DMAエンジン | コピー扱い(CPU非関与) |
| read 完了 | ページキャッシュ → ユーザーバッファ | CPU | CPUコピー1回 |
| write 発行 | ユーザー→カーネルへ制御移行 | syscall | モード切替1回 |
| write 処理 | ユーザーバッファ → ソケットバッファ | CPU | CPUコピー1回 |
| DMA送出 | ソケットバッファ → NIC | DMAエンジン | コピー扱い(CPU非関与) |
つまりコピー4回(うちCPUが手を動かすのは2回)とモード切替4回。問題は中央2つのCPUコピーです。データはユーザー空間で何も変換されずに素通りするのに、ページキャッシュ→ユーザーバッファ→ソケットバッファと往復している。この往復はCPUサイクルとメモリ帯域を浪費し、L1/L2キャッシュも汚します。ゼロコピーの本質は、ユーザー空間を経由しないでカーネル内でデータを直結することにあります。前提となるシステムコールとカーネルのモード切替コストと、ページキャッシュとライトバックの仕組みを押さえておくと以降が腑に落ちます。
sendfile:fd間の直結
sendfile(out_fd, in_fd, offset, count) は、入力fd(通常はファイル)から出力fdへカーネル内だけでデータを渡すシステムコールです。ユーザーバッファを一切使いません。
sendfile(sock_fd, file_fd, &offset, len); // これ1本
これでモード切替は往復1回(syscall発行と復帰)に減り、ユーザー空間への/からのCPUコピー2回が消えます。さらにNICがScatter-GatherDMAに対応していれば、ページキャッシュのページをソケットバッファへコピーせず、ソケットバッファには「このページを送れ」というディスクリプタ(ページ参照+オフセット+長さ)だけを置き、NICがページキャッシュから直接DMA読み出しします。この場合、CPUによるデータコピーは完全にゼロになり、残るDMAは2回ともCPU非関与です。これが「真のゼロコピー」と呼ばれる状態です。
sendfile の制約は「データがユーザー空間に出てこない」こと自体に由来します。暗号化(TLS)・圧縮・テンプレート埋め込みなど、送る前にバイト列を変換したい場合はゼロコピーを使えません。TLSはこの壁を破るためにカーネル内で暗号化する kTLS(kernel TLS)が用意され、sendfile と組み合わせて暗号化しつつゼロコピーを保つ道があります。out_fd は歴史的にソケット限定でしたが、現在は通常ファイルなど広いfdを受け付けます。
splice と vmsplice:パイプバッファをハブにする
sendfile は「ファイル風fd→ソケット」に特化していました。これを任意のfd間へ一般化したのが splice です。鍵はパイプを中継器として使う点にあります。
Linuxのパイプは内部的にページ参照の配列(パイプバッファ)です。splice はデータ本体を動かさず、ソースのページをこのパイプバッファに参照として繋ぎ、もう一方の splice でその参照を宛先fdへ繋ぎ替えます。
// file → pipe → socket をページ参照の付け替えだけで実現
splice(file_fd, NULL, pipe_w, NULL, len, SPLICE_F_MOVE);
splice(pipe_r, NULL, sock_fd, NULL, len, SPLICE_F_MOVE);
ページの実体はページキャッシュに居たまま、パイプバッファのスロットがそれを指すだけ。だから「pipeを経由する」と言ってもメモリコピーは発生しません。パイプを必ず挟むのは、異種fdを繋ぐ共通の中間表現としてパイプバッファ(ページ参照のリングバッファ)が機能するからです。
vmsplice はその仲間で、ユーザー空間のメモリ領域(iovec)をパイプバッファへ参照として注入します。memcpy せず、ユーザーページをそのままパイプのスロットに指させるので、アプリが生成したデータをゼロコピーで送出経路へ載せられます。
vmsplice でユーザーページを参照注入した後、カーネルがまだそのページを送信し終える前にアプリがバッファを書き換えると、送信内容が壊れるか古いデータと混ざります。参照を渡した以上、カーネルが使い終わるまでそのページを変更してはいけません。SPLICE_F_GIFT でページをカーネルへ「贈与」する場合は、以後アプリ側がそのページに触れないことが前提になります。所有権がいつカーネルへ移り、いつ戻るかを取り違えると、再現性の低い破損バグになります。
mmap:写像でコピーを1回畳む
mmap は別系統のアプローチです。ファイルをユーザーのアドレス空間へメモリ写像し、ページキャッシュのページをそのまま仮想アドレスに見せます。
char *p = mmap(NULL, len, PROT_READ, MAP_SHARED, file_fd, 0);
write(sock_fd, p, len); // p はページキャッシュを直接指す
read と違い、ページキャッシュ→ユーザーバッファのコピーが起きません。アプリが触る p はページキャッシュそのものを指すからです。ただし write 側ではページキャッシュ→ソケットバッファのCPUコピーが1回残るため、sendfile ほど削れません。mmap の利点はむしろデータをCPUで読み書きしながらコピーを節約できる点で、加工が必要な場合に効きます。仕組みの詳細はメモリマップトファイル(mmap)に譲ります。
mmap はページフォルト経由でページを取り込むため、初回アクセスでマイナーフォルトの行列が発生します。ファイルが他プロセスから truncate されると SIGBUS が飛ぶ危険もあり、TLBエントリの消費やページテーブル更新のコストも伴います。小さなファイルを大量に扱う場面では、mmap のセットアップ費用がコピー削減のメリットを食い潰すことがあります。
MSG_ZEROCOPY:送信ペイロードを直接DMA
ここまでは主に「ファイル→ソケット」でした。アプリが自分で生成したデータをゼロコピー送信したい場合の仕組みが MSG_ZEROCOPY です。send(fd, buf, len, MSG_ZEROCOPY) と指定すると、カーネルは buf のユーザーページをソケットバッファへコピーせず、そのページをピン留めしてNICへ直接DMAします。
問題は完了の通知です。通常の send は「カーネルにコピーし終えたら戻る」ので、戻った瞬間にバッファを再利用できます。ところが MSG_ZEROCOPY はコピーしないため、send が戻ってもまだページは送信中かもしれません。そこでカーネルは送信完了をエラーキュー経由の非同期通知で返します。
send(..., MSG_ZEROCOPY) → すぐ戻る(ページはまだ送信中)
… 実際の送出 …
recvmsg(MSG_ERRQUEUE) → 通知到着。連番でどの送信が完了したか分かる
→ ここで初めて buf を安全に再利用できる
この非同期完了モデルゆえに、MSG_ZEROCOPY は大きなバッファを送るときにだけ得をします。小さい送信ではコピーの方が安く、ページのピン留め・通知処理・エラーキュー読み取りのオーバーヘッドが上回ります。カーネルは「ゼロコピーが割に合わない」と判断すると、黙って通常コピーへフォールバックすることもあり、通知フラグでそれを見分けられます。
「sendfile と MSG_ZEROCOPY の違いは」と問われたら、データの出所で答えます。sendfile はファイル(ページキャッシュ)→ソケットでユーザー空間を一切通さない。MSG_ZEROCOPY はユーザー空間で作ったデータをコピーせず送る代わりに、完了を非同期通知で待つ必要がある。「splice は何でできているか」にはパイプバッファ=ページ参照のリングを中継器に使う、と即答できると強いです。
技法の選び分け
| 技法 | 経路 | 削るもの | 向く場面 |
|---|---|---|---|
| read+write | ファイル→ユーザー→ソケット | (基準) | 加工が必要で量が小さい |
| mmap+write | 写像→ソケット | CPUコピー1回 | 読みながら加工、ランダムアクセス |
| sendfile | ファイル→ソケット直結 | CPUコピー2回+モード切替2回 | 静的ファイルの素通し配信 |
| splice/vmsplice | 任意fd→pipe→任意fd | CPUコピー(ページ参照付替え) | パイプライン・プロキシ中継 |
| MSG_ZEROCOPY | ユーザーページ→NIC直接DMA | 送信時のCPUコピー1回 | アプリ生成の大きなデータ送信 |
選択の軸は3つです。第一にデータを加工するか。加工が要るなら sendfile/splice は使えず、mmap か MSG_ZEROCOPY が候補。第二にデータの出所がファイルかアプリ生成か。第三に送るサイズ——ゼロコピーは固定費(ページ管理・非同期通知)を払うので、小さな転送ではコピーの方が速い場合が多いことです。
まとめ
ゼロコピーI/Oは「ユーザー空間を素通りするだけのデータに、CPUコピーとモード切替を払わせない」一点で貫かれています。基準の read+write はCPUコピー2回・モード切替4回を払う。sendfile はファイル→ソケットをカーネル内直結し、Scatter-GatherDMA対応ならCPUコピーをゼロにする。splice/vmsplice はパイプバッファ(ページ参照のリング)を中継器に任意fd間を繋ぐ。mmap はページキャッシュを写像してコピーを1回畳む。MSG_ZEROCOPY はアプリ生成データをページのまま直接DMAし、完了を非同期通知で受ける。いずれも固定費があり、加工の有無・データの出所・サイズで選び分けるのが要諦です。この経路の先で、カーネルが終えた仕事をアプリへ渡す窓口の最適化は高性能I/Oモデル(epoll・io_uring)の内部へ、パケットがNICまで届く道筋はネットワークスタックのカーネル内データパスへと繋がります。
OS Article
ゼロコピーI/Oの技法(sendfile・splice・mmap)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Linuxカーネル
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
sendfile/splice はデータをユーザー空間に上げず、ページキャッシュからソケットへカーネル内で直結する。splice はパイプバッファをページ参照のハブにして任意のfd間を繋ぐ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「Linuxカーネル / ゼロコピー」に近いか確認する。
- 強みである「素朴な read+write はカーネル内コピー2回・ユーザー空間コピー2回の計4回と、システムコール2回分のモード切替を払う。ゼロコピーはこの無駄を削る技法群。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。