5.TCPクライアント - サーバ例題 🔗
目的 🔗
前章までの知識を使って、簡単なTCPサーバー/クライアントを実装する。
サーバーの挙動は、
- クライアントが標準入力からテキストを1行読み込み、この行をサーバーに書き込む。
- サーバーは、ネットワーク入力からこの行を読み込み、クライアントにエコーバックする。
- クライアントはエコーされた行を読み込み、標準入力に印字する。
また、
- クライアントとサーバーが起動された瞬間に何が起きるのか。
- クライアントが正常に終了した場合に何が起きるのか。
- クライアントが終了する前にサーバーが終了したら何が起きるのか。
といった境界条件を確認する。
サーバーコード 🔗
#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)。
※ ただし、SIGKILL と SIGSTOP は介入できない。
シグナル処理配備を実施する最も簡単な方法は 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.h は ifndef __unp_h でインクルードガードされている。
これで再度同じことをすると、ゾンビプロセスが発生しないことが確認できた。
signalの実装で設定した SA_RESTART に関して補足しておく。
特に指定しない場合、カーネルは割り込まれたシステムコールを自動的に再実行することはなく、EINTR エラーを返して終了する。
たとえば、accept でブロックされている時に子プロセスが終了し SIGCHLD を受け取ると、シグナルハンドラが割り込む形になるので、この割り込みによってカーネルはシステムコール accept を EINTR で抜ける。したがって、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 であることと、各データ型のエンコーディングが決められている。