信頼性の高い通信を実現するTCP

 TCP(Transmission Control Protocol、RFC793)は信頼性の低いインターネットでも信頼性の高い通信を実現することを目的としています。下位層としてはIPが想定されています。IPについては既にご存知の通りベストエフォット型の通信を提供します。ベストエフォットというと"ベスト"の"エフォット"(effort、努力)をするわけですから、かなり努力してくれるんだろうなと思うかもしれませんが、これはイメージとはかけ離れていて、"一応やってみるけど失敗した場合は知らないよ"というかなりいい加減な手法です。下位層としてはIP以外でも利用できるようになっています。そのためTCPは独立性の高いプロトコルになっています。とにかく、信頼性の低いプロトコルを下位層として利用しても、信頼性が確保できるようになっているということです。ではどうやってこの信頼性を確保しているのかを学んでいきましょう。




1 プロセス間通信

≪後回しにしても構いません。気の進まない人は「2」から読み進んでください≫

 TCPはリモートのプロセス間での通信を制御するプロトコルです。ではプロセスとは何でしょうか。プロセスはプログラムを実行している状態のことです。PCのソフトを買ってきたとしましょう。プログラムはCD-ROMに格納されています。これをハードディスクにコピーすることができます。この状態ではプログラムは動いていません。次にコマンドをたたいてプログラムを起動すると動き始めて何か画面に表示されたり、あなたに入力を促したりします。これが「実行している状態のプログラム」ということになります。OSによってはプロセスではなく、タスクとかアクティビティなどと言ったりします。

 Windows OSの場合はタスクマネージャで実行中のプロセスを表示することができます。Windows 10の場合は、タスクマネージャは「全てのアプリ」>「Windowsシステムコール」>「タスクマネージャ」で起動できます。簡易表示だと現在利用中のアプリしか表示されませんが、詳細ボタンを押すと、バックグラウンドで実行中のプロセスを含めて詳しい情報が表示されます。
 UNIX系のOSではps(Process Status)コマンドを実行すると稼働中のプロセスの情報を表示することができます。psでは様々なオプションを指定することができますので、プロセスに関する様々な情報を表示できます。

 インターネット上のサービスはUNIX系のOSで提供されていることが多いので、UNIX系のOSでアプリケーションプログラムを起動させる方法について次に説明します。WebではHTTPというプロトコルを使って、WebサーバとWebクライアント(ブラウザ)が通信をしますが、この通信を制御するプログラムはhttpdといいます。"d"はデーモンという意味です。デーモン(daemon)とはバックグラウンドで起動するプログラムを意味します。このプログラムを起動する時には、serviceというコマンドを利用します。serviceというコマンドは/sbin/というディレクトリに入っていますので、"/sbin/service httpd start"というようにコマンド実行します。httpdが/etc/init.d/にあるとすると、"/etc/init.d/httpd start"でもいいです。

※IPやTCPの実装であるipd、tcpdはOSのカーネルとして用意されています。OSはコンピュータを起動させるとメインメモリに常駐します。従って、ipd、tcpdはOSを起動した時点でプロセス化されているということになります。

 起動したプロセスはメモリ上に置かれることになります。たくさんのプロセスを同時に起動すると、メモリがいっぱいになってしまいます。そこで、たくさんのデーモンの起動を請け負うプログラムも必要となります。それがinetdです。頻繁にクライアントからのアクセス要求があるサービスは独立に起動して、プロセス化しておく必要があります。しかし、それほど頻繁にアクセス要求がない場合は、常時起動させておくのはリソースの無駄使いですので、inetdを使います。inetdはクライアントからのアクセスを検知すると、寝ているサービスを起こします。

 TCPのプロセス間通信は、それぞれのアプリケーションプロセスに対して論理リンクと呼ばれる"土管"を提供するという形式で実現されます。この土管である論理リンクの端の"穴"はポートと呼ばれています。TCPはアプリケーションプロセスの間を結びつけるというよりも、このポート同士を結び付けて通信をサポートしているといえます。ポートと対応するアプリケーションプロセスを結び付けているのはOSです。

 ポートと対応するプロセスを結びつけるためにUNIXではSocket(ソケット、BSDソケット、バークレーソケットなどと呼ばれる)と呼ばれるAPI(Application Programming Interface;ソフトウェアコンポーネントが互いにやり取りするためのインターフェースの仕様)を用意しています。
※SocketはBSD系UNIXで開発されたプロセス間通信(特にコンピュータネットワーク)に関するライブラリーです。Windowsでも同じものがwinsock(Windows Sockets API、WSA)というプログラムとして用意されています。
※BSD系のUNIXとは、カリフォルニア大学バークリー校で開発されたUNIX互換のOS(Berkeley Software Distribution)のことです。直系のOSとしては、主なものにFreeBSD、NetBSD、OpenBSD、DragonFly BSD、BSD/OSなどがあります。MAC OS Xの基礎部分はFreeBSDが入っているので(カーネギーメロン大学で開発したマイクロカーネ
ルとFreeBSD環境の組み合わせ=Darwin)、これもBSD系に含める考え方もあります。FreeBSD、NetBSD、OpenBSD、DragonFLY BSD、Darwinはフリーで提供されていますが、BSD/OS、Mac OS Xは商用製品として提供されています。その他にも、傍流としてUltric(DEC)、SunOS(Sun Microsystems)(後にSolaris)、NEXTSTEP(NeXtコンピュータ)(後にOPENSTEP)、NEWS(ソニー)、JUNOS(ジェニパーネットワークスのルータのOS)などがあります。

 UNIXやWindowsではSocket(Winsock)インターフェースがシステムコール(OSカーネルに対する機能呼び出しのメカニズム)の形で用意されています。ここではsocket()というシステムコールを使ってソケットを作成し(ソケットはソケット記述子=sockfdという識別子を与えられます)、bind()というシステム関数を使ってそのソケットをアプリケーションプロセスと結びつけます。これによってアプリケーションプロセスの持つローカルアドレス(IPアドレス+ポート番号)がソケットと結び付けられます。Webサーバの場合にはIPアドレスと80番ポートがソケットに結び付けられます。サーバプロセスの場合は受け身ですので、更にlisten()という関数を呼び出して、このソケットをサーバ専用のPassiveなものに修正します。これでサーバ側の準備が整いました。

 
 このときTCPに対して80番ポートでコネクション要求に応えられるように準備するよう指令が入ります。


 クライアントプロセスはサーバプロセスのポート番号(例として80番)に対するコネクションの開設をOSに依頼します。これはActive Openとなります。OSはsocket()関数で作成したソケットをconnect()関数を使ってアクティブにします。クライアント側のポート番号はそのとき空いているものをOSが割り当てます(例えば12,345番)


 クライアント側ではaccept関数の呼び出しに先立ってbind関数が呼ばれていません。これはOSのカーネルが必要に応じて短命(エフェメラル、ephemeral)ポートと送信元IPアドレスの両方を選択しているためです。

 この後、クライアント側のTCPモジュールはサーバ側のTCPモジュールにコネクション開設要求のメッセージを送信します。サーバ側のTCPモジュールはそれを受信すると、ポート番号がオープンされているかどうか調べ、そのポートに対してコネクション開設要求があったことを知らせます。OSはそのポートに対応するサーバプロセスに対してコネクション開設要求を伝えます。サーバプロセスはコネクション開設要求を受けるかどうか判断し、受ける場合は受諾の返事をポートに出します。


 accept関数でコネクション開設を受諾した後は、サーバプロセスは自分で送受信処理をする方法と、自分はコネクション開設要求受付に専念し、子プロセスを生成して、送受信はその子プロセスにゆだねる方法があります。クライアントからのアクセスが頻繁あることが予想されるサーバでは、子プロセスを生成し(fork)、その子プロセスに送受信を任せる方法が一般的です。

 サーバ側のTCPモジュールは受諾の返事をクライアント側のTCPモジュールに送信し、TCPモジュールはそれをポート12,345番に伝え、これがOSを介してクライアント側のプロセスに伝えられます。

 実際にはこのTCPモジュール間のコネクション開設要求は双方向から行われ、一般に3ウェイハンドシェークという形が採用されています。

 これでコネクション開設の段階が終了し、この後データ転送の段階に入ります。TCPモジュールはaccept()関数を使って取り出したコネクション(新しいソケット記述子のnew-sockfd、確立済みのコネクションキューの先頭から取り出したもの)を使ってデータの送受信を行います。


 コネクションは双方向に開設されます。送信側TCPモジュールは送信用のコネクションのソケットに書き込みます。書き込みに関してもシステム関数が用意されています。この関数では、ソケット記述子や送信用バッファへのポインタなどが引数となります。書いたものはシステム関数を使って送信されます。このコネクションの反対側のソケットは受信用です。受信側のTCPモジュールもシステム関数を使います。受信用の関数ではソケット記述子と受信用バッファへのポインタなどが引数として使われます。読み込んだものは受信バッファに書き込まれます。



2 フロー制御の概要

 IPはただ送信するだけで失敗しても申し訳ないというだけですが(実際にはあやまりもしませんが・・・)、TCPはそれではすみません。もっと高い信頼性を期待されています。TCPはセグメントに損傷が発生した場合や、途中で消失してしまった場合、重複して送信されてしまった場合、セグメントが順番通りに到着しなかった場合などにそれを検出する機能を持っていなくてはなりません。そのためにTCPでは様々な工夫をしていますので、これについて細かく見ていくことにしましょう。

■ シーケンス番号
 伝送データはオクテット毎にシーケンス(順序)番号で管理されています。セグメント内の最初のオクテットのシーケンス番号はセグメント番号と呼ばれ、このセグメント番号がTCPセグメントヘッダのシーケンス番号フィールドに記載されます。

■ ACK(応答確認)番号
 受信セグメントに格納されているデータの受信を確認する機能を持っています。受信セグメントに格納されたデータの「最後尾のオクテットのシーケンス番号+1」という数字が利用されます。これには、ACK番号-1まで受信したという意味と、次のセグメントでACK番号のデータから送信してほしいという意味があります。複数の受信セグメントに対して、1つのACK番号で受信確認をすることもできます。また、受信側で送信するデータに合い乗せする形で送信することもできます。これは、piggybackと呼ばれます。

 上の例は双方向にデータを送り合っていますが、場合によっては一方向にだけデータ送信をする場合もあります。この場合は、データ部分がないセグメントはシーケンス番号を更新しませんので、データ送信と反対方向に送られるセグメントはシーケンス番号が同じになります。

■ 送信バッファとタイマ
 データ伝送を行うプロセスは送りたいデータが格納されたバッファの位置を教えて、TCPモジュールに対してデータ転送要求を出します。TCPモジュールはバッファのデータを送信にちょうどいいように区切って、それに制御情報の入ったヘッダを付加して、セグメントを作成し、宛先コンピュータ上のTCPモジュールに送信するようにIPモジュールに依頼します。
 TCPは送信と同時にそのセグメントを再送用のバッファに格納し、タイマをセットします。相手からACKを受信すると、その番号を調べて、送信し終えたデータのシーケンス番号と合致した場合は、そのACK番号で示されたシーケンス番号までのデータを再送バッファから取り除きます。ACK番号が予想されるものと異なる場合やタイマが切れる前にACKが届かない場合は、再送バッファのデータを再送します。

 上の図では、TCPモジュールAが送信したシークエンス番号10,501のセグメントがTCPモジュールBに到達し、それに対するACKが返ってこないという例ですが、シーケンス番号10,501のセグメントが何らかの原因でTCPモジュールBに到達できなかったという場合でも状況は全く同じです。TCPモジュールAから見れば自分が送信したセグメントが届いたが何らかの理由で返事がトラブルに巻き込まれて返ってこなかった場合と、自分の送信したセグメントが相手に届かなかった場合の区別はつきません。TCPモジュールAはどちらの場合に対しても同じ対応をするしかありません。


■ 誤り制御
 送信側は誤りを検出するための情報(チェックサム)をヘッダに入れておき、受信側はチェックサムを使って誤りを検出したときは、そのセグメントを廃棄します。

■ Windowサイズ広告
 受信側のTCPモジュールは、ACKを送信する際に、その時点で受信可能な容量(受信バッファの空き容量)をWindowサイズを使って知らせます。単位はオクテットです。

■ Pushファンクション
 TCPモジュールはアプリケーションプロセスが送信用のバッファに書き込んだデータをただ送信するだけです。アプリケーションが書き込んだデータがASCIIコードなのか、バイナリデータなのか、はたまたEBCDICキャラクタなのかなどということはTCPモジュールには知る由もありません。あるいはアプリケーションの送信側プロセスが512バイト単位で4回書き込み(write)を行ったのか、2048バイトをいっぺんに書き込んだのかなどは全く感知しません。TCPは自分の都合で送信バッファにあるデータを送信します。アプリケーションプロセスが送信バッファに書き込んだデータはTCPモジュールから見ると、区切りのないデータの続きです。このようなデータをデータストリームといいます。もちろん、送信バッファは先に書いたデータは先にとりだされるという構造(先入れ先出し)になっています。従って、後から書き込んだデータが先に送られるということはありません。どのようなタイミングで送信するかはTCPモジュールの実装時に予め決めておくこともできます。例えば、ユーザからの送信要求が数回あって送信バッファが一杯になったときとか、送信要求がなく一定時間が過ぎたときはその時点で送信バッファにあるデータを送信するとか予め決めておきます。

 しかし、ユーザがどの時点で送信してほしいと明示的に指定してきたときは、その時点で送信バッファに溜まっている未送信データを送らなくてはなりません。TCPではこの機能をPUSHファンクションと呼んでいます。ユーザがPUSHファンクションを起動したときは、送信TCPは送信するセグメントの制御フィールドのPUSHビットをセットします。

 受信データをいつアプリケーションプロセスに渡すかはTCPが設定条件に従って勝手に決めていますが、PUSHフラグの立ったセグメントを受信したときは、その時点で受信バッファに溜まっているデータをアプリケーションプロセスに即座に渡さなくてはなりません。

■ 緊急用のデータに対する制御
 TCPは「緊急データ」が通常のデータストリームの中にあることを示す緊急モードを採用しています。送信側TCPモジュールは制御フィールドのURGフラグをセットし、緊急ポインタフィールドにオフセット値をセットすることで、データストリームの中に緊急データが入っていることを受信側のTCPモジュールに知らせることができます。このオフセット値は、データの先頭(シーケンス番号が示す値)からのオフセットです。このオフセット値でどこまで(シーケンス番号+オフセット値)緊急データが続いているかを示しています。受信側TCPモジュールは緊急ポインタを受け取ると、それを受け取ったことをアプリケーションプロセスに知らせます。受信側プロセスはデータストリームを読み込むことができ、読み込んでいる途中で緊急ポインタに遭遇したらそのことに気が付くことができなくてはなりません。緊急ポインタに遭遇するまでは「緊急モード」が継続し、緊急ポインタに出会うと、その後は「通常モード」に戻ります。緊急データがどこから始まっているかは分かりません。ただ言えることは、受信側が緊急モードに入ると、緊急ポインタで示すデータを読み取るまではずっと緊急モードに入った状態が続くということです。
 緊急モードに関しては、TCPモジュールは緊急モードに入ったこと(URGビットがセットされているので)、どこまで緊急データがつづくか(緊急ポインタ)を示すことだけで、後のことはアプリケーションに委ねられています。

緊急ポインタは緊急データの終点を指示




3 コネクションの開設

 TCPのデータ転送においてどのようにフロー制御が行われるのか、それによってどのように信頼性が確保できるのかについては既にみてきましたが、これはTCPコネクションを介して実現されています。そして、このコネクションが十分に機能を発揮するためにはコネクションが初期化され、同期がとられる必要があります。これはコネクションの確立、維持、終了によって得られます。では、最初に如何にしてコネクションを確立するかについてみていくことにしましょう。

3.1 シーケンス番号の初期値

 シーケンス番号は伝送対象のデータにオクテット単位に割り振られた番号ですので、当然0あるいは1から始まるだろうと思うでしょうが、そういうわけにはいきません。シーケンス番号はデータをどこまで送信したかという指標であると同時にセグメントの識別子としての機能も与えられています。TCPモジュール間の通信が途中で異常終了して、新たにコネクションが確立された場合のことを考えてください。新しいコネクションで使われるポート番号は同じものが使われる可能性あります。サーバ側はウェルノウンポートですので同じです。クライアント側は、その時使用されていないポート番号が使われるので、同じポート番号が割り当てられる可能性があります。異常終了したコネクションのセグメントが宛先に届くことができず、インターネットをゾンビのようにうろうろした挙句、新しいコネクションが張られた後で宛先のTCPモジュールに到達したと仮定しましょう。このとき、受信側のTCPモジュールは、これが新しいコネクションのセグメントなのか、異常終了したコネクションのセグメントなのか区別できなくてはなりません。初期シーケンス番号を単純に0とか1とかしているとこの区別はつかない可能性があります。

 初期シーケンス番号(先頭シーケンス番号、Initial Sequence Number、ISN)を単純なものにしておくとコネクションをハイジャックされやすいという警告(CERT/CCのCA-1995-01)もなされています。当時の対策は、大きな桁数の擬似乱数を発生させてそれを初期シーケンス番号とするものです。しかし、これに対して、擬似乱数発生器を使った初期シーケンス番号は統計的な手法でかなり狭い範囲まで絞り込みが可能であるという警告(CERT/CCのCA-2001-09)が出されており、現在は、多くのOSにおいてRFC1948(シーケンス番号予想攻撃からの防御、Defending Against Sequence Number Attacks)の対策が施されているようです。

3.2 3ウエイハンドシェーク

 初期シーケンス番号が決まりましたので、これを使ってコネクションを識別します。初期シーケンス番号はコネクションを開設するときに最初に相手に渡します。TCPでは双方向からコネクションを開設しますので、双方が最初に初期シーケンス番号を相手に渡します。クライアントからサーバへのコネクションはクライアント側から開設しますので、クライアントが決めた初期シーケンス番号を相手に渡します。サーバからクライアントへのコネクションはサーバ側から開設しますので、サーバ側で決めた初期シーケンス番号をクライアントに渡すことになります。それぞれに対してACKがあれば、コネクションは確立したことになります。


 最初はクライアントからサーバにコネクション開設要求を行います。この時に初期シーケンス番号が提示されます。これは最初のセグメントですから、ACKビットはセットされていません。制御フィールドはSYNビットだけがセットされています。これを受けて今度はサーバ側からコネクション開設要求を行いますので、サーバ側から初期シーケンス番号が提示されます。サーバはこのセグメントで、クライアントからのコネクション開設要求を承諾します。従って、制御フィールドはSYNだけでなく、ACKビットもセットされています。また、確認応答番号はクライアントからの初期シーケンス番号+1となります。最後にクライアントから、ACKが送られます。確認応答番号はサーバの初期シーケンス番号+1です。
 シーケンス番号はセグメントのデータフィールドで運ばれるデータにつけられた通し番号のはずです。まだデータは全く送信されていないのに、どうして「ISN+1」をACK番号として応答するのか疑問に思った方もいると思います。これはSYNビットはシーケンス番号を1つ消費すると決められているためです(同様にFINも1つ消費することになっています)。要するに送信されるデータにつけられた通し番号は「ISN+1」から始まるのだと思ってください。



3.3 沈黙時間(Keep Quiet Time)

 既に見てきたようにOSは初期シーケンス番号の設定にかなり気を使っています。それにもかかわらず異常終了したコネクションのセグメントが、新しいコネクションのセグメントと間違われてしまう確率はゼロとは言えません。前のコネクションの初期シーケンス番号がどこかに残っているだろうから、それを避ければいいではないかというかもしれません。しかし、ホストのクラッシュなどが原因でコネクションが切れた場合などには、記録が失われてしまっている可能性もあります。TCPは前のコネクションのシーケンス番号との重複を避けるために、新たにコネクションを張る場合には、前のコネクションによるセグメントがインターネットから消えるであろう時間(これは、IPパケットのTTLフィールド値や回線スピードなどに関係してきます)だけ、新しいセグメントを発信しないようにしています。この時間を沈黙時間といいます。

3.4 ハーフオープンコネクションとリセット

 通信の途中で一方のホストがクラッシュしてコネクションが切れても、他のホストはコネクションを張ったままということがあります。このような状態をハーフオープンコネクションと呼びます。


 上の例はTCPモジュールAの側に何らかの異常が発生してコネクションが切れてしまったが、TCPモジュールB側からのコネクションは張られたままの場合です(ハーフオープン状態)。このような場合は、TCP Aは新しいコネクションを張り直そうとしますが、TCP Bはコネクションが張られたままですので現状のコネクションを維持しようとします。その結果、双方の現状理解に齟齬が発生しますが、TCP Aは相手が誤解していることに気付くはずですので、RSTビットをセットして、TCP Bからのコネクションも破棄してもらい、改めてコネクションを作り直します。




4 コネクションの維持管理

 データ伝送の概要については既に「2」で説明していますが、ここでは「2」で説明できなかった少し詳細な内容について説明したいと思います。

4.1 スライディングウィンドウ

 TCPではシーケンス番号によってセグメントを識別するといいましたが、送信側と受信側では、スライディングウィンドウというプロトコルで、シーケンス番号空間を管理しています。次に示すのは送信側のTCPモジュールが管理するシーケンス番号空間です。

 送信側のシーケンス番号空間


 相手からの確認応答のセグメントで確認応答番号やウィンドウサイズなどが示されます。上の例は、相手からの直近の回答のセグメントで、確認応答番号「n+200」が示された場合の例です。確認応答番号が「n+200」ということは、相手は「n+200-1」までは受け取ったということになりますので、このデータ領域(①)は既に確認応答を受けていて、再送用のバッファからの取り除かれた部分ということになります。

 相手からの直近のセグメントでウィンドウサイズが400オクテットと広告を受けた場合は、スライディングウィンドウの枠にはシーケンス番号の「n+200」~「n+599」までが入ります。この部分については、送信側で今回送信可能なデータということになります。送信側で、②に該当する部分を送信したとすると残りの「n+400」~「n+599」の部分については、相手からの確認応答を待たずに送信することが可能です。

 時間が経過し、相手が応答確認すると、スライディングウィンドウは右に移動します。ウィンドウの両端が移動することでウィンドウサイズは拡大したり縮小したりします。ウィンドウ枠の左端((a))は必ず右に移動します。これはACK番号によって制御されているためです。右端((c))は左に移動することはシュリンクする(縮む)といいます。RFCではシュリンクしないように強く主張していますが、相手方がウィンドウサイズを縮めてきた場合に対応する準備をしておく必要があります。

 受信側でも同様にシーケンス番号空間を管理しています。

 受信側のシーケンス番号空間


 受信側のシーケンス番号空間の(a)は新たに受信することを期待しているシーケンス番号で、(a)~(b)の領域がウィンドウサイズで相手に広告している受け入れ可能サイズということになります。

※ウィンドウサイズは16ビットで定義されますが、SYNセグメントのオプションでこのウィンドウサイズのスケールを変更することができます。スケールの変更にはシフトカウンタが利用されます。例えば、シフトカウンタを4だけ移動すれば、16ビットですが、実際は20ビットと同様の大きさのウィンドウサイズを利用できます(ウィンドウスケールを変更しない場合はシフト0; 例えば、シフト0の場合は1オクテットを単位としますが、シフト1すると2オクテット単位、4ビットシフトすると16オクテットを単位とすることになります)。シフトカウンタは0~14ビットまでシフトできますので、実質的には65535~65535×2^14まで利用可能となります。



4.2 ウィンドウの管理

 相手からのウィンドウサイズが0の場合でも、送信側のTCPはユーザからのデータ送信要求は何時でも受け付ける準備をしておき、最低でも1オクテットのデータ送信は行います。また、送信側のTCPは受信側のTCPに対して定期的に再送を行わなくてはなりません(2分程度が推奨)。受信側のTCPはセグメントを受け取った場合、もしまだウィンドウを開ける余裕がない場合は、次に受け取るべきシーケンス番号とウィンドウサイズ0を付けたセグメント(ACK)を返します。これは受信側ホストがクラッシュしてしまい、送信側TCPがウィンドウ待ちのまま止まってしまうことを防ぐためです。

 受信側がウィンドウを開ける余裕がない場合は、受け取るべきシーケンス番号とウィンドウサイズ0を付けたセグメントを送信します。では少しウィンドウを開ける余裕がある場合はどうでしょうか。例えば、1オクテット、2オクテットという僅かの空きでもそれを伝えるべきでしょうか。受信側が僅かでも受信バッファに空きができると直ぐにそれを送信側に伝えて、送信側も追加データを待って大きなセグメントを送ろうとせず、小さなデータを送信すると、コネクション上で小さなデータがやり取りされるようになり、転送効率が低下することがあります。これを「愚かなウィンドウシンドローム(SWS、Silly Window Syndrome)といいます。

 愚かなウィンドウシンドロームを回避するためには双方向のTCPで次のような対応を取らなくてはなりません。

● 愚かなウィンドウシンドロームを避けるための手法

・ 受け手側は小さなウィンドウを広告すべきではありません。通常のSWSアルゴリズムでは、受け手はウィンドウの空き容量が、フルサイズのセグメントの容量、あるいは受信バッファ容量の2分の1の、いずれか小さいほうのサイズで増加しない限り、現在広告している(0の場合もある)よりも大きなウィンドウを広告してはなりません。

・ 送信側は以下の条件が1つでも満たされない限りデータを送信してはなりません。
① フルサイズのセグメントを送ることができる。
② 相手側のエンドが広告したサイズのうち過去最高のサイズの少なくとも2分の1以上のデータを送信できる。
③ 今ある全てのデータを送信することができ、そして送信したデータに対しては全てACKを受けているか、Nagleアルゴリズムが現在のコネクションに対して無効になっている。

※フルサイズセグメントとは、許容できる最大バイト数のデータを持つセグメントですから、MSSのセグメントということになります。

※最大セグメントサイズ(MSS)最大セグメントサイズはTCPが他のエンドに送ることのできる最大サイズのデータのかたまりです。各TCPはコネクションを確立する際にSYNセグメントのオプションとしてMSSを付加することができます。他のエンドは、MSSを受け取らない場合は、デフォルトの536バイトをMSSとみなすことができます(20バイトのIPヘッダと、20バイトのTCPヘッダを合わせて576バイトのIPデータグラムになります)。

※Nagleアルゴリズム:Nagleアルゴリズム(RFC896)は、確認応答されない小さなセグメント(タイニグラム、tinygrams)は1つしか持つことができないというものです。つまり、確認応答が返ってくるまでに、また次の小さなセグメントを送信することができません。確認応答を待つ間に、小さいデータが集積しある程度の大きさのデータが集まれば、次のセグメントとして送信することができます。



4.3 転送制御ブロック

 TCPは各々のコネクションが開設されるたびにTCB(Transmission Control Block)と呼ばれるテーブルを作成します。TCBにはソケット、シーケンス番号、ウィンドウサイズを含むコネクションの状態情報が格納され、コネクションが張られているあいだ維持されます。

TCB
ローカル、リモートのソケット
優先度、セキュリティレベル
ユーザ用送信・受信バッファへのポインタ
再送バッファへのポインタ
現在送信中のセグメントへのポインタ





5 輻輳制御

 輻輳とは回線にデータが溢れ処理しきれないのでデータが破棄されている状態です。ルータに処理能力を超えてデータが入ってきた場合や回線スピードを超えたパケットを送信しようとする場合などに発生します。

 TCPモジュール同士はウィンドウサイズを広告し合って自分がその時点で受け入れ可能な容量を教え合いますが、転送途中のルータはそのようなことはしません。ルータは基本的にはレイア3の装置ですのでIPより上の層は機能していません。では、ルータには4層以上のプロトコルはないのかというとそういうわけではありません。ルータをリモートから管理するためには、ルータにもリモートログインの機能が必要です。また、設定情報を一定のサーバ上に置き、管理を自動化するなどの方法も採用されています。そのためには、ルータにも一定のネットワーク機能が必要になります。そのためにはトランスポートレイア、アプリケーションレイアのプロトコルも必要になります。しかし、そのような機能はルーティングには使用しません。ルータがルーティングにトランスポート層、アプリケーション層の機能を使っていたのでは、負荷がかかりすぎ、迅速なルーティングの妨げになるからです。ということで、ルータはただIPヘッダの情報を読み取って、パケットをネクストホップのルータに手渡すことだけに集中しています。しかし、ルータは受信バッファの空き容量を伝えることをしませんので、受け入れ能力以上のパケットを受け取ってしまうことがあります。このような場合にはパケットを破棄することになりますので、TCPコネクションのスループットは劇的に低下してしまいます。

 RFC2581は輻輳を制御する方法として、スロースタートと輻輳回避、高速再送、高速復帰という4つのアルゴリズムを定義しています。

5.1 スロースタートと輻輳回避

 スロースタートは文字通りゆっくりスタートするという意味です。最初は1つのセグメントを送信して様子をみます。確認応答があれば、次は2つ送信し、また確認応答があれば次は4つ送信するという具合に確認応答を受けつつ、指数関数的に送信セグメントの数を増やしていく方法です。こうやってどんどん送信セグメントの数を増やしていくと、やがて輻輳が発生してしまいます。輻輳が発生すればパケットは破棄されます。パケットの破棄は、タイムアウトの発生と、重複ACKの受信によって見当がつきます。そこで、スロースタートに加えて何らかの手法が必要となります。それが輻輳回避です。スロースタートと輻輳回避は全く独立したアルゴリズムですが、実際は2つは一緒に実装されます。

 転送回避とスロースタートは2つの変数で管理されます。1つは輻輳ウィンドウ(cwnd, Congestion Window)で、もう一つはスロースタートの閾値(ssthresh, Slow Start Threshold)です。双方向の2つのTCPの間にコネクションが確立されると、cwndは1に初期化されます(cwndは受信側がSYNセグメントのオプションで告知してきたセグメントの大きさを単位としていますが、実際はバイト数で管理されています)。cwndが1ですので、最初は1セグメントだけ相手側に送信し、cwndを2にセットします。ACKを受信すると、2セグメントを送信し、cwndを4にセットします。更にACKを受信すると、セグメントを4送信し、cwndを8に更新して、次のACKを待つという具合です。
 cwndをどんどん増やしていきスロースタート閾値まで達すると、輻輳回避アルゴリズムに切り替えます。

 スロースタートと輻輳回避の2つのアルゴリズムの組み合わせは次のように機能します。
① コネクションの初期化によりcwndは1セグメントに、ssthreshは65535バイトに設定される。
② TCPの出力ルーチンは、cwndと受け手が広告したウィンドウ(rwnd)との最小値を超えた送信は行わない。
③ 輻輳が発生すると(タイムアウト、あるいは重複ACKの受信)、現行ウィンドウサイズ(cwndと、受信側の広告したウィンドウサイズの最小値)の2分の1(ただし、少なくとも2セグメント)が、ssthreshにセットされる。更に輻輳がタイムアウトによって示されていれば、cwndを1に更新する。
④ 新しいデータが他方によって確認応答されると、cwndを増加させることになるが、増加方法はスロースタートと輻輳回避のいずれかのアルゴリズムを使用しているかによって異なる。

 これで実際にどうなるのか考えてみましょう。
 ある時点(例えばcwnd=32の時)に輻輳が発生したとします。③より、cwndと受信側が告知してきたウィンドウサイズのうちの小さいほうの2分の1がssthreshにセットされます。この場合は、ssthresh=16だとしましょう。更に輻輳がタイムアウトによって示されていれば、cwnd=1にセットされます。
 cwndがssthreshと等しいかあるいは小さい場合はスロースタートから始まります。従って、最初はセグメントを1つ送信し、ACKを受信する度に、2、4、8という具合に指数関数的に送信し、cwndがssthreshを超えると今度は輻輳回避アルゴリズムに移ります。輻輳回避アルゴリズムでは、ACKを受信するたびに、cwndを1/cwndずつ増加させていきます。cwnd(1/cwnd)ですから、つまり1ずつということになります。輻輳回避アルゴリズムに移ると、送信するセグメントの値は1つずつ増加していくということになります。輻輳が発生するとまた、その時のcwndの2分の1がssthreshにセットされ、cwndが1にセットされ、またスロースタートから始まるという具合です。これを図に書くと次のようになります。


5.2 高速再送・高速復帰

 輻輳回避のアルゴリズムでは輻輳の認識においてタイムアウトと重複ACKの受信を明確に区別していますが、重複ACKについてはその回数の違いを無視しています。重複ACKはセグメントの順番が違うことを相手に知らせるために、予期しないセグメントを受け取った場合には即座に返す必要があるとされています。しかし、それだけではありません。こちらから送信したセグメントが相手に届いていない場合にも重複ACKが届きます。セグメントが順番通りでないという場合は重複ACKが1回か2回来れば調整ができるはずですが、重複ACKが3回以上届くということはこちらから送ったセグメントが相手に届いていないのかもしれません。そこで、輻輳回避のアルゴリズムを若干調整して、重複ACKが3回以上の場合に輻輳回避アルゴリズムを起動させるというように修正すべきであるというのが高速再送・高速復帰のアルゴリズムです。

 このアルゴリズムは次のように機能します。
① 3番目の重複ACKを受け取ったら、ssthreshを現行の輻輳ウィンドウcwndの2分の1にセットする。
消失セグメントを再送する。
cwndをssthreshにセグメントサイズの3倍を加えたものにセットする。
② 別の重複ACKが届く度に、cwndをセグメントサイズ単位で増加させ、(新しいcwnd値で許可されたら)パケットを転送する。
③ 新しいデータを確認応答するACKが到着したら、cwndをssthresh(①でセットした値)にセットする。このACKは①の再送の確認応答であり、再転送から1往復が過ぎている。このACKはまた、消失したパケットと最初に受信した重複ACKの間に送られた中間のセグメント全てを確認応答しているはずである。パケットが消失したときに転送率を2分の1に減らしているので、このステップは輻輳回避となる。

 このアルゴリズムを適用した場合の例を次に示します。


 初めは先ほどのグラフと全く同じです。3回の重複ACKを受信して輻輳発生を認識したときは(往復時間6)、①からssthreshにcwndの2分の1がセットされますので、ssthresh<cwndの状態になります。従って、輻輳回避アルゴリズムが起動します。cwnd=18で輻輳が発生したら、ssthresh=9となりますので、ここからまた輻輳回避アルゴリズムが始まります(往復時間は7)。ここからcwndは1つずつ増加します。上のグラフは時間9で再度、輻輳が発生していますので、cwnd=11より、ssthresh=5.5となり、ここから輻輳回避アルゴリズムのスタートとなります。

 相手からのACKが来ずにタイムアウトしてしまう場合と、期待したのとは違うけれどもとりあえずACKが返ってきている場合では、タイムアウトしてしまうほうが輻輳の度合いが大きいと言っていいでしょう。そのためcwndは1に戻してしまいます。これに対して、重複ACKが返ってきた場合はまだはっきりしません。そこで3回以上重複ACKが返ってきた場合は重症かもしれないということになります。しかし、実際にタイムアウトしてしまった場合とは違いますので、とりあえずcwndを2分の1にして様子を見ようということになります。

 上の図は、新しく再送したセグメントに対してACKを受信した場合の例です。③により、cwndはssthreshと同じ値になります。cwnd=ssthreshですので、ここからスロースタートが始まります。ただし、スロースタートの1回目のセグメントですぐにssthreshを超えてしまいますので、輻輳回避のアルゴリズムがスタートすることになります。


5.3 ECN

※ECN(Explicit Congestion Notification、明示的な輻輳制御)は厳密にいうとTCPレベルの手法ではありません。IPとTCPを連携させた手法です。輻輳の検出はルータに任せ、後の対応はTCPレベルで行うという方法ですので、厳密にいうとプロトコル階層の独立性には反していることになります。これは後から追加された仕様で、全てのシステムがサポートしているとは言えません。

※ECNはRFC3168で追加されています。ECNはIPパケットヘッダのToSフィールドの再々定義によって規定されたものです。

 ECNという方法はIPとTCPが連携して制御を行うものです。輻輳の検出はルータに任せ、輻輳を検出したルータはIPレベルで、宛先ホストに知らせ、その対応は双方向のTCPがセグメントフィールドを使って行います。

 ECN対応のコネクションでは、IPヘッダ中にECTとCEというフラグがあります。ECT(ECN Capable Transport、ECN対応トランスポート)はECNに対応していることを示します。CE(Congestion Experienced)は輻輳発生を示すフラグです。TCPの輻輳制御ではルータは何もできませんでしたが、ECNはIPヘッダにフィールドを持っていますのでルータが何らかの働きをすることができるということになります。やはり、ネットワークの中の輻輳についてはルータに聞くのが一番です。ECTがセットされたIPパケットを中継する際に、ルータが輻輳を検出するとCEビットをセットします。このIPヘッダを受信したホストはルート上で輻輳が発生していることを知ることができます。この輻輳に対応すべきは送信側ですので、受信側は送信側に輻輳の発生を通知できなくてななりません。この連絡はTCPで行います。受信側が送信側に輻輳を通知するフラグはセグメント上のECE(ECN Echo)です。受信側は、受信確認のACKセグメントの中で(オプションフィールド)、ECEフラグをセットし、送信側に輻輳の発生を知らせます。ECEがセットされたACKセグメントを受信した送信側のTCPは、送信量を減らすととともに、送信セグメントのCWR(Congestion Window Reduced、輻輳ウィンドウ縮小)フラグ(オプションフィールド)で、通知に対応したことを知らせます。



6 コネクションの解放

 コネクションの終了はユーザのCLOSE要求から始まります。ユーザからアプリケーションプロセスを介してTCPモジュールにCLOSE要求が伝わると、TCPモジュールは相手側のTCPモジュールにFINをセットしたセグメントを送信します。このとき送信バッファに溜まっているデータがあれば全部送り出さなくてはなりません。相手から広告されているウインドサイズの関係で全部のデータを送信できない場合は、最後のデータを送信するセグメントでFINをセットします。こちら側をユーザA側とします。

 ユーザB側のTCP BはこのことをユーザBに伝えます。送信するデータがまだある場合でも、それを送る前に、即座にFINに対するACKを送信しなくてはなりません。その後、残っているデータを全部送り、ユーザBからのCLOSE要求を受けてA側にFINを送ります。

 TCP BからのFINを受け取ったTCP AはACKを返すと同時に、ユーザAに対してコネクションが解放されたことを知らせます。

 このようにTCPコネクションは徐々にコネクションを切断していくという意味で"緩やかな切断(Graceful close)"と呼ばれています。





7 擬似ヘッダ

 「1」のプロセス間通信は飛ばしてしまった人も多いかもしれませんが、読んだ人は少し疑問が残ったと思います。TCPはソケット間を接続するコネクションを開設すると説明しました。このソケットを作るにはいろいろのシステム関数を使いますが、そこでポート番号だけでなくIPアドレスを関数の引数として利用していました。相手側のIPアドレスはユーザから指定されるということが多いかもしれませんが、自分側(送信側)のIPアドレスはIPで管理しているためTCPはIPから何らかの形で教えてもらう必要があります。

 受信側ではどうでしょうか?相手から届くセグメントにはIPアドレスが入っていませんので、受け取ったセグメントは相手からのセグメントなのかハッキリわかりません。この場合にもIPからIPパケット(IPデータグラム)に格納されている相手側のIPアドレスを教えてもらう必要があります。このTCPとIPの間の情報連絡に使われているのが擬似ヘッダです。

 TCPとIPは「プロトコルの階層間の独立性」の考え方に反していますが、この擬似ヘッダを介して強く結びついているといえます。



8 状態遷移

 ここまでTCPコネクションの開設、維持、終了に関して様々なことを説明してきましたが、これらは次のような状態遷移図に表現することができます。なお、状態遷移の矢印に記述されている事柄は上段が遷移の引き金となった事象で、下段がそれに対するアクションです。通常のクライアントの状態遷移は太字の実線、サーバの遷移は太字の破線で示しています。

 図中の11個の状態はnetstatコマンドによって出力される状態と同じものです。CLOSEDはTCPコネクションの状態ではなく、この状態遷移図の起点と終点を表しています。

※netstatはpowershellで実行できます。プロトコルの統計と現在のTCP/IPネットワークの接続状態を表してくれます。いろいろのオプションを使うことができますので、"netstat ?"で確認してください。


 注意してほしいことはこの状態遷移図はあくまで簡略化されたものであり、状態遷移の典型的な場合しか記述されていないということです。実際にTCPモジュールを設計する場合はエラー処理などはもっと複雑に扱わなくてはなりません。ここではTCPの状態について理解を深めてほしいという意味で分かりやすい簡略図を紹介しています。

● CLOSED:コネクションがない状態です。CLOSEDはこの状態遷移図の理論的な起点と終点を表しています。
● LISTEN:サーバ側のTCPがクライアント側のTCPからのコネクション開設要求を待っている状態です。
● SYN SENT:クライアント側のTCPモジュールがSYNを送信し、サーバ側TCPからのSYN、ACKを待っている状態です。サーバ側からSYN、ACKを受信すれば、Established(ESTAB、コネクションの確立)状態になります。
● SYN RCVD:サーバ側のTCPモジュールがSYNを受信し、これに対してSYN(同時にACKも)を送信した状態です。SYNに対するACKが相手から来れば、Established(ESTAB)状態に遷移します。
● ESTAB:コネクションが開設され(Established)データ転送フェーズに入っている状態です。
● FIN-WAIT-1:相手からコネクション切断要求を待っている状態、あるいはコネクション切断要求を出してそれに対するACK(ACK of FIN)を待っている状態です。
● FIN-WAIT-2:自分から出したFINに対するACKは既に受信しているので、あとは相手からFINが来るのを待っている状態です。
● CLOSE-WAIT:ユーザからのコネクション切断要求を待っている状態です。
● CLOSING:相手からの切断要求に対してACKを送り、さらに自分からもFINを送ったので、そのFINに対する相手からのACKを待っている状態です。
● LAST-ACK:自分からのコネクション切断要求に対する相手からのACKを待っている状態です。
● TIME-WAIT:自分が出したACKが相手に届いて処理されるまで待っている状態です。最大セグメント時間(MSL)の2倍が目安です。最後のACKが何らかの原因で届かなかった場合に、待っている方のTCPはタイムアウトし、FINを再送するので、相手側は再度ACKを送信することが可能となります。
 2MSL時間待っている間は、このコネクションを定義するソケットペア(クライアントのIPアドレス、クライアントのポート番号、サーバのIPアドレス、サーバのポート番号)は再利用はされません。2MSL時間の間に、このコネクションのセグメントはインターネット上から消えると予想されますので、新しいコネクションで遅延セグメントが間違って新しいコネクションの一部と誤解されることはないだろうと期待されています。



9 TCPセグメントのフォーマット

3 9 15 31
送信元ポート 宛先ポート
シーケンス番号
応答確認番号
ヘッダ長 reserved 制御ビット windowサイズ
チェックサム 緊急ポインタ
オプション パディング
データ

● 送信元ポート番号(Source Port Number):(16ビット)送信側のポート番号
● 宛先ポート番号(Destination Port Number):(16ビット)宛先側のポート番号
● シーケンス番号(Sequence Number、順序番号):(32ビット)SYNフラグ(制御フィールド参照)が立っていない場合に、セグメントに含まれるデータの最初のオクテットの順序番号を示します。SYNフラグが立っている場合は、初期シーケンス番号を示します(制御フィールド参照)
● 応答確認番号(Acknowledgement Number):(32ビット)ACKフラグ(制御フィールド参照)がセットされている場合は、次に受信することを期待しているシーケンス番号を示します。例えばシーケンス番号1000番までのデータを正しく受信している場合は、応答確認番号は1001番となり、これはシーケンス番号1000番まで受信したことと、次に1001番目のデータが欲しいということを意味しています。この応答確認番号は、どこまで届いたかを示すもので強い肯定応答を示すわけではありません。いくつかのパケットを受信し、2000番までデータを受け取ったら、複数のパケットに対していっぺんに2001と応答確認することもできます。従って、応答確認のパケットが途中で失われたとしてもタイムアウトが起きる前なら、再送が行われることなく通信は続行されます。
● ヘッダ長(Data Offset):(4ビット)TCPのヘッダの長さを32ビットを1として表したものです。
● Reserved:(6ビット)将来のために確保されたフィールド
● 制御ビット:(6ビット)

URG ACK PSH RST SYN FIN

・URG:緊急(Urgent)ポインタがセットされていることを示す
・ACK:ACK(応答確認番号)フィールドがセットされていることを示す
・PSH:Pushファンクションが起動されたことを示す(受け手は可能な限り早急にアプリケーションに渡さなければならない)
・RST:コネクションをリセットすることを示す
・SYN:初期シーケンス番号のやり取りすることを示す(コネクションの初期化)
・FIN:送信するデータがこれ以上ないことを受信側に知らせることを示す

● Windowサイズ:(16ビット)応答確認番号フィールドで指定されたいる値を開始点として受信側がどれだけ(何オクテット)受信できるか(受信バッファの空き容量)を示します。例えば、応答確認番号の値が1501で、Windowサイズが200の場合は、シーケンス番号1501から1700までの200オクテットのデータを受信できるだけの空き容量があることを示しています。
● チェックサム:(16ビット)セグメントに含まれるデータ部、ヘッダ部、後述する疑似ヘッダを併せた形態のフォーマットでチェックサムの計算を行います。チェックサム計算ではこのフォーマットのセグメントを16ビット毎に区切ります。セグメントが16の整数倍でないときは、16の倍数になるように0を埋め込みます(もちろんこれは計算上のもので送信されません)。チェックサムの計算をする場合は、チェックサムフィールドは0としておきます。16ビット毎にそれぞれ1の補数で足し算を行い、その結果の1の補数をとります。
● 緊急(Urgent)ポインタ:(16ビット)制御ビットのURGがセットされているときのみ有効です。
● オプション(Options):(可変長)オプションはオリジナルのTCP(RFC793)では3つ定義されていましたが、現在は2つ追加されて5つ定義されています(RFC1323)。それぞれのオプションはタイプを表すkind(1バイト)で始まります。kind0とkind1はこれだけです。kind2、kind3、kind8は長さを示すlen(1バイト;kindとlenを含めた全体をバイト単位で指定)が続き、その後にオプション情報が続きます。

kind len オプション情報 説明
0 無し 無し オプションリストの最後:これはオプションリストが16ビット境界で終了した場合は不要
1 無し 無し ノーオペレーション:オプション間の区切りを示すために使用
2 4 最大セグメントサイズ 最大セグメントサイズ(16ビットで表現)
3 3 シフトカウンタ ウィンドスケールの係数
8 10 タイムスタンプ値(4バイト)、タイムスタンプエコー応答(4バイト) タイムスタンプ

● パディング(Padding):(可変長)TCPヘッダが32ビット境界で終了するように必要なら0を付け足します。


10 擬似ヘッダのフォーマット

 擬似ヘッダはTCP-IP間インターフェースでやり取りされる情報で、次のようなフォーマットを持っています。

          7 15               31
送信元IPアドレス
宛先IPアドレス
0 プロトコル番号 TCPセグメント長







更新履歴

2016/04/15 作成


























 ページの先頭