read(2)を呼んでもブロックさせない方法その他の手法

27 Jan, 2001
ksw

この文書の目的

UNIXでクライアント/サーバ形式のプログラムを作る際、 ネットワークの先にあるサーバからすぐに応答が得られない場合があります。

バッチ的な処理であれば応答時間は問題にならない場合もありますが、 これは特殊な例でしょう。 普通は、応答を待っている旨を表示させたり (webブラウザで「xxxバイト受信しました」と表示するものが典型的な例です)、 タイムアウトさせてエラー処理させたりします。 ところが、素直にread()システムコールを使うだけでは、 応答が来るまで待ち続けてしまい、プログラムの実行が進まなくなってしまいます。

この文書では、そのような「読み出し操作のためにプログラムを止まらせない」方法について説明します。 状況としてはソケットからの受信を想定していますが、 read()の実行に時間がかかる場合なら同様に適用可能です。 これには、例えばキーボード・RS-232C・パイプなどからの読み込みが該当します。

サーバの準備

まず、サンプルを動かすためのえせサーバを立ち上げます。 付属のlazy-daemonに実行属性が 立っていることを確認したら、/etc/servicesに
lazy-daemon		9200/tcp
/etc/inetd.confに
lazy-daemon	stream	tcp	nowait	nobody	/FULL/PATH/TO/lazy-daemon	lazy-daemon
という行を追加してください。 ただし、/FULL/PATH/TO/lazy-daemonは実際にファイルが置いてあるパスに置き換えてください。

このあとinetdにSIGHUPを送り、「telnet localhost 9200」を実行して 間欠的に文字が表示されることを確認してください。

Makefileの準備

特に解説しません。

特に何もしない場合

normal_read.c
特に何も考えずにread()するサンプルです。

サーバ側で出力処理が中断してしまうと、 このプログラムもread()を呼んだままのところで実行が止まってしまいます。


アラームシグナルを使う方法

alarmed_read.c
タイムアウト付きでread()するサンプルです。 受信動作が滞っても無限に応答を待つことなく、 一定時間後にread()が中断します。

これは、次の2つの性質を利用しています。

このサンプルの場合では、read()システムコールが中断されるわけです。 中断された時のread()の返り値は、それまでに読み込んだバイト数です。 1バイトも読み込んでいない場合は、 "interrupted system call(システムコールが割り込まれた)"と いうエラーを表す-1を返し、変数errnoにEINTRを設定します。 0ではなく-1なのは、ファイル終端と区別できないためです。 もちろん、他のエラーによってもエラー終了する可能性があるので、 システムコール中断であることを区別する際は 返り値が-1かつerrno==EINTRであることで判定します。

サンプルではread()が割り込まれると読み込みループを抜けていますが、 再度read()を呼び出しても構いません。 なお、read()の後にalarm(0)しているのは、 指定時間内に全部読み終わってしまった場合や、 指定時間内に読み込みエラーが起きた場合に余分なSIGALRMが 届くことを防止するためです。

alarm()は、一般にsleep()やsetitimer()とは一緒に使えない点に注意してください。


ポーリングを行う方法 - select編

selected_read.c
データが読み出せないときは、受信待ちのアニメーションをさせるようにしたサンプルです。

アラームシグナルを使ったサンプルは、 確かに永久に待ちつづけるということはありませんが、 read()のところで実行が止まってしまうことには変わりありません。 このように、システムコールの呼び出しによって ユーザプログラムの実行が止まってしまうことを 一般に「read()によってブロックされる」と表現します。

受信待ち中も別の処理をさせたい場合もあるでしょう。それには

  1. ポーリングして、ファイル記述子が読み出し可能かどうか調べる
  2. 非ブロッキングモードを利用する
の2種類の手法を利用することができます。 これらの手法は排他的なものではなく、必要に応じて組み合わせて使います。

まずは最初のポーリングという手法を紹介します。 ポーリングとは、ある事象が起こっているかどうかを 対象の状態を変更することなく調査することを指します。

UNIXシステムコールプログラミングでは select()またはpoll()を利用してファイル記述子の状態をポーリングすることができます。 その結果によってユーザに渡すことのできるデータがすでにOS内にあるかどうか、 すなわちread()を呼んでもブロックしないかを判断することができます。

移植性メモ: select()やpoll()は本物のシステムコールである場合もありますし、 Cのライブラリ関数である場合もあります。 現在ではおそらくselect()が使えないことはないと思いますが、 poll()が利用できないシステムもまだ多少あるかもしれません。

最初に、select()の典型的な使い方を挙げます。

sockutil.cのcheck_readable()関数

この関数はあるファイル記述子が読み込み可能であるか、待ち時間なしで調べています。 この例にあるように、select()を使うには以下の手順を踏みます。

  1. 調査するファイル記述子のうち、最大のものを探します。
  2. select()は調べるファイル記述子のどれもが読み込み可能でない場合、 ブロックします。このときのタイムアウト値をtimeval構造体にセットします。 timeval構造体についてはman gettimeofdayを見てください。
  3. 調査するファイル記述子のリストを、FD_ZEROとFD_SETで作成します。
  4. select()を呼びます。割り込まれた場合はやり直しをします。
  5. FD_ISSETを用いて個々のファイル記述子の状態を調べます。

select()を使う際は、EOFの扱いに注意する必要があります。 select()が「例外状態」の調査もできることを知っていると、 EOFが「例外状態」の一つであると思ってしまうかもしれませんが、そうではありません。 EOFに達している時、select()は「読み込み可能」であると報告します。 そして、実際に読み込みを行うとread()は0を返します。


ポーリングを行う方法 - poll編

polled_read.c

じつはこのpollを使うサンプルは、 さきほどのselected_read.cとほとんど同じです。 唯一の違いは、sockutil.cの poll_readable()を(check_readable()の代わりに)呼び出している点だけです。

poll()はselect()に比べてより詳細な調査を行うことができます。 調べたいファイル記述子はpollfd構造体の配列で渡します。

poll()ではEOF到達状態は「ハングアップ状態」として扱われ、 「入力可能状態」と区別して調査することができます。 ただし先に挙げたpoll_readable()は、 check_readable()との互換性のために意図的に区別しないで扱っています。

また、select()とpoll()に共通する注意点ですが、 これらによって調べられるのは「1byte以上読めるかEOFか」であり、 ユーザが希望するデータ数を直ちに読み込めるかはまた別の問題になります。 ブロックすると困る場合は、 次に紹介する非ブロッキングモードを設定する必要があります。


非ブロッキングモード

nonblock_read.c
データの読み出し状況にかかわらず、read()実行ごとにアニメーションをさせるようにしたサンプルです。

普通にファイル記述子をオープンすると、 そのファイル記述子は「ブロッキングモード」になります。 ブロッキングモードでは、ユーザが要求した入出力操作が直ちに完了できない場合、 そのwrite()やread()を呼び出したまま制御が戻ってきません。

そこで、ファイル記述子を「非ブロッキングモード」に設定すると、 どんな場合でも入出力操作を直ちに終了させることができます。 読み出し操作なら、ユーザ側に渡せるデータをOSが用意出来ていなくても、 即座にread()の呼び出しが終了し、制御が戻ってきます。

このとき、データが多少でもあった場合は、返り値はそのバイト数になります。 1バイトもデータがそろっていなかった場合は返り値は-1となり、 変数errnoにはEAGAIN(try again)が設定されます。 EAGAINは古いシステムではEWOULDBLOCK(operation would block)と呼ばれていました。

非ブロッキングモードを設定するには、

  1. open時にフラグとして指定する
  2. オープンしたあとからfcntl()によって設定する
の方法があります。open時に指定する場合は、
	fd = open(path, O_RDONLY | O_NONBLOCK);
	if(fd < 0) ...
のようにオープンフラグとして指定します。 後者の方法についてはsockutil.cの set_nonblocking_mode()関数を見てください。

複数のファイル記述子の状態を調べる

check_readable()関数やpoll_readable()関数では 単一のファイル記述子の調査しか行っていません。 しかし、本来select()やpoll()には、 状態変化を待つ機能や、 複数のファイル記述子状態を調べる機能があります。

ここでは、複数のファイル記述子を調査する方法を示します (もちろん、必要なファイル記述子ごとにcheck_readable()を 毎回呼んで調べても構わないのですが、当然パフォーマンスは落ちます)。 多数のクライアントからのリクエストを待つサーバプログラムで 利用できるかと思います。

plural_watch.c

このサンプルでは、オープンするファイル記述子ごとにwatcher構造体を設け、 最後にファイル記述子が読み込み可能だった時刻を保存しています。 ファイル記述子状態の調査はupdate_active_time()の中で行っています。 このような処理をしておくと、タイムアウトしたかどうかを 「現在の時刻 - 最後に読み込み可能になった時刻」が一定値を越えたかで判断することができます。実行の様子は

 % mkfifo /tmp/pipe0
 % mkfifo /tmp/pipe1
 % ./plural_watch /tmp/pipe0 /tmp/pipe1
 % kterm& kterm&

(ktermで開いたプロンプトで)
 % cat > /tmp/pipe0

(ktermで開いたプロンプトで)
 % cat > /tmp/pipe1
としておき、それぞれのcatに対してキーボードから なにかを打ち込んでやることで確認できます。

I/Oの非同期通知

これまでに述べて来た手法は、 いずれも読み出しが可能であるかを都度調査しています。 短い期間にすべてのデータの転送が終わることが期待できる場合は これでもかまわないのですが、 用途によっては受信データが届かない場合は ただ寝ていたい場合もあります。

例えば、RS-232Cでなんらかの警報装置を接続するような場合を考えてみます。 数秒おきにselect()やpoll()でデータの有無を検査する方法では、 プロセスは間断なくスリープとselect()を繰り返すだけです。 これではページアウトされることもないので、 たいしたこともしないままメモリ上に常駐するかたちになります。 おまけに、データ送信からプロセスが反応するまでのレスポンスも、 ポーリングの間隔まで悪化する可能性があります。

このような場合、I/Oの非同期通知を利用すると、 ファイル記述子の状態が変化するときにシグナルを受け取ることができます。 シグナルを受け取るまでプロセスはひたすら寝てればよいので、 システム負荷を減少させることができます。 また、反応速度の点でもポーリング方式よりすぐれています。

非同期通知を設定する例を以下のサンプル、 set_pollable()関数に示します。 このコードは、少なくとも私が手元で確認できる Linux(カーネル2.2.16)とSolaris(2.6/SPARC)では動作します。 …とわざわざ断りをつけるように、非同期通知機能の設定はOSへの依存性があります。

sigio_driven.c

Linuxの場合(POSIX一般?):

  1. SIGIOに対するシグナルハンドラを設定する。
  2. オープンしたファイルにfcntl()を使ってO_ASYNCフラグを設定する。 またはO_ASYNCフラグをつけてオープンする。
  3. fnctl(ファイル記述子, F_SETOWN, プロセスID)を実行する。

O_ASYNCフラグによってファイル記述子の状態変化が SIGIOシグナルによって非同期に通知されるようになります。 また、O_ASYNCフラグは他にもTCP/IPのソケットでOOBデータを 受信したときにSIGURGシグナルを発生させる効果もあります。

ただ、これだけでどのプロセスにシグナルを送ればよいのか わからないため、F_SETOWNでファイル記述子の所有プロセスを登録します。 SIGIOシグナルはそのファイル記述子を所有するプロセス、 またはプロセスグループに対して配送されます。

Solarisの場合(SVR一般?)

  1. SIGPOLLに対するシグナルハンドラを設定する。
  2. ioctl(fd, I_SETSIG, S_MSG | S_RDNORM)を実行する。

Solarisではポーリング可能事象発生という意味で、 SIGIOではなくSIGPOLLという名称が使われています。 ただし実際にはSIGIOとSIGPOLLには同じ値が定義されているので、 サンプルでは特にわざわざ使い分けてはいません。

非同期通知の手配の方法は、STREAMSインターフェースを利用したものになっています。 ioctlの第3引数はどんな事象に対してSIGPOLLシグナルを発生するかを ビット和で指定します。おそらく S_MSG(EOFなどのSTREAMシグナル), S_RDNORM(通常データの読み込み可能), S_WRNORM(通常データの書き込みキューのフル解除) あたりを指定することになるでしょう。

設定方法に多少の違いはありますが、注意する点は同じです:


その他

write()による書き込みが可能かどうかも select()によって調査することができます。 ただし、ソケットへの書き込みについては必ずしも信用できません (相手ホストがダウンしていても、「送信可能」であるかのように報告されます)。 これは、たとえ自ホストからパケットが出て行くことが可能でも、 途中の経路によってはパケットが消失する可能性があるため、 厳密な意味では「もともとわからない」ためです。 少なくとも再送バッファがあふれるまでは送信可能として報告されることになっているようです。

I/Oの非同期通知とは、「I/Oの状態変化通知」が非同期に行われることであって、 「非同期に行われるI/O」の通知ではありません。