epollのエッジトリガー、読み残したデータがあっても待ち続ける理由

紫色の曲がった樋に緑の小石が四つ残り、手前の白い台に二つが移り、桃色の旗が一本立つ抽象的な構図。

接続をたくさん扱うサーバーでは、どの接続からデータを読めるかを調べる処理が必要です。Linuxのepollは、その入口になる通知機構です。ただし「読めるという通知を受け取った」と「届いたデータを読み終えた」は別の出来事です。特にエッジトリガー方式では、少しだけ読んですぐ通知待ちに戻ると、読み残しがあるのに処理が止まる場合があります。

簡単にまとめると?

  • レベルトリガーは読み出せる状態が続いていることを扱い、エッジトリガーは状態の変化に着目します。通知の回数を、受信したメッセージ数と同じには扱えません。
  • EPOLLETでは、非ブロッキングの読み取りを行い、EAGAINになるまで処理するのが基本です。読み残しを自分の処理待ち一覧で管理する設計も必要になります。
  • EAGAINは「今は進められない」という区切りです。通信の完了、相手の切断、データの意味上の完成とは区別します。

epollが返すのは、データそのものではない

ファイルディスクリプターは、プロセスがソケットやパイプなどを参照するための番号です。epollには、監視対象を登録する一覧と、入出力を進められる対象の一覧があると考えられます。epoll_waitは後者からイベントを受け取るための呼び出しです。届いた本文やパケットの内容は、その後にreadやrecvなどで読みます。

ここでいう「読める」は、アプリケーションが欲しい情報がすべてそろった、という意味ではありません。TCP上で長さ付きの要求を受け取るなら、先頭の長さを読めても、残りの本文はまだ届いていないかもしれません。epollで通知を受け取る段階と、受信したバイト列を解釈する段階を分ける必要があります。

二つの通知方式で、読み残しの扱いが変わる

EPOLLETを指定しない通常の方式が、レベルトリガーです。これは、対象が読み出せる状態にあるかどうかを扱います。データを一部だけ読んで残りがある場合、引き続き読み出せる対象として扱われます。

EPOLLETを指定したエッジトリガーでは、通知を消費した後の読み残しに対して、もう一度同じ通知が来ると期待してはいけません。たとえば、パイプに一度だけ6バイトが入り、最初の通知で2バイトだけ読む場合を考えます。残りは4バイトですが、新しい書き込みもほかの変化もなければ、次の通知を待ったままになる可能性があります。データが消えたのではなく、処理を再開する契機を取り違えた状態です。

公式マニュアルには、パイプへ2 kBを書き込み、通知後に1 kBだけ読む例があります。下の図はその図版を転載したものではなく、同じ注意点を6バイトの独自モデルで表したものです。実際のLinuxカーネルで通知回数を測った結果ではありません。

一度に6バイトが届いた説明用モデル。2バイト読むと4バイト残る。そこで次のエッジ通知を待つと再開の保証がない。非ブロッキングでさらに2バイトずつ読み、最後にEAGAINで通知待ちへ戻る。
一度の到着に対し、複数回の読み取りが必要な例。数値は独自の説明用モデルで、実測値ではない。

非ブロッキングとEAGAINを組にする

ブロッキングのreadは、入力がなければそこで待つことがあります。一つのスレッドが複数の接続を順番に処理するとき、その待ちがほかの接続の処理まで止めてしまいます。非ブロッキングなら、今すぐ読めない状況をEAGAINとして受け取り、次の仕事へ移れます。ソケットではEWOULDBLOCKも同じ状況を表す場合があるため、戻り値の確認では両方を考慮します。

上のモデルで2バイトずつ読めば、ABCDEFはAB、CD、EFの3回に分かれます。それでも成功した3回だけでは、「今ある入力を取り切った」という判定を省けません。書き手が開いたままで新しい入力がない条件では、次の非ブロッキング読み取りがEAGAINとなり、そこで通知待ちへ戻る区切りが得られます。readが要求した数より少ないバイト数を返すこと自体は、エラーではありません。

この例のEAGAINを、EOFと置き換えることはできません。ストリームやパイプでreadが0を返す終端の扱い、EINTRなどのエラーへの対処、アプリケーションの要求が完成したかの判定は、別々に実装します。UDPでは長さ0のデータグラムもあるため、ストリーム向けの終端判定をそのまま持ち込まないことも大切です。

読み切る方針にも、公平さの設計が要る

一つの接続に大量のデータが届き続けると、その接続だけを処理してほかの接続が待たされることがあります。マニュアルも、この飢餓状態への対処として、アプリケーション側に処理可能な対象の一覧を持ち、順番に進める方法を挙げています。

一度の処理量を制限する場合は、「まだ読み残しがある」という状態をその一覧に残します。EAGAINまで進んでいない対象を忘れ、カーネルから次のエッジ通知が来ることだけに再開を任せる設計は避けます。読み取り途中の受信データや解析位置も、次回へ引き継ぎます。

EPOLLONESHOTは、イベントを一度受け取ると対象を無効にし、再度有効にする操作を必要とする別の指定です。エッジトリガーなら自動で一回限りになる、と理解しないようにします。複数スレッドでの所有権、再登録、切断処理まで含む場合は、今回の単純なモデルより多くの状態管理が必要です。

方式を選ぶ前に、待ちへ戻る条件を決める

実装を比較するときは、通知方式の名前だけで高速さを決めず、処理をどこまで進め、何を記録してから待つかを確認します。レベルトリガーは読み残しの状態を扱いやすい一方、エッジトリガーは非ブロッキング処理と処理待ち状態の管理を組にして考えます。どちらでも、イベント通知は要求の意味上の完了を証明しません。

この記事はLinux man-pages 6.19のepollとreadの仕様説明を扱っています。独自モデルは読み残しの算術と制御の区切りを確認するもので、負荷、カーネル版、接続数ごとの性能比較や、本番サーバーの完全な実装を示すものではありません。

出典