[ ECHOサーバ 〜マルチプロセスとシグナル〜 ] 動作確認はすべて Linux + gcc で行っています。 >>[ 0x01 ] もっともシンプルなechoサーバ http://ruffnex.oc.to/kenji/src/echo.c [kenji@localhost test]$ gcc -Wall echo.c [kenji@localhost test]$ ./a.out 8080 port=8080 ready for accept ここでtelnetでこのサーバに繋いでみる。 (server) (telnet) accept:127.0.0.1:32812 | [kenji@localhost test]$ telnet localhost 8080 [client] dd | Trying 127.0.0.1... | Connected to localhost. [client] dddd | Escape character is '^]'. | dd ....... | dd ......... | dddd .......... | dddd | EOF | Connection closed by foreign host. | [kenji@localhost test]$ どうやらechoサーバになっているようだ。しかし、このプログラムでは同時に 1つのクライアント(telnet)しか相手にできない。試しにtelnetでこのサーバ に接続した状態でさらに他のtelnetで接続すると、片方の処理が終るまで応答 を受けつけない。それではサーバとしてはマズイ。よって複数クライアントに 対応できる仕組みを作らないといけない。 >>[ 0x02 ] マルチプロセス 例えばこういうプログラムを実行してみる。if-else文により分けているので 必ずどちらかの処理しか行われないはずだ。「IFが真」という文字列かあるい は「IFが偽」という文字列のどちらかしか表示されないはずである。 fork.c ------------------------------------------------------------------------------ #include #include #include #include int main(void) { int i; printf("fork()の実験\n\n"); if((i=fork()) == 0) { printf("IFが真\n"); exit(0); }else{ printf("IFが偽\n"); wait(0); } return 0; } ------------------------------------------------------------------------------ [kenji@localhost test]$ gcc -Wall fork.c [kenji@localhost test]$ ./a.out fork()の実験 IFが偽 IFが真 [kenji@localhost test]$ しかし実際はIF分岐のそれぞれのプログラムが実行されている。これがfork() の概念を知る上でのポイントとなる。なぜ分岐に関係なくそれぞれのプログラ ムが実行されたのか? 実はfork()が呼ばれた時点でメモリ空間にこのプログラムと全く同じプログラ ム(子プロセス)がつくられたのだ。全く同じプログラムとはつまりfork()が呼 ばれる場所までの処理がまったく同じなわけだから当然様々なファイルがイン クルードされているしもちろん i も宣言されている。(もちろん実際にはソー スコードがコピーされてるわけでは無いが考え方として)このあたらしく作られ た(コピーされた)ものを子プロセスと呼ぶ。そしてコピー元となったこのプロ グラム自身は親プロセスと呼ばれる。fork()が実行されたあとは親プロセスと 子プロセスが同時に実行されていることになる。 さてまったく同じプログラムと書いたが実はそれは違う。厳密には嘘である。 なぜなら本当に全く同じコピーされたプログラムが実行されるならば「IFが偽」 という文字列が2回出力されるはずだからである。しかしそうはなっていない。 これはつまり親プロセスと子プロセスでfork()の戻り値が違うということだ。 検証してみよう。 fork2.c ------------------------------------------------------------------------------ #include #include #include #include int main(void) { int i; printf("fork()の実験\n\n"); if((i=fork()) == 0) { printf("IFが真 i = %d\n", i); exit(0); }else{ printf("IFが偽 i = %d\n", i); wait(0); } return 0; } ------------------------------------------------------------------------------ [kenji@localhost test]$ gcc -Wall fork2.c [kenji@localhost test]$ ./a.out fork()の実験 IFが偽 i = 1584 IFが真 i = 0 [kenji@localhost test]$ 子プロセスと親プロセスとでfork()の戻り値は違うようだ。子プロセスは戻り 値が 0 になる。親プロセスは 1584 という数字になっている。man pageを見 てみるとこう書かれてある。 fork creates a child process that differs from the parent process only in its PID and PPID, forkは親プロセスとPID,PPIDのみが違う子プロセスを生成する。 (PID = プロセスID, PPID = 親プロセスID) fork3.c ------------------------------------------------------------------------------ #include #include #include #include int main(void) { int i; printf("fork()の実験\n\n"); if((i=fork()) == 0) { pid_t pid = getpid(); printf("IFが真 i = %d, pid = %d\n", i, pid); exit(0); }else{ pid_t pid = getpid(); printf("IFが偽 i = %d, pid = %d\n", i, pid); wait(0); } return 0; } ------------------------------------------------------------------------------ [kenji@localhost test]$ gcc -Wall fork3.c [kenji@localhost test]$ ./a.out fork()の実験 IFが偽 i = 1627, pid = 1626 IFが真 i = 0, pid = 1627 [kenji@localhost test]$ getpid()は自分のプロセスIDを取得する。子プロセスのプロセスIDと親プロセ スのfork()の戻り値が同じになっている。つまり親プロセスが子プロセスを生 成したときのfork()の戻り値は生成した子プロセスのプロセスIDということに なる。子プロセスのfork()の戻り値は 0 だ。 最後になぜwait(0)があるのか。これは子プロセスが終了するのを親プロセス が待つということだ。つまり親プロセスはこのwaitのところで処理が中断して いる。そして子プロセスが終了したことを確認して、wait以下の処理が実行さ れ自分自身も終了することになるということだ。別に必ずしもこどもの終了を 待たなければならないということでは無いけれど。気になるなら試しにwaitを コメントアウトして実行してみてください。子プロセスはexit(0)で終了して います。waitの戻り値は終了した子プロセスのプロセスIDです。 >>[ 0x03 ] マルチプロセスに対応する OneClient ------------------------------------------------------------------------------ int OneClient(int acc) { int pid; switch(pid=fork()){ case 0: DoClient(acc); close(acc); exit(0); break; case -1: perror("fork"); SocketClose(acc); break; default: close(acc); break; } return 0; } ------------------------------------------------------------------------------ さて0x02章のforkシステムコールを実際に使用してみた。この関数をacceptで 受信した後に入れてクライアントとの通信を子プロセスに任せるようにする。 親プロセスは何をするのかというと、もちろんまた次の接続要求がくるかもし れないのでacceptで待機することとなる。親プロセスはdefaultを通るのでそ のままacceptからもらってきたaccをcloseして処理はmain関数に進む。子プロ セスはcase 0を進むのでDoClient関数が実行されることになる。case -1はエ ラーの処理なのでそのままSocketClose関数を呼ぶことにする。 SocketClose ------------------------------------------------------------------------------ int SocketClose(int sock) { shutdown(sock, 2); return(close(sock)); } ------------------------------------------------------------------------------ シンプルな作りだ。受け取ったsocketをcloseするだけの関数だ。 DoClient ------------------------------------------------------------------------------ #define TRUE 1 #define FALSE 0 int DoClient(int acc) { int loop = TRUE; int len; char buf[256]; char *ptr; while(loop){ if((len = recv(acc, buf, (sizeof(buf)-1), 0)) < 0){ fprintf(stderr, "recv\n"); return(-1); } if((ptr = strstr(buf, "EOF")) != NULL) loop = FALSE; buf[len] = '\0'; len = strlen(buf); if(( len = send(acc, buf, len, 0)) < 0){ fprintf(stderr, "send\n"); return(-1); } } return 0; } ------------------------------------------------------------------------------ ここが実質的なECHOサーバの役割をはたしている。recvで受信してsendを返し ている。この関数は子プロセスでしか呼ばれない。EOFという文字列をうけとっ たらloopをFALSEにしてwhileを脱出!そのあとはOneClientに戻りclose(acc) のあとexit(0)で子プロセスは終了する。 InitSocket ------------------------------------------------------------------------------ #define MAXHOSTLEN 64 int InitSocket( char *port, int portNo ) { char hostname[MAXHOSTLEN]; struct hostent *myhost; struct servent *serv; struct in_addr *aptr; struct sockaddr_in me; int opt, sock; if((gethostname(hostname, MAXHOSTLEN)) < 0){ perror("gethostname"); return(-1); } #ifdef DEBUG fprintf(stderr, "gethostname = %s\n", hostname); #endif if((myhost=gethostbyname(hostname)) == NULL){ perror("gethostbyname"); return(-1); } aptr = (struct in_addr *)*myhost->h_addr_list; #ifdef DEBUG fprintf(stderr, "gethostbyname = %s\n", inet_ntoa(*aptr)); fprintf(stderr, "gethostbyname = %d.%d.%d.%d\n" ,(unsigned char)myhost->h_addr_list[0][0] ,(unsigned char)myhost->h_addr_list[0][1] ,(unsigned char)myhost->h_addr_list[0][2] ,(unsigned char)myhost->h_addr_list[0][3] ); #endif if((sock=socket(AF_INET, SOCK_STREAM, 0)) < 0){ perror("socket"); return(-1); } opt = 1; if(setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&opt, sizeof(int)) != 0){ perror("setsockopt"); return(-1); } memset((char *)&me, '\0', sizeof(me)); me.sin_family = AF_INET; if((serv=getservbyname(port, "tcp")) == NULL){ me.sin_port = htons(portNo); }else{ me.sin_port = serv->s_port; } if(bind(sock, (struct sockaddr *)&me, sizeof(me)) < 0){ perror("bind"); return(-1); } return(sock); } ------------------------------------------------------------------------------ これはSocket初期化とOpenの処理だ。main関数にこの処理を書くと変数定義な どが多くなって可視性が悪くなるので分けた。DEBUGをdefineするとfprintfで 様々な情報が出力されるようにしてある。 これらの関数を使用してマルチプロセスに対応したECHOサーバが以下だ。 http://ruffnex.oc.to/kenji/src/echo2.c [kenji@localhost test]$ gcc -Wall echo2.c -o echo2 [kenji@localhost test]$ ./echo2 8080 ready for accept accept:127.0.0.1:33040 accept:127.0.0.1:33041 (別のWindowで) [kenji@localhost kenji]$ telnet localhost 8080 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. ddd ddd (さらに別のWindowで) [kenji@localhost kenji]$ telnet localhost 8080 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. aaa aaa 見事マルチプロセスを実現! >>[ 0x04 ] シグナルと時間 シグナルはプロセスに対するソフトウェア割込みである。 シグナルの種類は /usr/include/asm/signal.h で参照できる。 /usr/include/asm/signal.h ------------------------------------------------------------------------------ .............. ............ #define SIGHUP 1 #define SIGINT 2 #define SIGQUIT 3 #define SIGILL 4 #define SIGTRAP 5 #define SIGABRT 6 #define SIGIOT 6 ........... ............. .............. #define SIGPWR 30 #define SIGSYS 31 #define SIGUNUSED 31 .......... ........... ------------------------------------------------------------------------------ 定義 | 割り込み ----------------- SIGINT | C-c SIGQUIT | C-\ SIGCONT | C-q SIGSTOP | C-s SUGTSTP | C-z sig.c ------------------------------------------------------------------------------ #include #include #include void Ctrl_c(int sig) { printf("Ctrl + c が押されました\n"); printf("%d が押されました\n", sig); exit(0); } int main(void) { signal(SIGINT, Ctrl_c); sleep(5); return 0; } ------------------------------------------------------------------------------ [kenji@localhost server]$ gcc -Wall sig.c [kenji@localhost server]$ ./a.out Ctrl + c が押されました 2 が押されました [kenji@localhost server]$ このように割り込みを捕捉することができる。割り込みが捕捉されたら強制的 に指定された関数に実行がうつる。ここではexitで終了しているが、もちろん exitがなければ、printfが実行されたあとにsleepに戻ることになる。 シグナルに関する定義は /usr/inclde/signal.h だ。 /usr/include/signal.h ------------------------------------------------------------------------------ .............. #ifdef __USE_POSIX extern int kill (__pid_t __pid, int __sig) __THROW; #endif /* Use POSIX. */ ....................... ........... /* Raise signal SIG, i.e., send SIG to yourself. */ extern int raise (int __sig) __THROW; ................ ............. ------------------------------------------------------------------------------ sig2.c ------------------------------------------------------------------------------ #include #include #include void Ctrl_c(int sig) { printf("Ctrl + c が押されました\n"); printf("%d が押されました\n", sig); exit(0); } int main(void) { signal(SIGINT, Ctrl_c); sleep(5); raise(SIGINT); return 0; } ------------------------------------------------------------------------------ [kenji@localhost server]$ gcc -Wall sig2.c [kenji@localhost server]$ ./a.out ( 5 秒待つ ) Ctrl + c が押されました 2 が押されました [kenji@localhost server]$ Ctrl + c を押していないのに表示されます。raise関数を使うとプログラムで シグナルを送ることができます。kill関数も基本的には同じなのですが、どの プロセスにシグナルを送るのかも決めることができます。raise関数は自分自 身(プロセス)にしかシグナルを送ることができません。 /usr/include/unistd.h ------------------------------------------------------------------------------ ................... The signal may come late due to processor scheduling. */ extern unsigned int alarm (unsigned int __seconds) __THROW; ............................ ------------------------------------------------------------------------------ sig3.c ------------------------------------------------------------------------------ #include #include #include void time(int sig) { printf("3秒たちました。\n"); exit(0); } int main(void) { signal(SIGALRM, time); alarm(3); while(1){ } return 0; } ------------------------------------------------------------------------------ [kenji@localhost server]$ gcc -Wall sig3.c [kenji@localhost server]$ ./a.out 3秒たちました。 [kenji@localhost server]$ alarm関数は指定した時間が経過するとシグナルを送ります。 alarmとの時間繋がりでもうひとつ時間に関する関数を紹介します。 select.c ------------------------------------------------------------------------------ #include #include #include #include int main(void){ fd_set rfds; struct timeval tv; FD_ZERO(&rfds); FD_SET(0, &rfds); tv.tv_sec = 5; tv.tv_usec = 0; switch(select(1, &rfds, NULL, NULL, &tv)){ case -1: perror("select()"); exit(-1); case 0: printf("5 秒以内にデータが入力されませんでした。\n"); break; default: printf("標準入力からのデータを取得\n"); } return 0; } ------------------------------------------------------------------------------ [kenji@localhost server]$ gcc select.c [kenji@localhost server]$ ./a.out dw (←何かを入力する) 標準入力からのデータを取得 [kenji@localhost server]$ dw bash: dw: command not found [kenji@localhost server]$ [kenji@localhost server]$ ./a.out 5 秒以内にデータが入力されませんでした。 (←何も入力しない) [kenji@localhost server]$ >>[ 0x05 ] シグナルとタイムアウトの処理を加える http://ruffnex.oc.to/kenji/src/echo3.c [kenji@localhost test]$ gcc -Wall echo3.c -o echo3 [kenji@localhost test]$ ./echo3 8080 ready for accept accept:127.0.0.1:33046 accept:127.0.0.1:33047 (別のWindowで) [kenji@localhost kenji]$ telnet localhost 8080 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. xx xx timeout Connection closed by foreign host. [kenji@localhost kenji]$ (さらに別のWindowで) [kenji@localhost kenji]$ telnet localhost 8080 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. dd dd timeout Connection closed by foreign host. [kenji@localhost kenji]$ main関数はシグナルの処理を加えただけです。DoClient関数は多少の変更を加 えられています。 DoClient ------------------------------------------------------------------------------ #define TRUE 1 #define FALSE 0 int DoClient(int acc) { int loop = TRUE; int len; char buf[256]; char *ptr; fd_set rfds; struct timeval timeout; while(loop){ FD_ZERO(&rfds); FD_SET(acc, &rfds); timeout.tv_sec = 5; timeout.tv_usec = 0; switch(select(acc + 1, &rfds, NULL, NULL, &timeout)){ case -1: fprintf(stderr, "select\n"); return(-1); break; case 0: strncpy(buf, "timeout\n", 9); len = strlen(buf); if(( len = send(acc, buf, len, 0)) < 0){ fprintf(stderr, "send\n"); return(-1); } loop = FALSE; break; default: if((len = recv(acc, buf, (sizeof(buf)-1), 0)) < 0){ fprintf(stderr, "recv\n"); return(-1); } if((ptr = strstr(buf, "EOF")) != NULL) loop = FALSE; buf[len] = '\0'; len = strlen(buf); if(( len = send(acc, buf, len, 0)) < 0){ fprintf(stderr, "send\n"); return(-1); } break; } } return 0; } ------------------------------------------------------------------------------ select関数を使用して5秒間データの受信が行われない場合は接続を終了して います。普通はもっと長いのですが、テストechoサーバなので許して下さい。 5秒じゃ入力できないというならtv_secをお好きなように変更してください。 >>[ 0x06 ] 標準入出力ライブラリ(fgets, fprintfなど)を使う さて、ぶっちゃけた話、バイト単位でデータを取得するのはメンドクサイです。 いやechoサーバなら特に問題無いのですが、出来れば recv や send よりも標 準入出力ライブラリの fgets や fprintf を使用したいものです。一行読み込 みの方が何かと便利ですしね。行単位でSocketから文字列を取得したい場合 fdopen を使用します。 http://ruffnex.oc.to/kenji/src/echo4.c これは echo3.c をfdopenを使って行単位の送受信を実現してるバージョンで す。機能的には変わってないのですが recv, sendの変わりに、fgets, fprintfを 利用しています。これらの関数を使用し行単位の送受信を実現しています。変 更を加えたのは DoClient関数 だけです。 DoClient ------------------------------------------------------------------------------ #define TRUE 1 #define FALSE 0 int DoClient(int acc) { FILE *fp; int loop = TRUE; char buf[256], *ptr; fd_set rfds; struct timeval timeout; if((fp = fdopen(acc, "r+")) == NULL){ fprintf(stderr, "fdopen\n"); return(-1); } setvbuf(fp, NULL, _IONBF, 0); while(loop){ FD_ZERO(&rfds); FD_SET(acc, &rfds); timeout.tv_sec = 5; timeout.tv_usec = 0; switch(select(acc + 1, &rfds, NULL, NULL, &timeout)){ case -1: fprintf(stderr, "select\n"); return(-1); break; case 0: fprintf(fp, "timeout\n"); loop = FALSE; break; default: fgets(buf, sizeof(buf), fp); if((ptr = strstr(buf, "EOF")) != NULL) loop = FALSE; fprintf(fp, "%s", buf); break; } } fclose(fp); return 0; } ------------------------------------------------------------------------------ fdopenを使って Socket をファイルとして扱うようにしています。ファイルの 場合はバッファリングが行われる可能性があるので setvbuf関数でバッファリ ングをOFFにしています。これによりfgets(一行読み込み)やfprintfでの書 き込みが Socket に対して行うことができるようになります。 バッファリングOFFとはメモリに確保された(バッファリングされた)データ が吐きだされるタイミングをOFFに設定することです。OFFにするとはメ モリにバッファリングしない(確保しないでそのまま吐きだす)ということです。 これは以外に説明しにくいので、気になるなら検索してください。一応分かり やすいかもしれないサンプルプログラムを載せます。 test.c ------------------------------------------------------------------------------ #include int main(void) { FILE *fp1, *fp2; char buf[256] = "first data."; fp1 = fopen("test.txt", "w"); fprintf(fp1, "second data.\n"); fp2 = fopen("test.txt", "r"); fgets(buf, 256, fp2); printf("test.txt = %s \n", buf); fclose(fp1); fclose(fp2); fp2 = fopen("test.txt", "r"); fgets(buf, 256, fp2); printf("test.txt = %s \n", buf); fclose(fp2); return 0; } ------------------------------------------------------------------------------ [kenji@localhost echo]$ gcc test.c [kenji@localhost echo]$ ./a.out test.txt = first data. test.txt = second data. [kenji@localhost echo]$ 重要なのは実際にファイルに書き込みが行われたのは fprintf が実行された 時ではなくfclose(fp1)が実行された時点であるということです。もちろんプ ログラム的には fprintf で書き込みを行うのですが、このときのデータはメ モリにバッファリングされている状態であり(メモリに確保されている状態で あり)正確にはまだファイルには書き込まれていません。だがら最初は first data が出力されるわけです。 test2.c ------------------------------------------------------------------------------ #include int main(void) { FILE *fp1, *fp2; char buf[256] = "first data."; fp1 = fopen("test.txt", "w"); setvbuf(fp1, NULL, _IONBF, 0); /* 追加 */ fprintf(fp1, "second data.\n"); fp2 = fopen("test.txt", "r"); fgets(buf, 256, fp2); printf("test.txt = %s \n", buf); fclose(fp1); fclose(fp2); fp2 = fopen("test.txt", "r"); fgets(buf, 256, fp2); printf("test.txt = %s \n", buf); fclose(fp2); return 0; } ------------------------------------------------------------------------------ [kenji@localhost echo]$ gcc test2.c [kenji@localhost echo]$ ./a.out test.txt = second data. test.txt = second data. [kenji@localhost echo]$ setvbuf関数を使いバッファリングをOFFにすると、fprintfが実行された時 点でファイルに書き込まれるので、今度は second data が2回出力されるこ とになります。 >>[ 0x07 ] 最後に 一応これである程度実用的なechoサーバが出来たと思います。(そもそも実用 的なechoサーバがどんなものかは知らないけれど) でもまだ問題はあります。 接続数の制限をしていませんので、ものすごい数(例えば10000とか)の同時接 続要求にすべて対応してしまうので子プロセスが10000個作られることになり ます。こうなるとメモリが足りなくなりサーバは落ちるでしょう。最大接続数 などを決めておかなければいけません。私はとりあえず仕組みが分かればOK な性格ですのでこういう最大接続数に対応するといった実用的なプログラムを 組むことは苦手です(ぉぃ) 気になる方はチャレンジしてみてください。 さて、なぜこんなもんを作ったのかというと、実は近いうちにWEBサーバを取 りあげようかなと思ってるからです。つまり今回はその布石のようなものです。 まぁいつになるかは分かりませんが。いずれ。 End. written by kenji aiko 2003/11/10 2003/11/11 一部修正(ソースコードをまとめる) 2003/11/18 fdopenの項目を追加 Copyright (C) 2003 kenji aiko All Rights Reserved