【C言語/select関数】TCP通信でのselect関数の利用(複数ポートからの接続受付)

TCP通信でのselect関数の使い方の解説ページアイキャッチ

このページにはプロモーションが含まれています

このページでは、C言語で select 関数を利用する例として、TCP 通信で複数ポートからの接続待ちを行う例を示していきます。

select 関数の使い方に関しては下記ページで説明していますので、select 関数をご存知ない方や、select 関数の使い方を知らない方は下記ページを先に読んでみていただくことをオススメします。

【C言語】select関数の使い方(複数ソケットの監視)

select 関数

前述のとおり、select 関数については下記ページで解説していますので、詳しくは下記ページを参照していただきたのですが、超簡単に言えば、select 関数は「複数の FD に対して “Ready になったかどうか?” を監視する関数」になります。FD はファイルディスクリプタの略になります。

【C言語】select関数の使い方(複数ソケットの監視)

例えば、select 関数を利用すれば、ソケット通信で複数のソケットに対して “受信可能になったかどうか?” を監視するようなことが可能となります。1つのソケットに対する監視は recv 等の他の関数でも実現可能ですが、複数のソケットに対する監視に関しては select 関数の利用が必要となります。

TCP 通信における select 関数

また、先ほど紹介したページでは、特に UDP 通信を行う際の select 関数の使い方について説明しています。それに対し、このページでは、”TCP 通信” を行う際の select 関数の使い方について説明していきます。select 関数の使い方自体に関しては UDP 通信でも TCP 通信でも同じなのですが、この2つの通信では、select 関数を実行するタイミングが異なります。なぜ、実行するタイミングが異なるのか?この辺りを、これらの通信の特徴を踏まえながら説明していきたいと思います。

スポンサーリンク

UDP 通信では recv の前で select 関数を利用する

まず、UDP 通信の場合は、複数のソケットを利用しているプログラムで recv 関数によってデータの受信待ちを行う直前に select 関数を利用することが多いです。

これは、複数のソケットで受信待ちを行っており、どのソケットに対してデータの送信が行われてくるかが分からないからです。基本的に、recv 関数では特定の1つのソケットに対しての受信待ちを行うことになります。なので、次にデータが送信されてくる特定のソケットに対して recv 関数を実行しておく必要があります。ですが、1つのプログラムで複数のソケットを利用しており、 次にどのソケットに対してデータが送信されてくるかが分からないような場合は、どのソケットに対して recv を実行すれば良いかが定まらないことになります。

次に、どのソケットに対してデータの受信を行えば良いかが定まらない様子

なので、データが送信されてくる可能性のある全ソケットに対して監視を行う必要があります。そのため、複数のソケットを監視するために select 関数を利用することになります。select 関数では複数の FD (ソケット含) の読み込み (受信)・書き込み (送信)・例外に対する監視を行うことが可能です。

そして、Ready になったソケットがあれば select 関数は終了しますので、select 関数終了後に、その Ready になったソケットに対して recv 関数を実行すれば、確実に “データが送信されてきたソケット” に対してデータの受信を行うことが出来るようになります。

select関数で複数のソケットを監視する様子

要は、select 関数は、複数の FD のうち、どの FD にデータが書き込まれるかが分からない、すなわち、どの FD が読み込みに対して Ready になるかが分からない時に利用することで効果を発揮する関数ということです(説明の都合上、読み込みに限定していますが、書き込みや例外に関しても同様のことが言えます)。

もちろんタイムアウトを実現するために利用する場合は、次に Ready になる FD が分かりきっていても使うこともありますが、それ以外は基本的に、どの FD が Ready になるか分からない時に select 関数を利用することが多いと思います。

TCP 通信では accept の前で select 関数を利用する

こういった背景があるため、TCP 通信の場合は、UDP 通信とは違って select 関数は recv 関数ではなく、accept 関数を実行する直前に利用することが多いです。つまり、TCP 通信と UDP 通信の場合とで select 関数を実行するタイミングが異なります。実行するタイミングが異なるのは、TCP 通信はデータの送受信を行う前に接続を確立するプロトコルであるからになります。

UDP 通信と TCP 通信との違いに関しては下記ページで解説していますので詳しくは下記ページを参照してみていただければと思いますが、 これらの決定的な違いはデータの送受信を行う前に接続を確立しているか否かになります。TCP 通信の場合は、データの送受信を行う前に接続を確立している必要があります。

C言語でUDP通信を行う方法の解説ページアイキャッチ 【C言語】UDP通信を行う

そして、TCP 通信の場合、接続を確立したソケットに対してデータの送受信を行うことになります。したがって、TCP 通信の場合はデータの送受信を行う段階では既にデータが送信されてくるソケットが確定していることになります。なので、データの受信を行う recv 関数を実行する前に select 関数を実行してもあまり意味がないということになります。

TCP通信では事前に接続の確立を行い、その後接続相手とデータの送受信を行われることを示す図

ですが、接続を確立するソケットに関しては、実際に接続の要求を受けないと、どのソケットに対して接続が要求されているかが分かりません。そして、接続の要求を受け付けるのは、クライアントとサーバーの関係におけるサーバーが実行する accept 関数になります。

この accept 関数では、接続の要求を受け付けるソケットを指定する必要がありますが、どのソケットに接続要求が送信されてくるかが分かりません。なので、サーバーが複数のソケットで接続の要求を受け付けるのであれば、“どのソケットに接続要求が送信されてくるかが分からない accept 関数を実行する直前のタイミング” で select 関数を実行し、複数のソケットに対して Ready になったかどうかを監視してやれば良いことになります。

TCP通信でacceptを実行する前にselect関数を実行する様子

そして、この場合に監視するのはソケットに対する読み込みになります。C言語のソケット関数において、サーバーに対して接続要求を送信するのは connect 関数になります。そして、クライアントから connect 関数が実行されると「接続したい」ということを伝えるデータがサーバーに対して送信されることになります。つまり、「接続したい」ということを伝えるデータが特定のソケットに対して書き込みされることになります。

したがって、クライアントから connect 関数が実行される前にサーバーで select 関数を実行してソケットに対して読み込みの監視を行っていれば、クライアントから connect 関数が実行されてデータが送信されてくると、その接続を要求されたソケットが読み込みに対して Ready になります。

なので、その Ready になったソケットに対して accept を実行すれば、確実に接続要求を送信してきた相手と接続確立することができます。そして、その後は通常通りにデータの送受信を行ってやれば良いこだけです。

ReadyになったFDに対してaccept関数を実行する様子

ということで、TCP 通信の場合、select 関数を実行するのはサーバー側で、その実行タイミングは accept 関数の実行前というケースが多いと思います。そして、select 関数では読み込みに対して監視を行いますので、readfds 引数に監視対象の全てのソケットの FD を追加した FD の集合を指定して select 関数を実行してやれば良いことになります。

この FD の集合や readfds 引数に関しては下記ページで詳細を解説していますので、詳しくは下記ページをご参照いただければと思います。

【C言語】select関数の使い方(複数ソケットの監視)

また、ここでの解説の中で登場した accept 関数や connect 関数、さらには TCP 通信におけるこれらの関数の実行シーケンス等に関しては下記ページで解説していますので、これらについて詳しく知りたい方は別途下記ページをご参照いただければと思います。

ソケット通信解説ページのアイキャッチ 【C言語】ソケット通信について解説

複数ポートからの接続受付を行うプログラム

最後に、複数ポートからの接続受付を行うプログラムのサンプルソースコードと、その使い方を説明してい置きます。

ソケット通信においては、ソケットとポートが関連付けられ、そのソケットに対して accept 関数を実行すれば、その関連付けられたポートで接続受付の待ちが行われることになります。なので、ここまで説明してきたように、accept 関数を実行するタイミングの前に複数のソケットに対して select 関数を利用し、読み込みに対して監視を行うことで、実質的に複数のポートでの接続受付が行われることになります。

ここでは、これを行うプログラムのソースコードの実例を示していきます。 

スポンサーリンク

サーバー側のプログラム

まず、サーバー側のプログラムのサンプルソースコードである server.c は下記のようなものになります。

server.c
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    /* 監視対象のFDを用意する*/
    int fd1 = socket(AF_INET, SOCK_STREAM, 0);
    int fd2 = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr1, addr2;

    /* fd1が受信待ちするポートを50001に設定 */
    memset(&addr1, 0, sizeof(struct sockaddr_in));
    addr1.sin_family = AF_INET;
    addr1.sin_port = htons(50001);
    addr1.sin_addr.s_addr = inet_addr("127.0.0.1");

    bind(fd1, (const struct sockaddr *)&addr1, sizeof(addr1));

    /* fd2が受信待ちするポートを50002に設定 */
    memset(&addr2, 0, sizeof(struct sockaddr_in));
    addr2.sin_family = AF_INET;
    addr2.sin_port = htons(50002);
    addr2.sin_addr.s_addr = inet_addr("127.0.0.1");

    bind(fd2, (const struct sockaddr *)&addr2, sizeof(addr2));

    listen(fd1, 5);
    listen(fd2, 5);

    while (1) {
        /* FDの集合を用意する */
        fd_set read_fd_set;
        FD_ZERO(&read_fd_set);

        FD_SET(fd1, &read_fd_set);
        FD_SET(fd2, &read_fd_set);

        /* 監視対象とする FD の最大値を求める */
        int max_fd;

        if (fd1 < fd2) {
            max_fd = fd2;
        } else {
            max_fd = fd1;
        }
        
        /* 用意したデータを引数に指定してselect関数を実行する */
        int ret = select(max_fd + 1, &read_fd_set, NULL, NULL, NULL);

        /* 返却値を確認する */
        if (ret == 0) {
            printf("タイムアウトが発生しました\n");
            return 0;
        } else if (ret == -1) {
            printf("エラーが発生しました\n");
            return -1;
        } else {
            printf("いずれかのFDがReadyになりました\n");
        }

        /* Ready になった FD に読み込みや書き込みを実行する */
        unsigned char buf[256];
        if (FD_ISSET(fd1, &read_fd_set)) {
            printf("fd1がReadyになりました\n");

            /* fd1で接続を受け付け */
            int c_sock = accept(fd1, NULL, NULL);

            /* 接続を確立した相手からデータを受信 */
            recv(c_sock, buf, sizeof(buf), 0);
            printf("%s\n", buf);
            close(c_sock);
        }
        if (FD_ISSET(fd2, &read_fd_set)) {
            printf("fd2がReadyになりました\n");

            /* fd2で接続を受け付け */
            int c_sock = accept(fd2, NULL, NULL);

            /* 接続を確立した相手からデータを受信 */
            recv(c_sock, buf, sizeof(buf), 0);
            printf("%s\n", buf);
            close(c_sock);
        }
    }
    /* 用意した FD をクローズする */
    close(fd1);
    close(fd2);
}

このプログラムでは、2つのソケットの生成を行った後、それらのソケットの FD に対し、読み込み可能になったかどうかの監視を select 関数で監視するようになっています。これらのソケットの FD はそれぞれ fd1fd2 の変数で管理されており、fd1 のソケットがポート 50001fd2 のソケットがポート 50002bind 関数によって関連付けられており、これらに対して接続要求が送信されてくると select 関数が終了するようになっています。

そして、select 関数が終了すれば、接続要求が送信されてきた FD、すなわち Ready となった FD のソケットに対して accept を実行して接続を受け付けて接続要求を送信してきた相手との接続を確立しています。その後は、接続を確立した相手とデータの送受信を行えば良いだけで、上記の例ではデータの受信のみを recv 関数で実行するようにしています。

下記ページでも解説しているように、監視対象とする FD を全て “FD の集合” に追加したり、select 関数終了後に、Ready となった FD を調べたりする必要がある点が select 関数の使い方のポイントになると思います。このあたりの詳細に関しては、最初にも紹介した下記ページをご参照いただければと思います。

【C言語】select関数の使い方(複数ソケットの監視)

また、ここで示したソースコードではタイムアウトを設定していませんが、select 関数では FD が Ready になったかどうかを監視する時間にタイムアウトを設定することも可能です。これに関しても上記のページで説明していますので、詳しく知りたい方は上記のページをご参照いただければと思います。

クライアント側のプログラム

続いて、クライアント側のプログラムのソースコード例を紹介します。下記の client.c が、そのクライアント側のプログラムのソースコードになります。

client.c
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int x;
    char message[256];
    struct sockaddr_in addr;

    printf("書き込み先のFDを決めるために 1 or 2 を入力してください:");
    scanf("%d", &x);

    if (x == 1) {
        /* 送信先のポートを50001に設定 */
        memset(&addr, 0, sizeof(struct sockaddr_in));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(50001);
        addr.sin_addr.s_addr = inet_addr("127.0.0.1");
        sprintf(message, "Write data to fd1 (50001)");
        
    } else if (x == 2) {
        /* 送信先のポートを50002に設定 */
        memset(&addr, 0, sizeof(struct sockaddr_in));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(50002);
        addr.sin_addr.s_addr = inet_addr("127.0.0.1");
        sprintf(message, "Write data to fd2 (50002)");
    }

    /* 接続要求を送信 */
    connect(fd, (struct sockaddr *)&addr, sizeof(addr));

    /* 接続後に、接続した相手にデータを送信 */
    send(fd, message, strlen(message) + 1, 0);
    
    close(fd);
}

この client.c は、server.c のプログラムで select 関数によって監視されている FD に対して connect 関数で接続要求を送信することを役割としたプログラムのソースコードになります。

クライアントのプログラムの動作1

この接続要求の送信により、この接続要求を受信したソケットの FD が Ready 状態となり、server.c のプログラムで実行されている select 関数が終了することになります。そして、その後に server.c のプログラムで accept 関数が実行されて接続が確立され、その後にデータの送受信が行われるようになっています。

server.c で管理される FD を Ready 状態にするプログラムが存在しなければ、server.c で実行されている select 関数は、タイムアウトも設定されていないため永遠に終了しないことになります。そして、その FD を Ready 状態にするプログラムのソースコードの例が、上記の client.c となります。

client.cからのconnectによって、connectされたポートに関連づけられたソケットのFDがReadyになる様子

また、server.c では2つの FD に対して select 関数による監視が行われるため、client.c のプログラムからは、その2つのどちらか一方の FD のソケットに対して接続要求が行われるようになっています。そして、どちらのソケットに対して接続要求を行うのかについては、プログラム実行直後に実行される scanf により、ユーザーによって指定できるようにしています。

具体的には、プログラム実行直後に 1 を入力すれば server.c のプログラムが管理する fd1 のソケットに対して接続要求が送信されます。もっと具体的に言うと、fd1 に関連付けられたポート 50001 に対して接続要求が送信されることになります。そして、これにより server.cfd1 が Ready となります。同様に、2 を入力すると server.c における fd2 が Ready となることになります。

プログラムの実行手順

次は、先ほど示した2つのソースコードのプログラムを実行する手順について説明していきます。

まず、先ほど紹介した2つのソースコードを、1つ目を server.c、2つ目を client.c という名前で同じフォルダに保存してください。

続いて、ターミナルを2つ開き、それぞれのターミナルで、先ほどソースコードをファイル保存した先のフォルダに移動します。

移動後、一方のターミナルで下記の2つのコマンドを実行します。1つ目のコマンドの実行により、server.c がコンパイルされて server.exe が生成され、2つ目のコマンドの実行により client.c がコンパイルされて client.exe が生成されます。

gcc server.c -o server.exe
gcc clientc. -o client.exe

次は、生成された .exe ファイルを実行していきます。

最初に一方のターミナルで下記コマンドを実行して server.exe を実行します。

./server.exe

server.exe を実行したら、次は他方のターミナルで下記コマンドを実行して client.exe を実行します。

./client.exe                          
書き込み先のFDを決めるために 1 or 2 を入力してください:

次は、client.exe を起動しているターミナルで 1 を入力してエンターキーを押してみましょう。エンターキーを押せば、client.exe はポート 50001 に対してデータを送信し、その後にプログラムを終了するようになっています。

続いて server.exe を起動しているターミナルを確認してみましょう。こちらのターミナルには下記のような出力が行われると思います。server.c のソースコードより、1行目からは、いずれかの FD が Ready になったこと(つまり select 関数が 0 or 1 を返却して終了したこと)、2行目では Ready になったのが fd1 であることが読み取れます。また、3行目では、client.c から受信したデータが出力されています。

いずれかのFDがReadyになりました
fd1がReadyになりました
Write data to fd1 (50001)

この表示が行われるのは、client.exe からポート 50001 対して接続要求のデータの送信が行われたため、50001 に関連付けられている fd1 のソケットが読み込み可能となって select 関数が終了し、その後、読み込み可能になった FD に対して accept 関数が実行されて client.exe のソケットと server.exefd1 のソケットの間の接続が確立され、さらに接続が確立したソケット間でデータの送受信が行われたからになります(server.exe は client.exe から "Write data to fd1 (50001)" というデータを受信し、それを printf で出力している)。

同様に、先ほど client.exe を起動したターミナルで再度 client.exe を起動し、2 を入力してエンターキーを押してみましょう。これにより、client.exe からポート 50002 に対してデータの送信が行われることになります。server.exe を起動しているターミナルへの表示内容を確認すれば、今度は fd2 が Ready になったことが確認できると思います。

いずれかのFDがReadyになりました
fd2がReadyになりました
Write data to fd2 (50002)

こんな感じで、select 関数を利用することで複数のポートを監視することができ、これにより1つのプログラムから複数のポートからの接続要求の受付を行うことが可能となります。

スポンサーリンク

まとめ

このページでは、C言語での select 関数の利用例、特に TCP 通信での複数ポートからの接続待ちの行い方について説明しました!

select 関数が効果的に利用できるのは、複数の FD のうち、どの FD が Ready になるかが分からない、もしくは動的に決まるようなケースとなります。特に TCP 通信の場合は、複数のソケットで接続要求の受付を行う場合に、どのソケットに対して接続要求が送信されてくるかが分からないため、接続要求の受付を行う accept 関数を実行する前に select 関数を利用することが多いです。

select 関数は使い方は結構難しいですが、使いこなせるようになると開発できるプログラムの幅が広がり、よりプログラミングを楽しめるようになると思います。是非 select 関数の特徴や使い方は理解しておくようにしましょう!

また、接続要求やデータの受信だけでなく、標準入力への入力の監視を select 関数で行うこともでき、これにより標準入力への入力にタイムアウトを設定するようなこともできます。これに関しては下記ページで解説していますので、もっと select 関数について詳しくなりたい方は是非読んでみてください!

【C言語】タイムアウト付きscanfを実現(時間制限ありの4択クイズのサンプル付き)

同じカテゴリのページ一覧を表示