TCP_NODELAYの効果を確かめる (KLabTechBook Vol.13)
この記事は2024年5月25日から開催された技術書典16にて頒布した「KLabTechBook Vol.13」に掲載したものです。
現在開催中の技術書典17オンラインマーケットにて新刊「KLabTechBook Vol.14」を頒布(電子版無料、紙+電子 500円)しています。 また、既刊も在庫があるものは物理本をオンラインマーケットで頒布しているほか、 KLabのブログからもすべての既刊のPDFを無料DLできます。 合わせてごらんください。
KLabでは独自のリアルタイム通信基盤「WSNet2」を開発運用し、OSSとしても公開しています。 ある日、WSNet2のサーバーを海外に建て、手元のクライアントからのメッセージの応答時間を調べていたところ、 想定より妙に長い時間がかかっていることに気づきました。
WSNet2はメッセージの送受信にWebSocketを利用しています1。 WebSocketはHTTPをベースとしていて、基本的にはTCPでパケットを送り合うことになります。 そしてこのとき見つけた遅延の原因はTCPの機能にありました。
この章では、遅延の原因となったTCPの機能であるNagleのアルゴリズムとTCP_NODELAY
オプションについて解説し、
実際にどのような動きをするのか確認します。
加えて、Unity特有の事情についても解説します。
TCPの小さなパケット問題
TCPではパケットが到達することをプロトコル自体で保証しています。 送信側はパケットのヘッダにシーケンス番号などの情報を付け加えておき、 また受信側は受信したことをACKというメッセージで送信側に通知します。 これらの情報を使って、未到達のパケットを自動で再送するようになっています。
このため、TCPでは1バイトのデータを送るだけでもTCPとIPのヘッダを合わせて40バイト以上送信することになります。 このような小さなパケットを多数送るような状況はtelnetセッションなどでよく発生し、 通信帯域の限られていた1980年代には輻輳崩壊の原因になりうるような無視できないオーバーヘッドだったようです。 この問題を回避する方法として、RFC 896が提案されました。
NagleのアルゴリズムとTCP_NODELAY
RFC 896で提案された手法は、送信するデータをある程度バッファリングしてひとつのTCPパケットにまとめることで効率化するというものです。 データをまとめるかどうかの決定方法は、RFCの著者の名前をとってNagleのアルゴリズムと呼ばれています。
Nagleのアルゴリズムでは、次の条件を満たすまでデータをバッファリングして、ひとつのパケットにまとめます。
- 未送信のデータが最大セグメントサイズ2を超える
- 未受信のACKがなくなる
2024年05月12日時点の日本語版Wikipediaでは条件に「タイムアウトになる」が加えられていますが間違いです。 元のRFCにはタイマーは不要と明記されていますし3、少なくともLinuxカーネルの実装にはタイムアウトはありません。
さて、WSNet2で問題になっていたのはサーバーを海外に建てているときでした。 物理的な距離によりレイテンシーが高く、ACKが届くのにも時間がかかっていました。
他にもACKが遅延する要因として、RFC 1122に記載されているTCP遅延ACKという機能もあります。 これはACKの返答を一時的に遅らせ、複数のACK応答をまとめて返すことでプロトコルのオーバーヘッドを減らすものです。
これらの要因でACKが遅延したために、Nagleのアルゴリズムにより、 ACKが届くまでの間に送信したメッセージはTCPのレイヤーでバッファリングされてしまいました。 このバッファリングされている時間の分、アプリケーションからは応答時間が長くなっているように見えたわけです。
このようなケースに対応するために、Nagleのアルゴリズムを無効にするオプションTCP_NODELAY
が用意されており、setsockopt
関数で設定できます。
実験
それでは実際にNagleのアルゴリズムの働きとTCP_NODELAY
の効果を確認してみましょう。
TCPサーバーとネットワーク遅延設定
まずはDockerを使って単純なTCPサーバーとネットワーク遅延環境を用意します。 DockerはLinuxなので、tcコマンド(traffic control)で通信遅延のエミュレートが簡単にできます。
最初にTCPサーバー用のコンテナを立ち上げます。
ネットワーク遅延を設定するためには、--privileged
オプションの指定が必要です4。
また、名前をtcpsv
としておきます。
docker run -it --name=tcpsv --privileged alpine
コンテナが起動したらtc
コマンドをインストールし、送信パケットを1秒遅延させるように設定します。
tc
コマンドで指定するデバイスeth0
は外部との通信に使われるネットワークインターフェイスです。
このコンテナから外部へ送信するパケットは遅延しますが、受信するものは遅延しないことに注意してください。
apk add iproute2-tc
tc qdisc add dev eth0 root netem delay 1s
TCPサーバーはnc
コマンド(netcat)を使うことで簡単に用意できます。
次のように5000番ポートで待ち受けます。
nc -l -p 5000
クライアントの実装
指定サーバーにTCPで1文字ずつ送るプログラムを用意しました。 このソースコードはGitHub Gistにも置いてあるのでダウンロードしてお使いください。
▼リスト1 main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netdb.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
int main(int argc, char **argv)
{
if (argc <= 2) {
printf("usage: %s <host> <port> [nodelay]\n", argv[0]);
return 0;
}
struct addrinfo hint, *addr;
memset(&hint, 0, sizeof(hint));
hint.ai_family = AF_INET;
hint.ai_socktype = SOCK_STREAM;
if (getaddrinfo(argv[1], argv[2], &hint, &addr) != 0) {
perror("getaddrinfo");
return 1;
}
int sock = socket(
addr->ai_family, addr->ai_socktype, addr->ai_protocol);
if (sock == -1) {
perror("socket");
return 1;
}
/* TCP_NODELAYの設定 */
int n = (argc > 3) ? atoi(argv[3]) : 0;
if (setsockopt(sock, SOL_TCP, TCP_NODELAY, &n, sizeof(n)) == -1) {
perror("setsockopt");
return 1;
}
if (connect(sock, addr->ai_addr, addr->ai_addrlen) == -1) {
perror("connect");
return 1;
}
/* 1文字ずつ100ms間隔で送信 */
for (int i=0; i<100; i++) {
char c = '0' + (i % 10);
write(sock, &c, 1);
usleep(100000);
}
close(sock);
freeaddrinfo(addr);
return 0;
}
クライアント用のコンテナを立ち上げ、gcc
でコンパイルします。
サーバーコンテナtcpsv
にアクセスしやすいよう--link
も指定しておきます。
docker run -it --link=tcpsv alpine
apk add gcc libc-dev
wget https://gist.github.com/makiuchi-d/f748ca25bd4089756faa45fe3af4ced0/raw/main.c
gcc -o tcpcl main.c
ビルドしたtcpcl
コマンドは引数でサーバーとポートを指定して実行します。
サーバーコンテナにはtcpsv
という名前でアクセスできます。
./tcpcl tcpsv 5000
サーバーコンテナの画面に0~9の数字が順に表示されることが確認できるはずです。
クライアントが終了するとnc
も停止するので、サーバーコンテナ側で毎回nc
コマンドを実行しなおしてください5。
通信内容の確認
LinuxマシンでDockerを使っている場合は簡単です。
ホストのdocker0
インターフェイスをWiresharkなどでキャプチャすることでコンテナ間の通信内容を見ることができます。
tcpcl
実行時の通信内容は図1のようになっています。
▲図1 Nagleのアルゴリズムの効果
サーバーからのACKを受け取ってからデータを送信していて、Data部が"0123456789"
の10文字になっているのがわかります。
また実行中にサーバーの画面をみていると、約10文字ごとに表示が進んでいくのが見て取れると思います。
続いてTCP_NODELAY
を有効にしてみます。
tcpcl
の3番目の引数に1
を指定すると有効になります。
./tcpcl tcpsv 5000 1
図2のように、サーバーからのACKを待たずに1文字ずつ送信しているのが見て取れます。 サーバーの画面も1文字ずつ順に表示が進んでいくことが分かるはずです。
▲図2 TCP_NODELAY
の効果
このように、Nagleのアルゴリズムによって1パケットにデータがまとめられている様子や、
TCP_NODELAY
によってそれが無効になっている様子が確認できました。
Unity特有の事情
C#ではSocket
クラスのNoDelay
プロパティによってTCP_NODELAY
の有効無効を設定できます。
このプロパティをセットすると、内部では最終的にsetsockopt
関数がよばれます。
WSNet2のC#クライアント実装では、標準ライブラリのSystem.Net.WebSockets.ClientWebSocket
を使用しています。
現在公式サポートされているUnityのC#ランタイムは.NET Framework 4.8相当で、
とても残念なことに、WebSocket接続時のNoDelay
はfalse
になっています。
このために、冒頭で言及した応答時間調査のときにはNagleのアルゴリズムが有効になっていて余計な遅延が発生していました。
さらに酷いことに、ClientWebSocket
クラスには内部のSocket
にアクセスする手段がありません。
仕方がないので、WSNet2ではUnityの場合にはリフレクションを使ってSocket
を取り出し、NoDelay
プロパティを設定するようにしました。
.NET 5以降では、依然としてClientWebSocket
からNoDelay
を設定するインターフェイスは存在しないものの、
WebSocket接続時にNoDelay
はtrue
に設定されるので、Nagleのアルゴリズムによる遅延の心配はありません。
まとめ
ここまで、NagleのアルゴリズムとTCP_NODELAY
オプションについて解説し、実際の動作を確認しました。
ゲームの協力プレイやオンライン対戦では、パケットをまとめることによる帯域の節約よりもリアルタイム性のほうが大切です。
このようなケースではTCP_NODELAY
を設定したほうがよいでしょう。
また、想定以上の遅延が見られたときはTCP_NODELAY
が設定されているか確認してみてください。
-
WSNet2のWSはWebSocketの略です ↩
-
TCPの1つのパケットに載せられる最大サイズ ↩
-
原文: This inhibition is to be unconditional; no timers, tests for size of data received, or other conditions are required. ↩
-
userns-remapを設定している場合は
--userns=host
の指定も必要です。userns-remapについてはKLabTechBook Vol.11「Dockerを使うなら当然userns-remapしてるよね!」をご覧ください。 ↩ -
ncを終了するためにサーバーコンテナ側で何度かエンターキーを入力する必要があることがあります ↩