ソケットプログラミングのTips

3 min read読了の目安(約3000字

概要

ソケットプログラミングに関するTipsをメモレベルで記載する。

切断検知と経路切断

TCPコネクションの切断検出

対向がclose()、shuttdown()、プログラム終了等をしたときの切断検出について。
OSをシャットダウンさせた場合も通常はアプリケーションの終了処理が走り、正常な切断が動く。

受信側の切断検出は、recv()がlength==0で返ってきたとき、または、errno==ECONNRESETとなる。(ECONNRESETはRSTによって切断された場合)

送信側の切断検出は、切断された後2回目のsend()がエラーとなる。
※相手がclose()→こちらがsend()→相手にパケットが飛ぶが待ち受けプログラムがいないためRST応答が来る→もう1度send()→エラー
※send()自体はカーネルの送信バッファにデータコピーするだけなので、TCPレベルの応答(送信完了)を待つわけではない

つまり、受信側は切断を即時検出できるが、送信側はできない。データが届いたことを保証する必要がある場合、アプリケーションレベルで送信に対する応答を受信する実装とした方が良い。send()後にrecv()で応答を待ち、一定時間応答がなければエラーとする。

経路切断

LANケーブルが抜かれた場合等、ネットワークの途中経路で切断が発生した場合。
対向ホストの電源断により強制終了した場合なども正常に切断されない。

経路の切断は、切断された瞬間に検出することはできない。

recv()は待ち続けるし、send()も成功する。

send()を繰り返していると、カーネルの送信バッファが一杯になったらブロックされる。
だいたい15分くらいたつとエラーになる。

送信側は、アプリケーションレベルで送信に対する応答を受信する実装とした方が良い。
受信側は、もうこれ以上データがこないだろうという時間でタイムアウトするようにしておく。
また、相互にアプリケーションレベルの死活監視パケットを投げて監視する等。

受信タイムアウトについて

recv()をブロッキングモードで使用した場合、1byte以上受信するまでブロックされる。
タイムアウトについて何も考慮せずブロッキングモードを使用していると、パケットが来なかった場合ずっと待ち続けてしまう。

ノンブロッキングモードにした場合、recv()の応答はすぐに返り、データがない場合はerrno==EAGAINが返る。
タイムアウトの1つの方法として、ノンブロッキングモードでrecv()をループして開始時間からの経過時間でタイムアウトする方法がある。

ノンブロッキングで受信タイムアウト
int len = 0;
start_time = time(NULL);
while(1) {
    if(time(NULL) - start_time > TIMEOUT_SEC) {
        printf("Timeout\n");
        break;
    }
    len = recv(socket, buf, size, flag);
    if(len < 0) {
        if(errno == EAGAIN) {
	    usleep(100000); // CPUの消費を軽減するため
	}
	else {
	    perror("recv");
	    break;
	}
    }
    else {
        break;
    }
}

これでもタイムアウトを実現することができるが、ノンブロッキングでループを回しているとCPUを消費し続けてしまう。
上記では0.1秒sleepを入れて消費を軽減しているが、それでも0.1秒毎にCPUを使うことになるし、sleep時間を長くしてしまうと処理性能が劣化する。

epoll(またはselect,poll)を使うと、epoll_waitなどでソケットが受信可能となるのを待機でき、epoll_waitの引数でタイムアウトを指定することができる。

epollで受信タイムアウト
while(1) {
    nfds = epoll_wait(epollfd, &event, 1, TIMEOUT_SEC * 1000);
    if(nfds < 0) {
        if(errno != EINTR) {
            perror("epoll_wait");
	    break;
	}
    }
    else if(nfds == 0) { //タイムアウト
        printf("Timeout\n");
	break;
    }
    else {
        if(event.events & (EPOLLIN | EPOLLERR)) {
	    len = recv(socket, buf, size, flag);
	    if(len < 0) {
	        perror("recv");
		break;
	    }
	    else {
	        break;
	    }
	}
    }
}

epoll_waitの第4引数でタイムアウト時間を指定しており、戻り値==0のときがタイムアウトとなる。
ソケットがレディとなっていることが保証された状態でrecvを実行でき、epoll_waitでタイムアウト待ちしている時間はCPUも消費しない。

また、setsockopt()でソケットオプションSO_RCVTIMEOを使ってタイムアウト値を設定すると、recv()は指定時間経過した際、ノンブロッキングモードのようにEAGAINが返りタイムアウトする。

SO_RCVTIMEOで受信タイムアウト
int len = 0;
struct timeval tv;
tv.tv_sec = TIMEOUT_SEC;
tv.tv_usec = 0;
if(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, (char *)&tv, sizeof(tv)) < 0) {
    perror("setsockopt");
    return -1;
}
while(1) {
    len = recv(socket, buf, size, flag); // ブロッキングモードで実行
    if(len < 0) {
        if(errno == EINTR) {
	    continue;
	}
        if(errno == EAGAIN) {
	    perror("Timeout");; // タイムアウト
	}
	else {
	    perror("recv");
	}
	break;
    }
    else {
        break;
    }
}

なお、SO_RCVTIMEOはLinux 2.3.41 以降でサポートされるようになったため、古いシステムでは使えないかもしれない。
自分経験してきた開発現場では、select/poll/epollを使用したタイムアウトがほとんどだった。