Edited at

C言語で学ぶソケットAPI入門 第3回 サーバ/クライアント編 #1

More than 3 years have passed since last update.

C言語で学ぶソケットAPI入門 第2回 クライアント編の続編のサーバ/クライアント編 #1です。

このテーマは一度の投稿だけで完結しそうにはないので、#1とさせていだきました。

引き続きソケットプログラミングをC言語のソースコードをもとにおっていきますが、ソケットの作成、TCPによるコネクションの確立などは前回、前々回と主なものはとりあげましたので、何か新しい情報がない限りその部分の解説は省略させて頂きます。

今回は多少アプリケーションプロトコルの兆しを見せながら、クライアントとサーバの実際のメッセージ(ネットワークアプリケーションでやりとりされるデータ)のやりとりについて見ていきます。


実行環境

クライアントソフト Mac OS X  10.10.5 gcc

サーバソフト x86_64のCentOs6.8 gcc

まずはクライアントソフトについて見ていきます。


クライアントソフトのソースコード


tcpc.c

#include <stdio.h> //printf(), fprintf(), perror()

#include <sys/socket.h> //socket(), bind(), accept(), listen()
#include <arpa/inet.h> // struct sockaddr_in, struct sockaddr, inet_ntoa(), inet_aton()
#include <stdlib.h> //atoi(), exit(), EXIT_FAILURE, EXIT_SUCCESS
#include <string.h> //memset(), strcmp()
#include <unistd.h> //close()

#define MSGSIZE 1024
#define BUFSIZE (MSGSIZE + 1)

int main(int argc, char* argv[]) {

int sock; //local socket descripter
struct sockaddr_in servSockAddr; //server internet socket address
unsigned short servPort; //server port number
char recvBuffer[BUFSIZE];//receive temporary buffer
char sendBuffer[BUFSIZE]; // send temporary buffer

if (argc != 3) {
fprintf(stderr, "argument count mismatch error.\n");
exit(EXIT_FAILURE);
}

memset(&servSockAddr, 0, sizeof(servSockAddr));

servSockAddr.sin_family = AF_INET;

if (inet_aton(argv[1], &servSockAddr.sin_addr) == 0) {
fprintf(stderr, "Invalid IP Address.\n");
exit(EXIT_FAILURE);
}

if ((servPort = (unsigned short) atoi(argv[2])) == 0) {
fprintf(stderr, "invalid port number.\n");
exit(EXIT_FAILURE);
}
servSockAddr.sin_port = htons(servPort);

if ((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0 ){
perror("socket() failed.");
exit(EXIT_FAILURE);
}

if (connect(sock, (struct sockaddr*) &servSockAddr, sizeof(servSockAddr)) < 0) {
perror("connect() failed.");
exit(EXIT_FAILURE);
}

printf("connect to %s\n", inet_ntoa(servSockAddr.sin_addr));

while(1){
printf("please enter the characters:");
if (fgets(sendBuffer, BUFSIZE, stdin) == NULL){
fprintf(stderr, "invalid input string.\n");
exit(EXIT_FAILURE);
}

if (send(sock, sendBuffer, strlen(sendBuffer), 0) <= 0) {
perror("send() failed.");
exit(EXIT_FAILURE);
}

int byteRcvd = 0;
int byteIndex = 0;
while (byteIndex < MSGSIZE) {
byteRcvd = recv(sock, &recvBuffer[byteIndex], 1, 0);
if (byteRcvd > 0) {
if (recvBuffer[byteIndex] == '\n'){
recvBuffer[byteIndex] = '\0';
if (strcmp(recvBuffer, "quit") == 0) {
close(sock);
return EXIT_SUCCESS;
} else {
break;
}
}
byteIndex += byteRcvd;
} else if(byteRcvd == 0){
perror("ERR_EMPTY_RESPONSE");
exit(EXIT_FAILURE);
} else {
perror("recv() failed.");
exit(EXIT_FAILURE);
}
}
printf("server return: %s\n", recvBuffer);
}

return EXIT_SUCCESS;
}



1行目から50行目

ヘッダ、データ型、ソケットの作成、コネクションの確立など前回のクライアント編と同じですので、割愛いたします。


51行目から56行目

whileループがはじまり、printf()にて文字入力をうながす指示を標準出力に出力します。

その後fgetsを用いて、文字列を標準入力から受け取ります。環境によりますが、入力の手段はユーザーの端末のキーボードになるでしょう。fgetsはEOFか改行コードを受け取るまで入力を受け取りますが、末尾にヌル文字を追加することに留意しておきましょう。

なお、MSGSIZEは表示される文字列の最大サイズを意図して、BUFSIZEはヌル文字を含めた格納されるバイト列の最大サイズを意図しています。


58行目〜61行目

send()システムコールを用いて、データを接続先のリモートホストに送ります。

第1引数は接続が確立したソケットディスクリプタを指定します。

第2引数は送信するメッセージが格納されているポインタを、第3引数にはメッセージの長さを指定します。メッセージの長さの指定にはstrlen関数を用いておりますので、fgetsで追加されたヌル文字のバイトは除外されます。

第4引数には前回のrecvと同じように、0を指定しています。

これは、メッセージがソケットモジュールの送信バッファに、指定したメッセージの長さが格納されるまでプログラムをブロックするというデフォルトの挙動を意味します。

ブロッキングされる状況としては、ソケットモジュールの送信バッファに空きが出ずにメッセージを送ることができない場合が考えられます。

これは、接続先のリモートホストの受信バッファに空き容量がない為、TCPのウィンドウサイズに基づくフロー制御により送信バッファから下位モジュールにデータを送ることができず、データ転送が待たされることなどによって発生します。

なお、ソケットの送信バッファはキューのデータ構造で実現されていて、netstatではSendQとして確認することもできます。

ここで重要なことはsend()の成功はあくまでOSに送信処理を依頼し、ソケットモジュールの送信バッファにメッセージを格納したということであり、実際にリモートホストにデータを送信することができたということは意味しないということです。

send()の処理の後、OS内のTCPモジュール、IPモジュール、デバイスドライバで様々な処理をされ、ネットワークインターフェースカードなどのハードウェアからパケット(バイト列)が何らかの物理変換をされ、何らかの物理媒体を経て接続先のホストに届くことになります。


64行目から64行目

受信したバッファのバイト数(byteRcvd)と受信バッファの配列のインデックス(byteIndex)を指定する整数を定義します。なおbyteIndexは今回の実装上、受信した合計のバイト数と一致します。


65行目から86行目

受信した合計のバイト数がMSGSIZE以内におさまる限りrecvにて受信を繰り返します。

recv()システムコールについては前回軽く触れましたが、第1引数に接続が確立したソケットのディスクリプタ、第2引数に受信するメッセージのポインタ、第3引数に受信するメッセージの長さ、第4引数にブロック動作に関してデフォルトの動作を指示する0を指定します。

デフォルトの動作は、ソケットの受信バッファから指定したメッセージの長さまでのバイト列が取り出し可能になるまでプログラムの実行をブロックします。ソケットの受信バッファはキューのデータ構造で実現されていて、netstatではRecvQとして確認できます、

recv()もsend()同様OSに対して、ソケットにたまっている受信バッファから、ユーザープロセスにバイト列を転送するよう依頼するシステムコールであり、下位の階層であるトランスポートモジュール(今回の場合はTCPモジュール)によって渡されてきたバイト列をソケットAPIを介して受け取っているにすぎません。

ネットワークインターフェースカードなどのハードウェアに届いた物理的な信号が、ハードウェア割り込みやソフトウェア割り込みなどによって、プロトコルスタックによって処理されたデータを受け取っているだけなので、recv()がデータの受信処理を行っているということではないということです。

つまり、パケットの受信処理自体はrecv()を実行していようがしていまいが、絶えず行われており、それぞれのモジュールのバッファに空きがなかったり、データが壊れていれば廃棄されてしまいます。

TCPモジュールの処理後の段階まで廃棄されなかった正しいメッセージはソケット受信バッファに格納されるので、recv()はそこにデータがあった場合、ユーザープロセスにメッセージを転送します。

ソースコードに戻ります。

今回は1バイトずつ、配列recvBufferに転送しています。

また、ストリーム型の受信バイト列が\nで終わった場合、そこまでが意味のあるデータの固まりとしてアプリケーションプロトコルを設計しているので、\nを受信した場合は改行をヌル文字で上書きし、strcmp関数を用いてquitかどうか判定しています。

quitと一致した場合は、動作を終了することにするのでクライアントのソケットを閉じて、プログラムを終了させます。

そうではない場合は、whileのループを抜けて、受信した文字列を改行つきで表示させます。バイト列の末尾にヌル文字をつけているので、C言語の文字列出力関数で出力することができます。

今回はプログラムを簡単にするためrecv()を1バイトずつ実行していますが、recv()はシステムコールのため、CPUのカーネルモードとユーザーモードの切り替えによるオーバーヘッドが少なからず発生します。

その為、一度のrecv()である程度大きなバイト列をユーザープロセスのバッファ内に格納して、それに対して1バイトずつ処理を行う方が実行性能が向上するはずですが、今回程度のデータ量では人間の体感的には変わらないと思います。

なお、続いてのサーバプログラムで確認しますが、サーバから送られてくるメッセージはクライアントで入力した文字列そのものなので、入力した文字列と同じものがそのまま標準出力に出力されることになります。

続いては、サーバソフトについて見ていきます。

今回のサーバソフトはクライアントソフトに比べると単純になっています。


サーバソフトのソースコード


tcps.c


#include <stdio.h> //printf(), fprintf(), perror()
#include <sys/socket.h> //socket(), bind(), accept(), listen()
#include <arpa/inet.h> // struct sockaddr_in, struct sockaddr, inet_ntoa()
#include <stdlib.h> //atoi(), exit(), EXIT_FAILURE, EXIT_SUCCESS
#include <string.h> //memset()
#include <unistd.h> //close()

#define QUEUELIMIT 5
#define MSGSIZE 1024
#define BUFSIZE (MSGSIZE + 1)

int main(int argc, char* argv[]) {

int servSock; //server socket descripter
int clitSock; //client socket descripter
struct sockaddr_in servSockAddr; //server internet socket address
struct sockaddr_in clitSockAddr; //client internet socket address
unsigned short servPort; //server port number
unsigned int clitLen; // client internet socket address length
char recvBuffer[BUFSIZE];//receive temporary buffer
int recvMsgSize, sendMsgSize; // recieve and send buffer size

if ( argc != 2) {
fprintf(stderr, "argument count mismatch error.\n");
exit(EXIT_FAILURE);
}

if ((servPort = (unsigned short) atoi(argv[1])) == 0) {
fprintf(stderr, "invalid port number.\n");
exit(EXIT_FAILURE);
}

if ((servSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0 ){
perror("socket() failed.");
exit(EXIT_FAILURE);
}

memset(&servSockAddr, 0, sizeof(servSockAddr));
servSockAddr.sin_family = AF_INET;
servSockAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servSockAddr.sin_port = htons(servPort);

if (bind(servSock, (struct sockaddr *) &servSockAddr, sizeof(servSockAddr) ) < 0 ) {
perror("bind() failed.");
exit(EXIT_FAILURE);
}

if (listen(servSock, QUEUELIMIT) < 0) {
perror("listen() failed.");
exit(EXIT_FAILURE);
}

while(1) {
clitLen = sizeof(clitSockAddr);
if ((clitSock = accept(servSock, (struct sockaddr *) &clitSockAddr, &clitLen)) < 0) {
perror("accept() failed.");
exit(EXIT_FAILURE);
}
printf("connected from %s.\n", inet_ntoa(clitSockAddr.sin_addr));

while(1) {
if ((recvMsgSize = recv(clitSock, recvBuffer, BUFSIZE, 0)) < 0) {
perror("recv() failed.");
exit(EXIT_FAILURE);
} else if(recvMsgSize == 0){
fprintf(stderr, "connection closed by foreign host.\n");
break;
}

if((sendMsgSize = send(clitSock, recvBuffer, recvMsgSize, 0)) < 0){
perror("send() failed.");
exit(EXIT_FAILURE);
} else if(sendMsgSize == 0){
fprintf(stderr, "connection closed by foreign host.\n");
break;
}
}

close(clitSock);
}

close(servSock);

return EXIT_SUCCESS;
}



61行目から77行目

60行目から上は前々回のサーバプログラムと同じです。

クライアントプログラムで紹介したrecv()とsend()については上述した通りです。

サーバプログラムはクライアントプログラムから受け取ったメッセージをそのまま、クライアントに返す実装になっています。

send()、recv()ともに返り値が0の場合の処理をもうけていますが、こうすることによってクライアントソフトから意図した意図しないにかかわらず接続を切断された場合でも、再びaccept()によって新たな接続を待ち受ける状態を作ることができ、サーバプログラムの実行を継続することができます。

なお、現状のサーバプログラムは複数のクライアントからの要求を同時に応えることができないので、サーバプログラムとしては使い勝手が悪いです。

実際、このクライアントプログラムを2つ以上実行させると、後で立ち上げたプログラムはfgetsで文字入力をしてsend()を実行した段階で処理が待たされ続け、先に処理されているクライアントプログラムが終了すると、処理が行われはじめるといった具合です。

人がどんどん入ってきているのに、ATMが1台しかないので後から入ってくる人が待たされ続けているような状態です。

この不便な状態をどうにかする為に、マルチプロセスやマルチスレッド、あるいは同一プロセス、スレッドによる多重処理などの方法に拡張したものも今後紹介していきます。

次回に関しては、先日UDPに関する下記の記事を拝見しましたので、一度UDPについてのデータ送受信の仕組みとともに、TCPとの違いについて確認していきます。

GoogleのQUICプロトコル:TCPからUDPへWebを移行する


参考書籍