課題の背景と適用範囲
あるLinux TCPサービスが、リスニングソケットと接続済みソケットの両方をノンブロッキングモードに設定し、epollで駆動しています。クライアントが接続の受信バッファに8 KiBのデータを送信しました。epoll_waitがEPOLLINを返した後、ハンドラはrecvを1回だけ呼び出し、4 KiBを消費してリターンします。
デフォルトのレベルトリガーモード(LT)では、次のepoll_waitでも多くの場合その接続が再度報告されます。しかし、EPOLLETを使用してエッジトリガーモード(ET)を有効にすると、同じ実装でそれ以降の読み取り可能通知が届かなくなり、クライアントが永久に応答待ちになることがあります。以下を説明してください。
epollの関心リスト(interest list)、準備完了リスト(ready list)、およびI/O準備完了性(readiness)の意味- LTとETの通知規約(contract)が異なる結果をもたらす理由
- ETにおいて
accept、recv、send、EAGAIN、ハーフクローズ、エラーをどのように処理すべきか - ワーカースレッド、
EPOLLONESHOT、ホット接続の公平性、およびFD再利用がもたらすリスク - 原因の特定と、再現性のある実験による修正の検証方法
8 KiBと4 KiBという値は面接用の前提条件であり、TCP送信、システムコール、アプリケーションメッセージの固定サイズではありません。中核となるスキルはLinuxのI/O準備完了性規約であり、バックエンド、インフラ、SRE、システム、一般的なソフトウェアエンジニアリング職に適用されるため、カテゴリはgeneralです。
面接官が見ているポイント
第一に、epollが報告するのは準備完了性(readiness)—特定のI/O操作がブロックせずに進められるかどうか—であり、1つの完全なリクエストが到着したことや非同期I/Oが完了したことではないと明確に述べられるか。TCPはバイトストリームであり、1回のrecv呼び出しはその時点で利用可能な任意の正のバイト数を返します。
第二に、スローガン的な理解にとどまらずLTとETの本質を理解しているか。LTは要求された準備完了条件が満たされている間、報告を続けます。ETは条件が真のままであっても再度の通知を保証しません。ETイベントの後、アプリケーションはノンブロッキングの読み取りまたは書き込みがEAGAINまたはEWOULDBLOCKを返すまで、そのFDを実行可能として扱う必要があります。「ETは1回しか通知しない」という表現は単純化しすぎており、正しい実装には結びつきません。
第三に、読み取り、書き込み、リスニングソケットに対して同じ規則を適用できるか。読み取りはEAGAINまでドレイン(全量読み取り)し、accept4をEAGAINまでループさせ、通常書き込み可能なソケットでEPOLLOUTを永続的に監視しないこと。アプリケーションに未送信のバッファデータがある間のみ監視します。
第四に、並行状態を管理できるか。EPOLLONESHOTは1回の通知後にFDを無効化します。ワーカーは状態更新を完了した後、EPOLL_CTL_MODで再アーム(rearm)しなければなりません。再アームが早すぎると2つのワーカーが1つの接続を同時に操作する可能性があり、再アームを忘れると接続が永久にスタックします。
第五に、「EAGAINまでのドレイン」とイベントループの公平性を両立できるか。常にトラフィックがある接続がスレッドを長時間占有する可能性があります。公平性を保つためにアプリケーションが処理を途中で切り上げる場合、ETが新たなエッジを発生させることを期待するのではなく、ユーザ空間の実行可能キューにその接続を保持しなければなりません。
回答前に確認すべき質問
- ソケットは実際にノンブロッキングになっているか? ET環境でブロッキングFDを使用すると、次の読み取りや書き込みで多数の接続を担当するスレッド全体がブロックされる可能性があります。
- スタックはaccept、読み取り、書き戻しのどこで発生しているか? acceptループのドレイン漏れ、入力の残留、
EPOLLOUTの更新漏れ、EPOLLONESHOTの再アーム忘れは、いずれも接続の停止として現れます。 - アプリケーションプロトコルはどのようにメッセージを区切っているか? TCPにはメッセージ境界がありません。長さプレフィックス、デリミタ、HTTPパーサーの状態、または接続クローズによってリクエストの完了が判断されます。
- ハンドラはどれだけ読み取り、いつリターンするか? 1イベントにつき固定長を1回だけ読み取ることが今回の直接のバグです。また、ドレインループ内で重いビジネスロジックを実行すると公平性の問題が生じます。
- 接続はスレッド間を移動できるか? 入力、出力、クローズ、
epoll_ctlの所有者を特定し、EPOLLONESHOTが有効になっているか確認します。 EPOLLOUTは常に購読されているか? ソケットはほとんどの時間書き込み可能です。LTで永続的に購読すると、epoll_waitが即座に戻り続け、CPUを高消費するビジーループに陥ります。EPOLLRDHUP、EPOLLHUP、EPOLLERRはどのように処理されているか? HUPが到着した時点でデータが残っている可能性があり、ERR/HUPは明示的な購読なしでも報告されます。- クローズされたFD番号はすぐに再利用される可能性があるか? 整数値のFDのみを接続の完全な識別子として扱うと、遅延したイベントやタスクが新しい接続に対して誤動作するリスクがあります。
30秒で答える要約
「epollは関心リストを保持し、準備完了リストからイベントを返します。これはI/Oの準備完了性を報告するものであり、完全なメッセージや非同期完了を意味しません。LTは条件が準備完了である限り報告を続けるため、4 KiBしか読み取らなくても残りの入力によって次のwaitで再びFDが返されます。ETは準備完了状態が変わらない場合に繰り返し通知することを保証しないため、部分的な読み取りのままでは二度と来ない通知を待ち続けて永久にスタックします。
ETのFDはすべてノンブロッキングにする必要があります。リスニングソケットはaccept4をEAGAINまで、読み取りはrecvをEAGAINまで、書き込みはsendをEAGAINまでドレインし、EPOLLOUTは出力バッファが空でない場合のみ監視します。recvが0の場合は対向ピアによる正常な書き込みハーフクローズを意味し、その他のエラーは別途処理します。複数ワーカー環境ではEPOLLONESHOTで接続の処理をシリアライズできますが、状態更新後にMODで再アームする必要があります。公平性のための制限は有効ですが、EAGAINの前に中断する場合はユーザ空間の準備完了キューが必要です。分割入力、部分的読み書き、ハーフクローズ、並行処理を用いて再現し、すべての接続がEAGAINに達し、正しく再アームされ、ビジーループしないことを検証します。」
ステップごとの詳細解説
ステップ1: epollの準備完了性モデルを整理する
epoll_create1はepollインスタンスを作成し、それ自体がFDによって参照されます。概念上、インスタンスは2つの状態セットを保持します。
- 関心リスト(interest list):
epoll_ctl(EPOLL_CTL_ADD/MOD/DEL)を通じて登録されたFDとイベントマスク - 準備完了リスト(ready list): 現在イベントが発生しており、
epoll_waitが結果として返す関心リストのエントリ
読み取り可能とは、待機することなくデータ、EOF、またはエラーを直ちに取得できることを意味します。書き込み可能とは、書き込みが少なくとも一部進行できることを意味し、レスポンス全体が収まることを保証するものではありません。アプリケーションは依然としてrecv、send、またはaccept4を呼び出し、その戻り値を解釈します。イベントはアクションの契機であり、システムコールの結果がステートマシンを駆動します。
ステップ2: 未読の4 KiBを例にLTとETを比較する
デフォルトのLTはpollのように動作します。受信バッファにデータが残っている限り、読み取り準備完了状態が維持され、次のepoll_waitでもそのFDが再び返されます。そのため、「1イベントにつき1回読み取る」という誤った実装でも、ウェイクアップとシステムコールの回数が増加するものの、一見動作しているように見えます。
EPOLLETを使用すると、カーネルは準備完了状態の「変化(エッジ)」を報告します。ハンドラが4 KiBを消費した後もさらに4 KiBが残っており、FDは依然として読み取り可能なままです。つまり、アプリケーションは「データがもうない」状態まで進めていません。ETはそのような変化のない状態に対して再度の報告を保証しないため、新しいイベントを待つと無限にブロックされる可能性があります。
確実なルールは、ETによって返されたFDを準備完了として扱い、EAGAINまたはEWOULDBLOCKが返るまでノンブロッキングI/Oを実行し続けることです。これらの値は、これ以上ブロックせずに進められる操作がないことを示します。この段階になって初めて、アプリケーションは通知の責任をepollに戻します。
ステップ3: 読み取り処理を明示的なステートマシンにする
以下のC言語風のスケッチは、アプリケーション固有のパーサー、ライフタイム、ロギングの詳細を省略しています。
void drain_read(Connection *conn) {
unsigned char buf[4096];
for (;;) {
ssize_t n = recv(conn->fd, buf, sizeof buf, 0);
if (n > 0) {
append_and_parse(conn, buf, (size_t)n);
continue;
}
if (n == 0) {
conn->peer_write_closed = true;
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
close_with_error(conn, errno);
return;
}
if (conn->peer_write_closed && output_is_empty(conn)) {
close_connection(conn);
}
}n > 0はそれらのバイトが到着したことのみを示しており、パーサーはまだ完全なリクエストを構成できていない可能性があります。n == 0は、バッファリング済みのデータを消費した後の、ストリームソケット上での対向ピアによる正常なクローズです。EINTRはリトライ可能であり、EAGAIN/EWOULDBLOCKはこのドレイン処理の完了を示し、その他のエラーはクローズ処理に進みます。ショートリード(要求サイズ未満の読み取り)は、メッセージの完了やソケットが空であることを証明するものではありません。
リスニングソケットも同じパターンに従います。読み取り可能イベントが発生したら、accept4をループさせ、新しい各接続にアトミックにSOCK_NONBLOCK | SOCK_CLOEXECを適用しながら、EAGAINになるまで処理します。1回のイベントで1接続のみをacceptすると、すでにキューにある他の接続がET環境で取り残され、以降の通知が保証されなくなります。
ステップ4: 出力バッファリングとEPOLLOUTの制御
バッファリングされた出力は直ちに送信を試みます。部分的なsendの場合はオフセットを進めて継続します。EINTRの場合はリトライします。EAGAIN/EWOULDBLOCKの場合は残りのバイトを保持し、EPOLLOUTを介してEPOLL_CTL_MODを追加します。次の書き込み可能イベントでドレインを継続します。出力バッファが空になったら、速やかに関心マスクからEPOLLOUTを削除します。
EPOLLOUTを永続的に購読すると、別の障害モードが発生します。ソケットは長時間書き込み可能であることが多いため、LTでは即座に戻り続け、ビジネス処理が進まないままCPU使用率が上昇します。ETを使用しても、アプリケーションの出力バッファ、バックプレッシャー、最大バッファ制限、低速クライアント対策が不要になるわけではありません。変わるのは通知の挙動のみです。
ステップ5: ハーフクローズ、HUP、ERR、およびクローズ順序の処理
EPOLLRDHUPは、ストリームの対向ピアが接続またはその書き込み側をクローズしたことを示します。EPOLLHUPはピアがチャネルの自側をクローズしたことを示しますが、未読データが残っている可能性があり、直ちにクローズするとデータが破棄される恐れがあります。EPOLLERRとEPOLLHUPは、明示的に要求していなくても報告されます。ERR発生時、アプリケーションはプロトコルポリシーに従って記録およびクローズする前に、getsockopt(SO_ERROR)で保留中のソケットエラーを取得できます。
クローズ処理では、まず新しい作業の割り当てを停止し、非同期タスクが無効になった識別子を保持し続けないようにする必要があります。基盤となるオープンファイル記述(open file description)を参照する最後のFDをクローズすると、カーネルはその登録を削除します。dupやforkでその記述が共有されている場合、1つのFDをクローズしても関連イベントが直ちに消滅するとは限らないため、所有権プロトコル側で明示的にエントリをDELするか、すべての参照をクローズする必要があります。ライフタイムが管理された接続オブジェクトと世代(generation)またはトークンを使用し、整数値のFDが急速に再利用されても古い処理が新しい接続に向けられないようにします。
ステップ6: ワーカーの所有権管理にEPOLLONESHOTを活用する
複数のスレッドが1つのepollインスタンスを待機することがあります。ETのFDの場合、カーネルは通常FDが準備完了になった際に1つの待機スレッドを起こしますが、それだけでは処理全体を通じて接続の単一所有権が保証されるわけではありません。キューイングされたタスクや後続のイベントによって、並行アクセスが発生する可能性があります。
EPOLLONESHOTは、1回イベントが配信されるとFDを無効化します。I/Oをドレインし、プロトコル状態と関心マスクを更新した後、接続を維持するワーカーはepoll_ctl(EPOLL_CTL_MOD)を呼び出して再アームします。再アームはハンドオフの最終ステップでなければなりません。MODを忘れると接続がスタックし、早すぎると古い所有者が終了する前に新しいワーカーが進入してしまいます。
ステップ7: ETの正確性と公平性の両立
EAGAINまでドレインし続けると、常にトラフィックがある接続がスレッドを占有し、他の接続を遅延させる可能性があります。ループには接続ごとのバイト数、メッセージ数、または時間の制限(バジェット)を設定できます。そのバジェットがEAGAINの前に切れた場合、単にリターンしてカーネルを待つだけではいけません。接続をユーザ空間の準備完了キューで実行可能としてマークし、後でEAGAINに達するまで処理を再開する必要があります。
このキューは重複登録を防ぐ必要があります。接続のクローズ、ワーカーの移譲、EPOLLONESHOTの再アームは、同一の所有権プロトコルを共有しなければなりません。これにより、特定のホットなFDが他のFDを枯渇させることなく、ETの規約を維持できます。
ステップ8: 失われたエッジを検出する検証の構築
通常の単一リクエストのテストだけでは不十分です。少なくとも以下を網羅します。
- クライアントからの8 KiB送信に対し、サーバーが1回の呼び出しで意図的に最大4 KiBしか読み取らないテスト。修正前のETでのスタックと、修正後の
EAGAINまでのドレインを確認。 - 任意の境界で一時停止を挟みつつ複数の送信に分割されたアプリケーションメッセージ。パース処理が1回の
recvのサイズに依存しないことを証明。 - 部分的な書き込みと
EAGAINを発生させるサーバー送信制限テスト。バイト落ちがなく、ドレイン後にEPOLLOUTが削除されることを証明。 - ピアのハーフクローズ、未読データがある状態でのHUP、接続リセット、および
EINTR。 - 複数ワーカーでの繰り返しの
EPOLLONESHOT配信。アクティブなすべての接続に対して所有者が厳密に1人であり、正しく再アームされることを証明。 - 連続送信を行う1つのホット接続と多数の低速接続を混在させ、公平性、CPU、イベントループ遅延、準備完了キューの長さを観測。
- 高速な接続の作成とクローズ。遅延した処理が再利用されたFD番号を誤って対象にしないことを証明。
各recv/send/accept4パスの終了理由、EAGAINの回数、関心マスクの変更、oneshotの再アーム、アプリケーションの準備完了キュー、接続ごとのバジェット、イベントループ遅延、進捗のない接続を監視します。バイトおよびプロトコルの状態が正確で、永久スタックがなく、無駄なビジーループがなく、並行アクセスの競合がなく、低速接続の長時間の飢餓状態がないことが確認できれば合格です。
評価の高い模範解答
「epollインスタンスは関心リストを管理し、epoll_waitはその準備完了リストからイベントを返します。これはI/Oの準備完了性を通知するものであり、TCPメッセージの完了や非同期処理の完了を意味するものではありません。接続のステートマシンを駆動するのはシステムコールの戻り値です。
LTで問題が表面化しなかったのは、4 KiBを消費した後も4 KiBが残っており、読み取り準備完了状態が維持されていたため、次のwaitで再びFDが報告されたからです。一方、EPOLLETでは、FDが非準備完了状態に戻りませんでした。ETは真のままである条件に対して通知を繰り返すことを保証しないため、1回読み取っただけで待機に入ると永久にスタックします。
私ならリスニングソケットと接続ソケットの両方をノンブロッキングにします。acceptパスはaccept4をEAGAINまでループさせます。readパスはrecvをEAGAINまでループさせ、正のバイト数をインクリメンタルパーサーに渡し、0はピアの書き込みハーフクローズとして扱い、EINTRはリトライし、その他のエラーではクローズします。出力は直ちにsendを試行し、部分的な書き込み後はオフセットを保持します。未送信データが残った状態でEPOLLOUTになった場合のみEAGAINを監視し、ドレイン後に解除します。HUPはクローズ前にドレインし、ERRはSO_ERRORで診断します。
複数ワーカー環境では接続ごとに1つの所有者を割り当て、EPOLLONESHOTを使用します。ワーカーはI/Oドレイン、状態更新、マスク計算が完了した後にのみMODで再アームします。公平性のバジェットによりEAGAINの前に処理を停止する場合は、存在しないエッジを待つのではなく、重複排除されたユーザ空間の準備完了キューに接続をエンキューします。接続識別子にはライフタイムや世代情報を持たせ、FDの再利用を安全に処理します。
8 KiB送信と4 KiB読み取り制限による再現実験を行い、さらに部分書き込み、ハーフクローズ、リセット、oneshot、複数ワーカー、ホット接続の負荷を追加します。ETの各処理パスがEAGAINまたは明示的クローズに達し、すべての出力が送信され、EPOLLOUTが空回りせず、すべてのアクティブなoneshot接続が再アームされ、スタックや飢餓状態になる接続がないことをもって修正完了と判定します。」
よくある間違い
- 準備完了性を完全なメッセージとみなす → TCPはバイトストリームであり、1回の
recvはアプリケーションメッセージと一致しない → インクリメンタルパースと独立した入力バッファを使用する。 - ETを「常に1回しか通知しない」と単純化する → 複数の状態変化により複数イベントが発生することがある。保証されないのは『準備完了のまま変化がない状態での繰り返し通知』である →
EAGAINまで処理する。 - ET下で1回しか読み取らない → バッファに入力が残ったまま新たなエッジが発生しない →
recvをEAGAIN/EWOULDBLOCKまでループさせる。 - ETとブロッキングソケットを併用する → ドレインループがブロックし、イベントループ全体が停止する → 登録前にノンブロッキングモードを設定する。
- リスナーイベントごとに1接続のみacceptする → すでにキューにある接続への後続通知が届かない可能性がある →
accept4をEAGAINまでループさせる。 - 常に
EPOLLOUTを購読する → 通常書き込み可能なソケットによりwaitが即座に戻り続ける → 未送信出力がある場合のみ購読し、ドレイン後に解除する。 - HUPで直ちにクローズする → 未読データが残っている可能性がある → 読み取りステートマシンでドレインした後、EOFと出力状態に応じてクローズする。
EPOLLONESHOTの再アームを忘れる → FDが関心リストで無効化されたままになる → 状態更新後にEPOLL_CTL_MODを使用する。- 公平性のために中断したあと次のETイベントを待つ → FDが準備完了のままで新たなエッジが発生しない可能性がある → ユーザ空間の準備完了キューから再開する。
- 整数値のFDのみを識別子として使用する → クローズ後に新しい接続が同じ番号を再利用する可能性がある → 管理されたライフタイムと世代情報を持つ識別子を使用する。
- ETが本質的に常に高速であると主張する → 結果はアクティブ接続の割合、システムコール、アプリの処理内容、実装の正確さに依存する → 代表的な負荷下でCPU、レイテンシ、スループット、公平性を測定する。
想定される追加質問と回答例
追加質問1: なぜ短いrecvがソケットのドレイン完了を証明しないのか?
recvは通常、要求された長さのうちその時点で利用可能なものを返します。ネットワークのパケット分割、スケジューリング、送信タイミングによって、後続バイトがすぐに届く状況でもショートリードが発生します。ETのドレイン境界はノンブロッキングのEAGAIN/EWOULDBLOCKであり、アプリケーションメッセージの境界はプロトコルパーサーによって決まります。これらは全く異なる境界です。
追加質問2: ETは常にLTより高速か?
モード単体では判断できません。ETは準備完了状態が続くFDに対する重複通知を減らせますが、ドレイン処理、ユーザ空間キュー、状態管理の複雑さが増します。接続の大部分がアイドル状態である場合や、アプリケーション処理が支配的な場合、あるいは実装によりepoll_ctl呼び出しが多数追加される場合、性能向上はわずかかもしれません。正確性を第一とし、目標とする接続数とアクティビティ分布において、CPU、システムコール数、スループット、p99レイテンシ、公平性を測定して判断します。
追加質問3: なぜ出力バッファがドレインされた後にEPOLLOUTを削除するのか?
書き込み可能とは通常、カーネル送信バッファに空きがあることを意味し、多くの接続でこの状態が継続します。アプリケーションに送信待ちデータがない状態で監視すると無駄な通知が発生し、LTではCPUビジーループを引き起こします。データが残っている間のみ書き込みを監視し、後から新しいデータが発生した場合は、再購読する前にまずsendを即座に試みるべきです。
追加質問4: EPOLLONESHOTとETは同じ機能か?
異なります。EPOLLETは準備完了通知の動作を変更します。EPOLLONESHOTは、1回イベントを配信した後にアプリケーションがEPOLL_CTL_MODで再アームするまでFDを無効化します。これらは併用可能です。ONESHOTはワーカー間の所有権移譲に役立ちますが、ETにおける適切なドレイン処理と、再アームプロトコルが依然として必要です。
追加質問5: 公平性のバジェットがEAGAINの前に切れた場合、イベントの喪失をどう防ぐか?
接続をユーザ空間で実行可能状態のままとしてマークし、重複のない準備完了キューに入れます。スケジューラはrecv/sendを呼び出し、EAGAIN、クローズ、または次のバジェット切れまで処理を再開します。キューに入っている間も有効な所有権を維持します。ONESHOTを使用している場合、ユーザ空間の作業が完了し、再びカーネル通知が必要になった段階でのみ再アームします。
追加質問6: FDをクローズした後、なぜ古いイベントを気にする必要があるのか?
イベントのバッチがすでにユーザ空間に読み出されている可能性があり、非同期ワーカーが接続の参照を保持している間に、カーネルが同じ整数FDを新しいソケットに即座に割り当てる可能性があります。また、複数のFDが同一のオープンファイル記述を参照している場合もあります。クローズ処理では、新規ディスパッチの停止、オブジェクトライフタイムの管理、古いトークンの無効化を行う必要があります。整数値のFDを比較するだけでは、イベントが現在の接続に属しているかを証明できません。