epoll とイベント駆動

このページの目次

epoll は関心のあるファイル記述子(fd)と準備完了イベントを管理し、プログラムが複数の I/O ソースに対して集中的に待機できるようにします。epoll が返すのは「今、読み書きを試みることができる」というヒントであり、アプリケーションのために読み書きを完了するものではなく、1 つのイベントが 1 つのビジネスメッセージに相当することも保証されません。

なぜ多くのアイドル接続に適しているか

select/poll は毎ラウンド、記述子の集合または配列を提出し、それらを検索します。一方、epoll は関心集合を保持し、基盤となる準備完了通知を通じて候補リストを維持するため、多くのアイドル記述子の重複処理を大幅に削減できます。

ただし、すべての epoll_wait が O(1) であるわけではありません。k 個のイベントを返すには、それに応じた処理とコピーのコストがかかります。登録・変更、コールバック、ロック競合、および多数のアクティブな接続にもコストがかかります。poll や epoll の記述子数はリソース制限の影響を受けるため、無制限に扱うことはできません。glibc の select の fd_set には、固定の FD_SETSIZE 制限もあります。

3つの操作

int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = client_fd };
int rc = epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

これは <sys/epoll.h> が必要な API の断片であり、epfd と rc のチェックが必要です。ADD は登録、MOD は関心とユーザーデータの修正、DEL は削除を行います。epoll_wait の正の戻り値はこのバッチのイベント数、0 はタイムアウト、-1 はエラーです。EINTR は接続がない、またはすべての接続が閉じたものと誤認してはいけません。

.data はアプリケーションが保存し、そのまま返される関連情報であり、fd、ポインタ、または世代番号付きの ID として使用できます。カーネルはアプリケーションのためにこのポインタオブジェクトのライフサイクルを管理しません。

イベントビットは排他的列挙ではない

イベントまたはフラグ意味と処理
EPOLLIN読み込みを試みる;リスニングソケットの場合は accept を試みる
EPOLLOUT書き込みを試みる。任意の長さのデータを一度に送信できることを意味しない
EPOLLERRエラー。SO_ERROR などで照会可能。明示的な購読がなくても受信される
EPOLLHUPハングアップ。バッファにデータが残っている可能性があるため、読み込みが必要
EPOLLRDHUPストリームソケットの相手側が書き込み方向をクローズ。Linux 2.6.17 以降で提供
EPOLLETエッジトリガーモードに切り替える
EPOLLONESHOTイベント配信後、その登録の後の通知を無効化。MOD でリセットが必要
EPOLLEXCLUSIVE特定の登録シナリオで複数の epoll インスタンスのウェイクアップを減らす。汎用ロックではない

IN、HUP、ERR などは同時に発生し得ます。ONESHOT は DEL を自動実行するものではなく、fd をクローズするものでもありません。epoll_ctl(2)

LT と ET:いつ再び待機できるか

LT はデフォルトモードであり、条件がまだ準備完了であれば継続して報告します。アプリケーションが一部を処理して待機に戻っても、データを消費し続けたり、書き込み可能状態を継続して購読したりすると、ループは継続してウェイクアップされます。

ET は未消費の準備完了状態が繰り返し通知されることを保証しません。一般的な做法はノンブロッキング I/O を使用し、EAGAIN が返されるまで処理を行い、その後新しい通知を信頼することです。ET をすべての fd に対して厳密に 2 値の状態遷移のみを報告すると単純化しないでください。具体的なウェイクアップは基盤となるイベントに由来し、アプリケーションは見落としを修正するための追加通知を依存すべきではありません。epoll(7)

準備完了通知の後、アプリケーションは依然として I/O を実行し、接続状態を維持する必要がある epoll_wait が読み込み可能通知を返す ノンブロッキング recv n > 0 バイトを処理し、読み込みを継続 n == 0 相手側の入力終了を記録 EAGAIN 現在データなし EINTR は再試行可能;他のエラーはプロトコルに従って処理。通知はデータを予約するものではなく、ビジネスメッセージが完全であることを意味するものでもない。

以下はストリームソケットの読み込み断片です。consume_bytes は高速に処理するかアプリケーションキューに入れる必要があり、イベントスレッド内でブロックしてはいけません。0 は EAGAIN を返したことを示し、1 は入力方向の EOF を、-1 はその他のエラーを示します。

#include <errno.h>
#include <stddef.h>
#include <sys/socket.h>

void consume_bytes(const unsigned char *data, size_t size);

int drain_socket(int fd) {
    unsigned char buf[4096];
    for (;;) {
        ssize_t n = recv(fd, buf, sizeof buf, 0);
        if (n > 0) {
            consume_bytes(buf, (size_t)n);
            continue;
        }
        if (n == 0) return 1;
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
        return -1;
    }
}

EOF は相手側から自分側への入力方向のみを終了し、応答の送信や出力のフラッシュが必要である可能性があります。接続全体をクローズするかどうかはプロトコルの状態によって決定すべきであり、HUP が発生したからといって即座にオブジェクトを破棄すべきではありません。

データが残っているのに、なぜ epoll_wait が新しいイベントを返さないのか?

ET において「到着→wait→read→wait」を実行すると、2 回目の wait は 0 を返す可能性がありますが、バッファにはまだ 2 バイト残っています。アプリケーションが保持する未処理フラグにより、残りの作業を継続してスケジューリングできます。このフラグは、現在のラウンドで新しいイベントがないために削除されるものではありません。ちょうど 2 バイト読み取ったからといって排空が自動的に証明されるわけではなく、再度読み込みを試みて EAGAIN を得て初めて、この境界が確認されます。

図を準備しています
データが残っているのに、なぜ epoll_wait が新しいイベントを返さないのか?

ET は未読データの繰り返し通知を保証しません。予算を使い果たした場合は、アプリケーションの未処理状態を保持する必要があります。read が EAGAIN を返して初めて、現時点で排空されたことが示されます。

書き込みバックプレッシャと公平性

アプリケーションに送信待ちデータがある場合、まずノンブロッキング送信を試みます。一部のみを書き込んだ場合や EAGAIN が返された場合は、残りの領域を保存し、EPOLLOUT に注目します。送信待ちキューが空になった後、通常は書き込み可能関心を解除します。そうしないと、LT モードでアイドル状態(空回り)になる可能性があります。ソケットへの書き込みには、SIGPIPE または MSG_NOSIGNAL のポリシーを明確にする必要があります。

1つの高スループット接続に EAGAIN が返されるまでずっとサービスを提供し続けると、他の接続が飢餓状態になる可能性があります。各接続にバイト数または時間の予算を設定できます。ET において予算を使い果たしても排空されていない場合、接続をアプリケーションの準備完了キューに残し、継続してスケジュールする必要があります。再度通知を期待して直接 epoll_wait に戻ってはなりません。

リスニングソケットも EAGAIN が返されるまで accept をループする必要があります。accept4 を使用して、新しい接続の NONBLOCK/CLOEXEC を原子指定できます。また、fd の枯渇などのエラーを処理し、リスニング準備完了が継続的な失敗による忙ループにならないようにする必要があります。

ET 処理予算の枯渇は、現在データがないことを意味しない 読み込み可能イベントを受信 アプリケーションの準備完了キューに追加 今ラウンドで 64 KiB 処理 他の接続に実行機会を残す EAGAIN をまだ読んでいない 準備完了キューの末尾に再スケジュール アプリケーションの準備完了キューにタスクが残っている場合、新しいイベントをブロックせずに取得し続ける EAGAIN を読んで初めて、その方向の準備完了フラグをクリア。EOF/エラーは各自の状態へ移行 バイト予算は例示であり、時間と解析作業量も制限可能。キューには重複排除が必要で、重複してスケジュールされないようにする。

公平なスケジューリングには「作業が残っている」ことを明示的に保存する必要がある

A が現在 10 ユニット読み込み可能、B が 2 ユニット読み込み可能であり、毎ラウンドの予算が 4 であると仮定します。A が最初のラウンドの予算を使い果たしても作業が残っている場合、A をキューの末尾に配置することで、B が先に完了できます。ET において A を直接忘却すると、残りのバイトは再び通知が来ないことを前提に待機し続ける可能性があります。

from collections import deque
remaining = {"A": 10, "B": 2}
ready = deque(["A", "B"])
queued = set(ready)
turns = []
while ready:
    connection = ready.popleft()
    queued.remove(connection)
    count = min(4, remaining[connection])
    remaining[connection] -= count
    turns.append((connection, count))
    if remaining[connection] and connection not in queued:
        ready.append(connection)
        queued.add(connection)
assert turns == [("A", 4), ("B", 2), ("A", 4), ("A", 2)]
assert all(n == 0 for n in remaining.values())

これは既知の作業量を持つオフラインモデルです。実際のソケットでは remaining を事前に知ることはできません。ノンブロッキング read の EAGAIN、EOF、およびエラーを使用して状態を更新し、予算を使い果たしたがまだ排空されていない接続を、アプリケーションが実行可能な状態として保持します。メッセージの解析自体が非常に時間がかかる場合、読み込みバイト数を制限するだけでは公平性を保証できず、解析/ビジネス実行時間の制限も必要です。

複数ワークスレッドと ONESHOT

ONESHOT は、通常、1 つの処理者が接続を一時的に引き受けるために使用されます。処理が完了した後、EPOLL_CTL_MOD を通じて関心を再有効化します。これはすべての接続フィールドがスレッドセーフになることを自動的にもたらすものではなく、タスクの引き渡し時のメモリ同期を代替するものでもありません。

リセット時には、接続状態と関心集合を適切に公開し、リセット後に他のスレッドがすぐにイベントを受信する可能性を考慮する必要があります。未読の準備完了データが残っている場合、再有効化後にすぐに再度配信される可能性があります。ロック、所有権、または直列化実行器は、誰が出力キューの変更と接続のクローズを行うかを明確に規定する必要があります。

eventfd を用いた ONESHOT と再有効化の可視化

oneshot-demo.c として保存します。eventfd の初期値は 3 で、最初の epoll_wait で読み込み可能イベントを受信した後、意図的に読み込みを行いません。カウントは残っていますが、ONESHOT により後の通知が無効化されます。MOD で再設定した後、既存の読み込み可能条件によりイベントが再度返されます。

#include <stdint.h>
#include <stdio.h>
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <unistd.h>
int main(void) {
    int fd = eventfd(3, EFD_NONBLOCK | EFD_CLOEXEC);
    if (fd == -1) return 1;
    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep == -1) { close(fd); return 1; }
    int failed = 1;
    struct epoll_event interest = { .events = EPOLLIN | EPOLLONESHOT };
    interest.data.u64 = 42;
    struct epoll_event event;
    if (epoll_ctl(ep, EPOLL_CTL_ADD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 1 || event.data.u64 != 42) goto done;
    if (!(event.events & EPOLLIN)) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 0) goto done;
    if (epoll_ctl(ep, EPOLL_CTL_MOD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 1 || event.data.u64 != 42) goto done;
    uint64_t count;
    if (read(fd, &count, sizeof count) != sizeof count || count != 3) goto done;
    if (epoll_ctl(ep, EPOLL_CTL_MOD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 0) goto done;
    puts("notified, disabled, rearmed; count=3; drained");
    failed = 0;
done:
    if (close(ep) == -1) failed = 1;
    if (close(fd) == -1) failed = 1;
    return failed;
}

cc -Wall -Wextra oneshot-demo.c -o oneshot-demo でコンパイル後、./oneshot-demo を実行します。すべての待機はゼロタイムアウトを使用し、すでに存在する準備完了条件をチェックします。この例では他のシグナルソースがないため、待機失敗は直接終了します。一般的なループでは EINTR の処理が必要です。ONESHOT

ここで read は 8 バイトを返し、読み取られたカウントは 3 です。これらは異なる意味を持ちます。EFD_SEMAPHORE が有効化されていない場合、成功した読み込みは現在の累積値を取得してゼロにリセットします。eventfd は通知カウンターとして機能し、3 つの独立したビジネスメッセージを保持するものではありません。eventfd(2)

クローズと fd 番号の再利用

fd は再利用可能な整数です。1 つのバッチ中に、古い接続のイベントが存在し、その後あるコールバックがそれをクローズし、新しい接続が同じ fd を取得する可能性があります。この数字だけで現在のオブジェクトを参照すると、古いイベントを新しい接続に誤って適用してしまう可能性があります。オブジェクトの世代番号、安定した ID、遅延回収、または検証により、このような問題を解決できます。

dup された記述子は、オープンされたファイル記述子を共有する可能性があります。1 つの fd をクローズしても、関連するすべての登録が即座に消えるわけではありません。確実な取り消しが必要な場合は、オブジェクトのライフサイクルが有効な間に DEL を実行し、アプリケーションキューに既に存在するイベントのルールに従ってクリーンアップします。カーネルが登録を削除しても、ユーザー空間にコピーされたイベントを取り消すことはできません。

古いイベントが新しい接続に誤って配信される例

現在のバッチイベントに (slot=7, generation=4) が保存されていると仮定します。前のコールバックがその接続をクローズしてスロットを解放し、新しい接続が slot=7 を再利用しますが、世代番号は 5 になります。残りのイベントを配信する際には、世代番号を確認する必要があり、slot や fd だけで非空のオブジェクトを見つけて継続してはいけません。

current = {7: {"generation": 5, "name": "new connection"}}
old_event = (7, 4)
new_event = (7, 5)
def resolve(event):
    slot, generation = event
    item = current.get(slot)
    if item is None or item["generation"] != generation:
        return None
    return item
assert resolve(old_event) is None
assert resolve(new_event)["name"] == "new connection"

世代番号には十分な範囲が必要であり、ラップアラウンドと再利用のポリシーを定義する必要があります。また、これは並行メモリ回収を代替するものではありません。data.ptr がすでに解放されたポインタである場合、そこから世代番号を読み取るために逆参照することは依然として安全ではありません。整数 ID を使用して有効な登録テーブルを照会するか、イベントバッチが配信が完了するまでオブジェクト参照を保持するようにします。

timerfd、signalfd、および eventfd

timerfd は作成後、timerfd_settime を呼び出して初めて起動します。読み取りでは通常、累積された期限切れ回数が取得され、読み込み可能になるたびに期限切れが 1 回だけ発生するわけではありません。eventfd の読み取りでは通知カウントが取得されます。両方とも、インターフェースで規定されたレコードサイズで読み取り、ノンブロッキング時の空読みを処理する必要があります。

signalfd を使用するには、関連スレッドで対象シグナルをブロックする必要があります。以下はプログラム初期化フェーズで使用される断片で、<signal.h>、<pthread.h>、<sys/signalfd.h> が必要です。

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
int err = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (err != 0) return err;
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd == -1) return errno;

ワークスレッドを作成する前に、それらが継承するマスクを確立するか、スレッドごとに調整する必要があります。そうでないと、シグナルがそれをブロックしていない他のスレッドによって受信される可能性があります。sigemptyset を忘れると、マスクの内容が未初期化になります。SIGKILL、SIGSTOP は signalfd で引き受けることはできません。signalfd(2)

イベント駆動の真の境界

準備完了コールバック内でブロッキング DNS、ディスク読み取り、長時間の計算、または通常のロックの待機を行うと、イベントスレッド全体がブロックされます。時間のかかるタスクは適切な実行メカニズムに委ね、キュー、eventfd、またはその他の通知を通じて結果を戻します。

epoll は準備完了モデルに適しています。io_uring はリクエスト完了を主なインターフェースとしています。両方とも、アプリケーションの状態マシン、バックプレッシャ、キャンセル、およびリソース管理が必要です。インターフェースの名前がこれらの問題を自動的に解決するわけではありません。

キーワード:epoll, readiness, LT, ET, EPOLLONESHOT, half close, backpressure, fairness, fd reuse, timerfd, signalfd, eventfd