Unix Network Programming: Chap5

Nov 3, 2022 23:21 · 583 words · 3 minute read

5.TCPクライアント - サーバ例題 🔗

目的 🔗

前章までの知識を使って、簡単なTCPサーバー/クライアントを実装する。
サーバーの挙動は、

  1. クライアントが標準入力からテキストを1行読み込み、この行をサーバーに書き込む。
  2. サーバーは、ネットワーク入力からこの行を読み込み、クライアントにエコーバックする。
  3. クライアントはエコーされた行を読み込み、標準入力に印字する。

また、

  • クライアントとサーバーが起動された瞬間に何が起きるのか。
  • クライアントが正常に終了した場合に何が起きるのか。
  • クライアントが終了する前にサーバーが終了したら何が起きるのか。

といった境界条件を確認する。

サーバーコード 🔗

#include "unp.h"

int main(int argc, char **argv) {
    // リスニング用ソケットを作る。
    int listenfd;
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);

    // サーバーのアドレスを定義する。
    struct sockaddr_in servaddr;
    bzero(&servaddr, sizeof(servaddr)); // 初期化。
    servaddr.sin_family = AF_INET; // IPv4
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意のアドレスからの通信を受け入れる。
    servaddr.sin_port = htons(SERV_PORT); // テキストで定義されているリスニングポート
    
    // ソケットをポートにバインドする。
    Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

    // リッスンを開始
    Listen(listenfd, LISTENQ); // 接続待ちソケットのキューの長さはマクロで定義。
    for (;;) {
        // 接続済みソケットのディスクリプター。
        int connfd;
        // ピアの情報を取り出すための構造体。
        struct sockaddr_in cliaddr;
        // ピア情報を取り出した際に、書き込まれたサイズを受け取るための変数。
        socklen_t clen = sizeof(cliaddr);

        // 接続を受け付ける。
        connfd = Accept(listenfd, (SA *) &cliaddr, &clen);
        
        if ( Fork() == 0 ) { // 子プロセス
            Close(listenfd); // 子プロセスにもリスニングソケットが引き継がれるので閉じておく。
            str_echo(connfd);
            exit(0);
        }
        // 親/子プロセス
        Close(connfd);
    }
}

これをビルドする。

cc -I../unpv13e/lib -L../unpv13e tcpsrv.c -lunp
  • ../unpv13e/lib/unp.h のライブラリは ../unpv13e/libunp.a
    • -L../unpv13e としてライブラリの探索パスを指定する。
    • -l でライブラリのファイル名(から lib プレフィクスと .a サフィックスを除いたもの)を指定する。
      • 参照元(tcpsrv.c)よりあとに記述が必要な点に注意。

サーバーを起動し、telnet すると echo reply が確認できる。

❯ telnet localhost 9877
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
hello
world
world
^]quit

telnet> quit
Connection closed.

起動直後のサーバーのリスニングソケットの状態は以下。

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN  

クライアント接続後のソケットは以下。

❯ netstat -a | head
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN     
tcp        0      0 localhost:9877          localhost:37664         ESTABLISHED
tcp        0      0 localhost:37664         localhost:9877          ESTABLISHED

1行目がサーバーの親プロセス、2行目がサーバーの子プロセス、3行目がクライアント(telnet)のプロセスが持っているソケット。

プロセスの状況も確認しておく。
サーバーを起動した直後は以下の通り。

❯ ps -laP
F S   UID   PID  PPID PSR  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000 28278 12823   3  0  80   0 -   452 inet_c pts/1    00:00:00 a.out

ここに接続すると、子プロセスができる。

❯ ps -laP
F S   UID   PID  PPID PSR  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000 28278 12823   3  0  80   0 -   452 inet_c pts/1    00:00:00 a.out
1 S  1000 28591 28278   2  0  80   0 -   452 wait_w pts/1    00:00:00 a.out

クライアントを閉じると以下のようになる。

F S   UID   PID  PPID PSR  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000 28278 12823   3  0  80   0 -   452 inet_c pts/1    00:00:00 a.out
1 Z  1000 28591 28278   1  0  80   0 -     0 -      pts/1    00:00:00 a.out <defunct>

子プロセスのステータスはZ(ゾンビ)になっていることがわかる。
子プロセスは exit しただけでは破棄されず、親プロセスでステータスを回収する必要がある。
これは、子プロセスの終了状態、CPU時間やメモリ使用量といった情報を親プロセスが後で参照する余地を残すためである。

現時点での実装では、この処理が入っていないため、子プロセスがゾンビ化している。

Posixのシグナル処理 🔗

シグナルとは、プロセスに対するイベント発生の通知である。ソフトウェア割り込みとも呼ばれる。
シグナルは、あるプロセスから別のプロセスへ、あるいはカーネルからあるプロセスへと送ることができる。

プロセスでは、あるシグナルを受け取った時に発火させる処理を紐づけることができる(処理配備: disposition)。
※ ただし、SIGKILLSIGSTOP は介入できない。

シグナル処理配備を実施する最も簡単な方法は signal(SIG, func) を呼び出すことだが、この関数は古く実装によってこの関数の呼び出しの意味(semantics)が異なっている。Poxis では意味を厳格に定めた sigaction という関数を定義しているが、引数に構造体が含まれるため処理が煩雑になりやすい。

ここでは、sigactionをラップした signal 関数を実装する。

#include "unp.h"

Sigfunc * signal(int signo, Sigfunc *func) {
    // Poxis 流のシグナル処理配備。
    struct sigaction act, oact;
    act.sa_handler = func;
    // sa_mask は、シグナルハンドラ呼び出し中に受付をブロックするシグナルを表す。
    // これをクリア(empty)することで、ブロックされるシグナルが存在しないことを示す。
    sigemptyset(&act.sa_mask);
    if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
    act.sa_flags |= SA_INTERRUPT;
#endif
    } else {
#ifdef SA_RESTART
    act.sa_flags |= SA_RESTART;
#endif
    }
    if (sigaction(signo, &act, &oact) < 0) {
        return SIG_ERR;
    }
    return oact.sa_handler;
}

ゾンビプロセスは、放置しておくといずれ新しくプロセスを作成できない状態に陥るため、適切に処理することが必要。
子プロセスが終了すると親プロセスに SIGCHLD が通知されるため、これを補足してシグナルハンドラで wait を呼び出す。

#include "unp.h"

void sig_chld(int signo) {
    pid_t pid;
    int   stat;
    pid = wait(&stat);
    printf("child %d terminated\n", pid);
    return;
}

※ 一般的に、シグナルハンドラ内でIO処理をすることはアンチパターン。これは多くの標準関数が再代入可能性を持たないためである。詳しくは11.14にて。

これをビルドする。

❯ cc -I../unpv13e/lib -L../unpv13e tcpsrv.c signal.c sigchld.c -lunp -z muldefs
  • -z muldefs は、多重定義を許可するオプション。今回は Signal 関数について libunp.a のものではなく自分で実装したものを使う。

unp.hifndef __unp_h でインクルードガードされている。

これで再度同じことをすると、ゾンビプロセスが発生しないことが確認できた。

signalの実装で設定した SA_RESTART に関して補足しておく。
特に指定しない場合、カーネルは割り込まれたシステムコールを自動的に再実行することはなく、EINTR エラーを返して終了する。 たとえば、accept でブロックされている時に子プロセスが終了し SIGCHLD を受け取ると、シグナルハンドラが割り込む形になるので、この割り込みによってカーネルはシステムコール acceptEINTR で抜ける。したがって、main関数もアボートされてしまう。

これを回避するために、シグナルハンドラに SA_RESTART フラグを指定することで、自動的にシステムコールを再実行させることができる。もちろん、accept を呼び出すところで EINTR をハンドリングしても良い。

wait関数と waitpid関数 🔗

wait関数もwaitpid関数も、子プロセスの終了状態を取得するという目的は同じ。
異なる点としては、waitpid の方が

  • どの子プロセスの状態を取得するか(-1なら全て)
  • 取得を試みた子プロセスが終了していない場合に、終了するまでブロックするか。

といった挙動を細かく制御できる。

実は今の sig_chld の実装ではゾンビ化の対応としては不十分である。
ポイントは シグナルはキューイングされない という点。
たとえば、複数のクライアントを同時に複数の子プロセスで処理し、処理の終了タイミングが重なったとする。 シグナルはキューイングされないため、SIGCHLD に紐づけたシグナルハンドラは1回しか呼び出されないので、2つ目以降の SIGCHLD を送ってきた子プロセスはゾンビ化してしまう。

これを回避するには、

  • SIGCHLD を受け取ったら、複数の子プロセスの終了を想定し、順繰りに終了状態を確認する。
  • 最後の子プロセスが処理されたら処理を復帰できるように、ブロックさせない waitpid を使う。

という対応を入れる必要がある。

#include "unp.h"

void sig_chld(int signo) {
    pid_t pid;
    int   stat;
    while((pid = waitpid(-1, &stat, WNOHANG)) > 0)
        printf("child %d terminated\n", pid);
    return;
}

その他 🔗

  • コネクションが確立した後に、サーバーの子プロセスを kill すると、接続済みのソケットはクローズされ、親プロセスには SIGCHLDが通知される。
  • RST を受信したソケットへの書き込みを行ったプロセスへは SIGPIPE が通知される。
    • SIGPIPE に対するデフォルトの動作は、プロセスの終了である。
    • このシグナルを補足して、シグナルハンドラから帰った場合、書き込み処理は EPIPE エラーを返す。
  • バイナリのデータ構造をソケットを介して送る時には注意が必要。
    • サーバーとクライアントでエンディアンが異なる場合、バイナリ値の解釈が異なってしまうため意図通りに動かない。
    • 回避策:
      • テキスト文字列として受け渡す。
      • サポートするデータ型に関して、バイナリ形式(ビット数およびエンディアン)を明示的に定義する。
        • たとえば gRPC も、little-endian であることと、各データ型のエンコーディングが決められている。