Unix Network Programming: Chap6

Dec 13, 2022 23:33 · 735 words · 4 minute read

6.I/Oの多重化: select 関数と poll 関数 🔗

ゴール 🔗

クライアントが標準入力を待っている状態でサーバーからFINが送られると、クライアントは直ちに応答することができず、しばらく経った後に応答する形になる。
これを回避するためには、2つ以上のIOに対して「どれかが入出力可能になったらカーネルに通知してもらう」という機能を利用する必要がある。
これはIOの多重化と呼ばれ、select と poll 関数を用いて利用することができる。

この章ではIOの多重化について理解を深める。

I/Oモデル 🔗

入力操作は、

  1. データの用意ができるまで待機する。
  2. データの用意ができたら、カーネルからプロセスにコピーする。

という流れで処理される。
ソケットの場合、1はネットワークからのデータの到着を待つことに等しい。
パケットが届くと、カーネル内部のバッファにコピーされる。
2は、カーネル内部にコピーしたバッファをアプリケーションのバッファにコピーすることに等しい。

Unix には、以下の5種類のI/Oモデルが存在する。

  • ブロッキングIO
  • ノンブロッキングIO
  • IO多重化
  • シグナル駆動IO
  • 非同期IO

ブロッキングIO 🔗

最も標準的に利用されるIOモデル。
これはカーネルからデータが受け渡されるまで、プロセスがブロックされる。
ここまでの例題で取り扱ってきたIOモデルは、いずれもブロッキングIOである。

ノンブロッキングIO 🔗

このモデルでは、システムコール呼び出し時にカーネルにコピーすべきデータが用意されていない場合、待機することはせずに、渡すべきデータが存在しないことが直ちにプロセスに通知される。
ノンブロッキングIOの場合、プロセスはループの中で呼び出しを繰り返す形になる。これをポーリングと呼ぶ。
ブロッキングIOと比べて、ポーリングはCPU時間の浪費になる場合が多いので、機能専用に割り当てられたシステムなど、使い所は慎重に検討が必要。

多重化IO 🔗

このモデルでは、実際の入出力システムコールでブロックするのではなく、データが読み取り可能になることを通知してもらう select または poll でブロックする。
一見するとブロックが発生する上にシステムコールが2回に増えるので、ブロッキングIOと比べて不利に見えるが、あつかうディスクリプターが複数の場合に効果を発揮する。

シグナル駆動IO 🔗

このモデルでは、データの入出力が可能になったタイミングでカーネルからシグナル(SIGIO)で割り込んでもらう。
プロセスはあらかじめシグナルハンドラを配備しておき、シグナルハンドラでIO操作を行う。

非同期IO 🔗

このモデルでは、IO操作を開始し、完了した時にカーネルから通知をもらう。
シグナル駆動IOとの違いは、シグナル駆動IOが「IO操作が可能になったことを通知」するのに対し、非同期IOでは「IO操作が終了したことを通知」する。

select 関数 🔗

// 準備ができているディスクリプターの個数を返す。
// タイムアウトなら0、エラーなら-1を返す。
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

rreadset には、読み出し用のディスクリプターを渡す。
writeset には、書き込み用のディスクリプターを渡す。
exceptset には、エラー状態を監視するディスクリプターを渡す。

timeout には、

  • null: 永久に待ち続ける場合
  • 0: 全く待たない
  • それ以外: 待機する時間

を渡す。

ディスクリプターのセット(fd_set)は、整数の配列とする実装が多い。

第1引数には、検査するディスクリプター番号の最大値+1を指定する。
したがって、ディスクリプター0、1、2、…、maxfdp1 が検査対象となる。

ソケットが読み取り可能になる条件 🔗

  • 受信バッファ中のデータが、ソケットファイルの SO_RCVLOWAT オプションで指定した値を超えたとき。
  • コネクションがクローズされたとき。
  • 対象のソケットがリスニングソケットであり、確立済みコネクションが存在するとき。
  • ソケット上に保留中エラーが存在するとき。

ソケットが書き込み可能になる条件 🔗

  • 送信バッファの空きバイト数が、ソケットファイルの SO_SNDLOWAT オプションで指定した値以上、かつソケットが接続済みのとき。
  • コネクションがクローズされているとき(SIGPIPEが発生する)。
  • ソケット状に保留中のエラーが存在するとき。

echoクライアントの実装 🔗

最初に書いたように、標準入力をブロッキングIOで実装すると、サーバー側でコネクションがクローズされた場合にすぐに検知できない。
これを回避するように、echoクライアントを実装する。

#include "unp.h"

void str_cli(FILE *fd, int sockfd);

int main(int argc, char **argv) {
    if(argc != 2) {
        err_quit("usage: tcpcli <IP address>");
    }
    int                sockfd = Socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    // バイトオーダーに注意。
    Inet_pton(AF_INET, argv[1], &addr.sin_addr.s_addr);
    // バイトオーダーに注意。
    addr.sin_port = htons(SERV_PORT);
    Connect(sockfd, (SA *)&addr, sizeof(addr));
    str_cli(stdin, sockfd);
    exit(0);
}
#include "unp.h"

void str_cli(FILE *fd, int sockfd) {
    char   rbuff[MAXLINE], wbuff[MAXLINE];
    fd_set rset;
    // fd set を初期化
    FD_ZERO(&rset);
    for(;;) {
        // 入力ファイルを追加
        FD_SET(fileno(fd), &rset);
        // ソケットファイルを追加
        FD_SET(sockfd, &rset);
        int maxfdp1 = max(fileno(fd), sockfd) + 1;
        Select(maxfdp1, &rset, NULL, NULL, NULL);
        if(FD_ISSET(sockfd, &rset)) {
            if(Readline(sockfd, rbuff, MAXLINE) == 0) {
                err_quit("str_cli: server closed");
            }
            Fputs(rbuff, stdout);
        }
        if(FD_ISSET(fileno(fd), &rset)) {
            if(Fgets(wbuff, MAXLINE, fd) == NULL) {
                return;
            }
            Writen(sockfd, wbuff, strlen(wbuff));
        }
    }
}

echo のバッチ処理 🔗

上記の実装では、対話的に通信をしているので、以下のダイアグラムのようにどの時間をみても全二重パイプの 1/8 しか使っておらず非効率。

t=0 client => |1| | | | => server client <= | | | | | <= server

t=1 client => | |1| | | => server client <= | | | | | <= server

t=2 client => | | |1| | => server client <= | | | | | <= server

t=3 client => | | | |1| => server client <= | | | | | <= server

t=4 client => | | | | | => server client <= | | | |1| <= server

t=5 client => | | | | | => server client <= | | |1| | <= server

t=6 client => | | | | | => server client <= | |1| | | <= server

t=7 client => | | | | | => server client <= |1| | | | <= server

最大まで使うと、以下のようにできるはず。

t=0 client => |1| | | | => server client <= | | | | | <= server

t=1 client => |2|1| | | => server client <= | | | | | <= server

t=2 client => |3|2|1| | => server client <= | | | | | <= server

t=3 client => |4|3|2|1| => server client <= | | | | | <= server

t=4 client => |5|4|3|2| => server client <= | | | |1| <= server

t=5 client => |6|5|4|3| => server client <= | | |1|2| <= server

t=6 client => |7|6|5|4| => server client <= | |1|2|3| <= server

t=7 client => |8|7|6|5| => server client <= |1|2|3|4| <= server

一方で、これをこのまま実装すると、ファイルを末尾まで読み込んだ時に、

            if(Fgets(wbuff, MAXLINE, fd) == NULL) {
                return;
            }

の箇所で EXIT してしまう。

ここで必要になるのが、クライアント側で「サーバーには FIN を送りつつ、サーバーからのデータは待機されている状態(=half closed)」にすること。

これを実現するのが shutdown 関数 (close 関数だと、ソケットに対する読み書き共にできなくなる。)

shutdown 関数 🔗

#include <sys/socket.h>
int shutdown(int sockfd, int howto);

howto には、以下のいずれかを指定する。

  • SHUT_RD: コネクションの読み出し側がクローズされ、受信バッファのデータが破棄される。
  • SHUT_WR: コネクションの書き込み側がクローズされる(half closed)。
  • SHUT_RDWR: 読み書き共にクローズする。
// 修正版: strcli2.c
#include "unp.h"

void str_cli(FILE *fd, int sockfd) {
    int    maxfdp1, stdineof;
    fd_set rset;
    char   sendline[MAXLINE], recvline[MAXLINE];

    // このフラグが 0 の間は読み込みを続ける。
    stdineof = 0;

    // ディスクリプターセットを初期化する。
    FD_ZERO(&rset);
    for(;;) {
        if(stdineof == 0)
            // 読み込みファイルをセットする。
            FD_SET(fileno(fd), &rset);
        // ソケットをセットする。
        FD_SET(sockfd, &rset);
        maxfdp1 = max(fileno(fd), sockfd) + 1;

        // 待機。
        Select(maxfdp1, &rset, NULL, NULL, NULL);

        // ソケットにデータが入ったケース。
        if(FD_ISSET(sockfd, &rset)) {
            // 終了した場合。
            if(Readline(sockfd, recvline, MAXLINE) == 0) {
                // ファイルも最後まで到達していた場合。
                if(stdineof == 1)
                    return;
                else
                    err_quit("str_cli: server terminated permaturely");
            }
            Fputs(recvline, stdout);
        }

        // ファイルを読み込んだ。
        if(FD_ISSET(fileno(fd), &rset)) {
            // 末尾まで読んだ。
            if(Fgets(sendline, MAXLINE, fd) == NULL) {
                stdineof = 1;
                Shutdown(sockfd, SHUT_WR);
                FD_CLR(fileno(fd), &rset);
                continue;
            }
            Writen(sockfd, sendline, strlen(sendline));
        }
    }
}

ビルドする。

cc -I../unpv13e/lib -L../unpv13e tcpcli.c strcli2.c -lunp -o cli2

サーバー側を fork ではなく select を使って単一プロセスで扱う。 🔗

まずは select 関数のおさらい。

select 関数は、第1引数で渡した maxfdp1 までの file descriptor を走査し、READ/WRITE/EXCEPT の通知してほしいイベントごとに file descriptor set を渡すと、準備ができたら教えてくれる、というもの。

これを使うことで、

  1. リスニングソケットを read 待ちの file descriptro set に追加する。
  2. select で待つ。
  3. リスニングソケットが「読み出し可能」なら、新しいコネクションが確立したことを意味するので、accept によって確立済みソケットを受け取り、read 待ちの file descriptor set に追加する。
  4. 確立済みソケットが「読み出し可能」なら、クライアントからのデータ到着を意味するので echo reply する。
  5. 「2」に戻る。

とすれば、1つのプロセスで複数のクライアントを捌くことができる。 ただし、1つのクライアントの処理でIO待ちが発生するような実装をしてしまうと、悪意のあるクライアントが永遠にブロックするようなデータを送りつけることで、サーバーがハングしてしまう。

そのため、特定のクライアントに関する関数呼び出しで決してブロックしてはならない。 対策としては、

  • non-blocking IOを利用する(第15章)。
  • プロセス/スレッドを分ける。
  • タイムアウトを設ける。

などがある。

poll 関数 🔗

poll関数も「複数の file descriptor に対して、どれかが準備可能になるまで待つ」という意味ではselect関数と似ている。

select 関数が第1引数で渡した file descriptor までを走査するのに対して、poll 関数は調べてほしい file descriptor の配列を渡すような I/F になっている。

言い換えると、select が file descriptor の集合を検査したいイベントごとに作成するような考え方なのに対し、

poll は file descriptor に対して検査したいイベントを設定し、その配列として表現できるような形になっている、

という表現方法の違いがある。