1. TCP/IP による通信


Web サーバを作成するには、まず通信レイヤである TCP/IP を使ったプログラミングを習得しなければならない。 今回はまず、socket を使った TCP/IP 通信について解説する。


ストリーム (stream)

Unix では、データの入出力はストリーム (stream) に対する操作として抽象化される。 ストリームを使わずに入出力をおこなうとすると、 例えばファイルの内容を読み出す(入力の)とき、次のようなプログラムを書くことになるだろう。

ここで read_block() が OS が用意する関数(システムコール: system call) である。 上のプログラムを実行すると、hello.c というファイルの先頭から 32byte 目から 256 bytes 分 (32+256=288 byte 目まで) が読みこまれ、char 配列 buffer に代入される。

このように、先頭から何 byte 目から何 byte 目まで、と指定して入出力ができるような装置 (device) のことを、Unix ではブロック・デバイス (block device) という。

ところが、入出力装置のなかには、ブロック・デバイスとしては扱えないものも多い。 たとえば普段の作業で使っているキーボード端末は、立派な入出力装置である。 キーボードから打ちこんだ文字が入力で、画面に表示される文字が出力である。 しかしキーボード端末をファイルのようなブロック・デバイスとして考えて、何 byte 目から何 byte 目まで読み込む、というような機能を実現するのには無理がある。 何千文字も打ちこんだ後で、プログラムが先頭から 0 文字目から 16 文字目までを読みこもうとするかもしれない。 これに備えるためには、login してから logout するまで打ちこんだ文字を全てとっておかなければならないが、これは現実的ではない。

キーボード端末のような装置のことを、Unix ではキャラクタ・デバイス (character device) という。 ブロック・デバイスとキャラクタ・デバイスを同じシステムコールで扱えるように、Unix の入出力のシステムコールは、キャラクタ・デバイスに合わせて設計してある。 この設計で使われている抽象化がストリームである。


ストリームとは、データの供給元 (例: ハードディスク) とデータの受け手(例: プログラム) の間にはいって、データの一時保存をおこなう抽象データ構造である。 ストリームは、データの供給元から 1 byte 単位でデータを受けとり、FIFO (first-in first-out) 方式でデータの受け手に渡す働きをする。

ストリームを使うと、データの供給元と受け手の間の転送量の違いを吸収することができる。 例えば供給元が 100bytes 分のデータを一気に渡そうとし、一方、受け手は 10 bytes 分しか受け取ろうとしなかったとする。 この場合、ストリームが余った 90 bytes を一時的に保持することで、転送量の違いを解消することができる。


ストリームに対する入出力をおこなうプログラムは、次のような形になる。

このプログラムを実行すると、hello.c というファイルの先頭から 32byte 目までが char 配列 buffer0 に代入され、先頭から 32byte 目から 256 bytes 分 (32+256=288 byte 目まで) が char 配列 buffer に代入される。

最初のものとの違いは、システムコール read() に、先頭より何 byte 目から読みこむかを指定する引数を渡さないことである。 read() システムコールは、前回 read() で読んだデータの次から、指定された長さのデータを読み出して、引数で渡された char 配列に代入する。 このため、上のプログラムでも、先頭より 32byte 目から先を読むために、最初の 32byte を別な read() で読み飛ばしている。

ストリームを扱うためには、今、何 byte 目まで読んだか管理しなければならないので、あらかじめ別なシステムコール open() を呼んで、OS に今から読み出しをおこなうことを通知しなければならない。 open() が返す整数値は、登録番号のようなもので、以後 OS はこの整数値を使ってそのストリームを管理する。 Unix の用語ではファイル・ディスクリプタ (file descriptor) という。 また読み込みが終了したら、システムコール close() を呼んで、OS に終了を通知しなければならない。

ファイルに書きこみをおこなうときも同様である。 今度は逆に書きこみのためのシステムコールを呼ぶたびに、先頭から順に書きこまれていく。

配列 buffer0 の内容が先頭から 32 byte 目までに、配列 buffer の内容がその次の 256 byte に書きこまれる。


ソケット (socket)

TCP/IP を使ったネットワーク通信は、片方が送信したデータをもう一方が順に受信するというものなので、ストリームという抽象化と相性がよい。 TCP/IP 通信をおこなうには、ふたつのマシンの間にストリームを用意して、送信するときは write()、受信するときは read() システムコールを呼べばよい。 送信側が write() した順番で、受信側がデータを受けとる。

普通のファイルと TCP/IP 通信の違いは、ストリームを作るときに open() システムコールではなく、socket() システムコールを使うことである。 ファイルの場合と違い、TCP/IP 通信の場合は、入出力の相手は装置ではなく、別なプログラムである。 このためストリームの作成は、ファイルの場合と異なった手順をふまなければならない。 また作成されたストリームのことをソケット (socket) と呼ぶ。


クライアント・サーバ・モデル

任意の相手との通信ができるように、socket は同期機構としてクライアント・サーバ (client server) 方式を採用している。 クライアント・サーバ方式では、同期の方法が非対称であり、サーバ側とクライアント側とで、異なるプログラムを書かなければならない。 同期は、他のホストからの通信を待ちうけるサーバと、 サーバに対してネットワークの接続を要求するクライアントの間でおこなわれる。 サーバは、任意のホスト上のクライアントからの接続要求を同時に待ちうけることができる。 一方、クライアントは特定のサーバに対して接続を要求する。 クライアントが接続を要求したとき、サーバが要求待ち状態にあれば、同期が成立して通信が始められる。 もしサーバが要求待ち状態にない場合は、それ以上待つことはせず、即座に同期 (connect() システムコール) が失敗する。

注:クライアント・サーバ・モデルは、電話の呼出し手順に似ている。

以上のような socket の同期処理は、複数のシステムコールによって実現される。 次の図は、それらのシステムコールの関係を示したものである。

クライアント側は比較的単純だが、サーバ側はたくさんのシステムコールを呼ばなければならない。

クライアント側では、まず socket() で socket を作成し、次に connect() で同期をとる。 通信相手のサーバは connect() の引数で指定する。 同期がとれて通信の準備が整えば、connect() システムコールが帰るので、read(), write() を使って通信がはじめられる。

一方、サーバ側ではまず socket() で socket を作成した後、bind() で socket に port 番号を設定する。 port 番号は、外部のホストに対する socket の識別番号のようなものである。 クライアントはホスト名と port 番号の 対で、通信しようとしているサーバの socket を指定する。 Port 番号は 0 から 1023 までは特権ユーザだけが使用できる。 それ以外の port 番号は自由に使えるが、一部の port 番号は Unix がすでに使用していて使えない。一般には 5001 以降の適当な番号を使うとよい。

サーバ側では、bind() の後、listen() を呼ぶ。 listen() はその socket がサーバ側の socket として使われることをOSに通知し、接続要求の受けつけを開始させる。 第2引数として渡される値 (OS の設定により上限値が決まっている) は、その socket が一度に受けつけられる接続要求の数を示す。 正確には、次で説明する accept() で選ばれるのを同時に待てるクライアントの数である。

accept() は、接続要求しているクライアントをひとつ選び、そのクライアントと通信するための socket を新たに作成して同期をとり、socket の識別子を返す。 accept() が返した socket はひとつで双方向の通信に使える。 その socket に対して read() すれば受信、write() すれば送信の意味になる。 元の socket は他のクライアントからの接続要求を待つのに使われる。 その socket に対して accept() を実行すれば、接続されるのを待っている別な クライアントが選ばれ、そのクライアントと通信するための socket が返される。


サンプルプログラム

サーバとクライアントのサンプル・プログラムとして server.c, client.c を用意した。 これらのプログラムをコンパイルするには、

とする。

実行するには、まずサーバを動かすマシン上で

とし、次にクライアントを動かすマシン上で

などとする。 client の第一引数 (例では mac701) はサーバ・プログラムを動かしているマシン名である。 クライアント・プログラムと同じマシン上で動いているのなら、マシン名は localhost (同一マシンの意味) でよい。 5001 はポート番号で、適当な数字を指定すればよい。

実行すると、クライアントがサーバに "Hello World" という文字列を送信する。


listen() と accept()

listen() は、その socket がクライアントからの要求を待ち受けるのに使われることを指示する。 一方、accept() は実際に接続要求をだしているクライアントをひとつ選択し、実際に接続を確立する。

listen() と accept() がふたつのシステムコールにわかれている理由は、サーバがマルチプロセッシングを使い、同時にいくつものクライアントと通信しながら処理をすすめられるようにするためである。 これについては、後に取りあげるので、今回はこれ以上ふれない。


read()

read() システムコールは、stream から指定された大きさのデータを読むか、stream の最後まで読んだら終了する。 ファイルの場合、stream の最後とはファイルの末尾である。 ネットワーク通信の場合、送信側 (write 側) が socket を close() してデータの送信を止めると、そこが stream の最後である。 システムコールの返り値は、実際に読み込んだ byte 数である。 もしも stream の末尾に到達しているときには 0 を返す。

ところが読んでいる stream が socket の場合には、指定された大きさのデータを読み終わる前に、read() が終了してしまうことがある。 例えば

と 128 byte のデータを fd から読もうとしても、fd が表わす stream が socket の場合、64 byte 読んだだけで終了し、64 が read() から返されることが ありうる。

このような設計になっている理由は、ネットワークがあまり安定した伝送路ではないためである。必ずしも一定の時間内に決めららた量のデータを送れるとは限らない。

stream の最後に到達しない限り、指定された大きさのデータを読みこむまで待ちつづけるには、次のようなプログラムを書かなければならない。

なお当然だが、このようにすると、送信側 (write 側) が指定された大きさのデータを送信しなければ、受信側 (read 側) のプログラムは永遠にブロックしてしまう。


標準設定では read() は 1byte 以上のデータを受信したら終了する。 read() が n-byte 以上読みこまないと終了しないようにするには、setsockopt() で SO_RCVLOWAT を n にすればよい。



close()

socket も stream の一種なので、処理が終わったら close() システムコールで破棄しなければならない。 接続するときと違って close() のときに同期をとる必要はないが、socket は2つのマシンをつなぐものなので、多少の注意が必要である。

まず、サーバかクライアントの一方が socket を close() しても、他方の socket も同時に close() されるわけではない。 close() するまで、他方の socket は read()、write() 可能である。

また、socket を close() しても、実は即座に socket が破棄されるわけではない。 OSは送信した全てのデータを相手が正しく受信したことを確認しないと、socket を破棄できないからだ。 このためサーバ側のプログラムを終了した後、すぐに再実行すると、bind() が「その port 番号は使用中です」というエラーを出して実行できないことがある。

これを避けるためには、常にサーバよりもクライアントのプログラムが先に socket を close() するようにすればよい。多くの Unix の実装でこの事がいえる。 あるいはクライアントの方を先に終了させるようにしてもいい (プロセスが終了すると全ての I/O が close される)。

もし強制的にサーバの方から先に socket を close させるときは、close() の直前に

を呼ぶとよい。s は socket の識別子、2は定数である。

あるいはサーバ側で bind() する前にあらかじめ

を実行しておいてもよい。


課題 1

client.c, server.c を参考に、指定されたマシン上で動いている port 80 のサーバと接続し、文字列

を write() システムコールを使って送信、その後、read() システムコールを使ってサーバから受信した文字列を、全て標準出力に書き出すクライアント・プログラムを作成せよ。

例えば、作成したプログラムが a.out であるとして、

と実行すると、コマンド行引数で指定したマシン www.is.titech.ac.jp に接続して、 ポート 80 番のサーバ(実は web server)と通信すればよい。



目次へ戻る

Copyright (C) 1999-2000 Shigeru Chiba