Linux で TCP クライアントを実装する上での注意点・初級編

「ソケットを直に触るプログラムを書くのは初めてなんですが、何かアドバイスないですか?」みたいなことを聞かれたので、入門書には載ってなさそうな注意点をまとめてみる。
とは言っても、私自身、直にソケットを叩いて C や C++ でプログラムを書いていたのは遠い過去のことだし、その道の専門家でもないので、誤りや抜けてる項目に気づいた方や、よりベターな方法を知ってる方は指摘していただければありがたい。

前提

  • 対象読者は最低限の知識(TCP/IP, socket, シグナル等)を持っていることが前提。
  • Linux + GNU libc だけの環境を仮定。その他のライブラリは無い。
  • TCP, IPv4 でクライアントを実装する。

注意点まとめ

  • あらゆるシステムコール/ライブラリ関数が失敗する可能性を考慮する。
    • 入門書や解説本ではエラー処理を省略している場合がある。
  • fdopen() を使ってソケットから FILE 構造体を作ってはいけない。
    • ライブラリのレベルでバッファリングされると困る。バグの元。
    • そういうコードを見たことがあるので……。
  • SIGPIPE に注意。
    • SIGPIPE のデフォルト動作はプロセスの終了。コマンドラインツールならその方が便利だが、ネットワーク通信では困る。
    • signal(SIGPIPE, SIG_IGN) で無視するなり、適切なハンドラーを書くなりして何とかする。
  • システムコールEINTR が返ってきたらリトライする。
  • ブロックするシステムコール/ライブラリ関数に注意する。
  • write() 等の書き込み系システムコールでは、渡したデータが全て書き込まれるとは限らない。(partial write と呼ばれる動作)
    • 1024 バイトのデータを渡しても 512 バイトしか書き込まれない、ということがありうる。必ず返り値(書き込んだサイズ)を確認して、適切にバッファを管理する。
    • 本当はファイルシステムへの書き込みでも同様なことは起こりうるが、滅多に起こらないので問題として認識されていない。TCP ソケットへの書き込みでは簡単に起こせる。

各論: gethostbyname() 等のタイムアウト

gethostbyname() 等のリゾルバ関数のタイムアウト時間を変更するには、resolv.h で定義されている リゾルバ構造体 _res の値を変更する。

res_state resp = &_res;
resp->retrans  = 1;  // DNS リクエストの再送間隔(単位: 秒). 1以上.
resp->retry    = 1;  // DNS リクエストの試行回数(単位: 回). 1以上.
  • _res のデータはスレッドごとにローカル。
  • _res の実体は(スレッドが使える環境では)関数呼び出しなので、何度も参照する場合はアドレスを一時的に保存して使う。
  • 問い合わせ先の DNS サーバが複数ある場合、タイムアウト時間が (retrans * retry) 秒にはならない。詳しい動作は glibc の resolv/res_send.c を参照。
  • gethostbyname() は reentrant ではないので、代わりに gethostbyname_r() を使うべき。
  • 名前解決を Non-blocking で行なう方法は、標準では用意されていないようだ。

各論: connect() のタイムアウト

connect()タイムアウトを制御するには Non-blocking I/O を使うと良い。基本方針は以下のようになる。

  • fcntl() を使ってソケットに O_NONBLOCK をセット。
  • connect() を実行。
  • select()poll() を使って、書き込み可能になるのを待つ。
    • ここでエラーが返ったりタイムアウトになった場合は適切に処理する。
  • ソケットが書き込み可能になったら、getsockopt() を使ってエラー情報を取得。
    • getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) を使う。man 7 socket を参照。

各論: read()/write() のタイムアウト

ブロックしても構わないなら、setsockopt()SO_RCVTIMEO, SO_SNDTIMEO を設定すればタイムアウト時間を設定できる。
ブロックされたくないなら Non-blocking I/O を使う。基本方針は以下のようになる。

  • select()poll() を使って読み出しや書き込みが可能になるまで待つ。
    • ここでエラーが返ったりタイムアウトになった場合は適切に処理する。
  • read()write() を実行する。
    • ここでブロックされることは無い。
    • read() は、受け取り済みのバッファリングされているデータを受け渡してくれる。
    • write() は、カーネルのバッファにリクエストを積む。
      • ここでデータの送信が確実に行なわれたかどうかを知る術は無い(と思う)。