はやいTCPサーバの書き方

cagra高速化にあたってのノウハウを一部公開してみます。また明日校正/更新します。つっこみ待ちです。

select(2)の代わりにepoll_wait(2), kqueue, /dev/epoll等を使う

他に山ほど解説ページがあるので略

大量のディスクリプタを処理するようなサーバの場合、多少効果があるかもしれません。しかし、クライアント数が少ない場合、劇的な性能の向上は見込めないとおもいます。クライアント数が多い場合は、1セッション1スレッドなモデルではOS側のタスクスイッチングのオーバーヘッドが効いてくることも多いです。クライアント数を増やすには複数のセッションを1スレッドで処理できるようにすると良いです。実装にあたっては、non-blocking ioを活用すると効果的です。

TCP_NODELAYを設定する

Nagleアルゴリズムをオフにします。多少応答性が良くなります。

これっておまじないみたいなものなんでしょうか。これで実際に改善した例はしらないです。

(指摘があったので追記/さらに指摘があったので修正)

Nagleアルゴリズムは、相手側のACK送信をまとめてくれるものこちらの送信データに対するACKを受信しおわるまで、送信を遅延させるものです。*1これは、下記の様にアプリケーション側でパケットを意識した処理を行っている場合、邪魔になることがあります。詳細については、Programming UNIX Sockets in C FAQを参照すると良いと思います。

ちなみに、write間隔が短すぎた場合、これを指定していてもパケットがまとめられる事があるようです。これ自身はありがたい挙動なのですが、アプリケーション側で想定しているパケットのまとめ方と、実際のOSのまとめ方にずれが発生する可能性があります。例えば、cagraでは前のリクエストのbodyと次のリクエストのheaderがまとめられてしまったりして、いびつなパケットになってしまい、パフォーマンスロスにつながることがありました。こういうケースの解決にあたっては、下記writev(2)やTCP_CORKが有効になります。

TCPパケットを意識する

write(2)を発行しても、すぐにパケットが送信されるわけではありません。

特に、小さいバッファをwrite(2)した場合、複数のwriteが1つのパケットにまとめられてしまうことが多いです。
これは、多くの場合スループットを高めてくれるありがたい処理なのですが、リアルタイム性が要求される場合、仇となることもあります。リクエストをwriteしたのにいつまでたってもパケットがサーバに送信されない結果、大幅に性能が低下してしまうことがあります。

目安としてMTUマイナスちょっとぐらいのデータがたまって初めてパケットは送信されるようです。

システムコールの回数を減らす

writev(2)を用いると、アドレスが連続していない複数のバッファを一回のシステムコールでまとめてwriteすることができます。結果、システムコール呼び出しに関するオーバヘッドが軽減されます。

使い方はシンプルで、iovec構造体にバッファ先頭へのポインタとバッファのサイズを指定するだけです。しかし、write(2)とおなじく、一回の呼び出しで全てのデータが書き込まれることは保証されていません。中途半端なところで送信を中断される可能性があります。そのような場合には戻り値をチェックして、iovec構造体をつくりなおすことが必要になります。

writev(2)を用いてwriteしたデータはまとめてTCPパケットとして送信されやすい様です。(要検証)

TCP_CORK (linux限定)

上で複数のwrite(2)がまとめられる、と書きましたが、どのwriteがまとめられるのかOS側にヒントを与えることができます。
darwin/FreeBSD向けにはTCP_NOPUSHというものがあるそうです。

// set TCP_CORK
{
  int state = 1;
  setsockopt(fd, IPPROTO_TCP, TCP_CORK, &state, sizeof(state));
}

writeを呼ぶ

// remove TCP_CORK
{
  int state = 0;
  setsockopt(fd, IPPROTO_TCP, TCP_CORK, &state, sizeof(state));
}

TCP_CORKに関しては、baus.netの記事が非常に参考になります。

ファイル送信にsendfile(2)を使う

sendfile(2)を用いれば、ファイル送信処理にかかるメモリコピーの回数を最小限に抑えることが出来ます。

このAPIを用いずにファイル送信をする場合、いったんユーザ空間上のヒープメモリに内容をコピーする処理が必要になります。これには、一旦カーネル空間でファイル読み出しを行った後、ユーザ空間のメモリに内容をコピーし、またこれをTCP送信の為にカーネル空間にコピーするという無駄な処理が含まれます。

sendfileを用いることによって、無駄なユーザ空間へのデータコピーをなくすことができます。

また、BSDのsendfileには、hdtr引数があります。これには、writevと同じようにヘッダ/フッタ用のiovec構造体を指定することができます。これを用いることで、ファイルを含む書き込み処理も同じシステムコールにまとめてしまうことができます。

さらに、Solarisにはsendfileのベクトル版であるsendfilevも用意されています。これにより、ファイル書き込み/メモリ上バッファ書き込みの任意の組み合わせを一つの呼び出しにまとめることが可能です。

ちなみに、非同期IOはsendfileと直接一緒に使うことはできません。lighttpdの実装では、これをむりやり行っていますが、結果的にユーザ空間メモリへのコピーが発生しています。この実装はいろいろと驚異的ですが、批判も多いです。

TCP_DEFER_ACCEPT

サーバ側でgreetingメッセージがない場合で有効です。新規TCPコネクションがクライアント側からのwriteからスタートする場合、最初のデータパケットが到着するまでTCPコネクションをユーザプログラムに渡すのを遅延してくれます。

新規TCP接続をlisten(2)しているディスクリプタに対してsetsockoptすることで、有効になります。

// set TCP_DEFER_ACCEPT
{
  int defer = 1;
  setsockopt(sdListen, IPPROTO_TCP, TCP_DEFER_ACCEPT, &defer, sizeof(defer));
}

また、これによりaccept()直後のソケットがポーリング無しでread可能なことが保証されます。(要検証)

setsockoptで指定する引数には意味がある模様ですが、よくわかっていません。ハンドシェイク直後のTCPコネクションの寿命に関する挙動について変化をもたらす可能性があるので、注意が必要です。参考

TCP_QUICKACK

今しったのであとでかく

おわりに

以上です。また新しくみつけたテクニックがあれば随時追加していきます。後半のパケットバッファのベクトル化については、抽象化したAPIを作って用いるのが最も良い気がします。

編集履歴

1/8 12:57

1/8 14:01

  • TCP_DEFER_ACCEPTについて追記

1/8 14:24

  • id:kazuhooku氏に指摘されたTCP_NODELAYに関して追記/修正。

*1:まだ間違いがある可能性があるので、id:kazuhookuさんのご指摘又はRFC原文を参照されることを推奨します