ノンブロッキングI/OとI/O多重化 - select/poll/epoll から io_uring までの歩き方

ノンブロッキングI/OとI/O多重化 - select/poll/epoll から io_uring までの歩き方

作成日:
読了:47
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

1万本の接続を1台のサーバで捌く、という話が「難問」として語られていた時代がありました。今では珍しくもない要件ですが、そこに至るまでにOSのI/Oインタフェースは何度か作り直されています。select から pollepoll へ、そして io_uring へ。この記事では、それぞれが何を解こうとして生まれ、どこで限界を迎えたのかを順に追いかけます。

数値やバージョンは原則としてmanページ・カーネルのソースと公式ドキュメントから引き、手元で動かした結果は実測として区別して書きます。Cのサンプルは epoll を使うためLinux専用です(実行環境: Docker上の gcc:13 イメージ、aarch64)。Pythonのサンプルは macOS 26.5 / Python 3.14 と Linux コンテナ / Python 3.12 の両方で動かしています。

C10K問題 - 「1接続1スレッド」はどこで壊れるか

Dan Kegel が1999年に書いた文書「The C10K problem」(最終更新2014年2月5日)は、こんな一文で始まります。

It's time for web servers to handle ten thousand clients simultaneously, don't you think? After all, the web is a big place now.

同文書はI/O戦略を5つに分類していますが、一番素朴なのが「1クライアントにつき1スレッド、I/Oはブロッキング」という方式です。処理の流れが上から下に読めて書き味は良いのですが、1万接続では次の3点で行き詰まります。

  1. スタックのコストpthread_create(3) によれば、新しいスレッドの既定スタックサイズは「プログラム開始時点の RLIMIT_STACK ソフトリミット」で決まり、unlimited の場合はアーキテクチャごとの値(多くのアーキテクチャで2MB、POWERとSparc-64で4MB)が使われます。多くのディストリビューションの既定は ulimit -s が 8192KB なので、1スレッドあたり8MBの仮想アドレス空間を予約することになります。1万スレッドなら80GBです。実際に触られたページしか物理メモリは消費しない(仮想メモリとページングの仕組みで扱った通りです)ので即死はしませんが、アドレス空間もページテーブルも確実に太ります
  2. コンテキストスイッチのコスト。1万スレッドがそれぞれ数KBのデータを待つと、スケジューラは大半の時間を「切り替え」に使います。切り替えのたびにキャッシュとTLBの中身が入れ替わるぶん、実効的なメモリアクセス性能も落ちます
  3. 同期のコスト。共有状態にロックを掛ければ、そのロックが新しいボトルネックになります

プロセスとスレッドのコスト構造そのものは並行と並列、プロセスとスレッドで整理しています。ここで押さえたいのは、「待っているだけの接続に、OSのスケジューリング単位を1つずつ割り当てるのは高すぎる」という一点です。

ノンブロッキングI/Oとレディネス通知

ブロッキングをやめる第一歩は、fdに O_NONBLOCK を付けることです。データが無ければ read(2) は待たずに -1 を返し、errnoEAGAIN(または EWOULDBLOCK)が入ります。

ただしこれだけでは何も解決しません。ノンブロッキングにしたfdを1万本ぐるぐる回して read を撃ち続けると、CPUを100%使いながらほとんど空振りするだけです。必要なのは「どのfdが今読めるのか」をOSに聞く手段、つまりI/O多重化(I/O multiplexing)です。

ここで、後の議論に効く区別を先に置いておきます。

  • レディネス(readiness)モデル: OSは「このfdは今なら読める/書ける」と教えてくれる。実際のデータ転送はアプリケーションが read/write を呼んで行う。select / poll / epoll / kqueue がこちら
  • コンプリーション(completion)モデル: アプリケーションは先に「この読み込みをやっておいて」と依頼し、OSは「終わったよ、結果はこれ」と教えてくれる。Windows の IOCP や Linux の io_uring がこちら

C10K文書の分類でいえば、前者が「nonblocking I/O + readiness notification」、後者が「asynchronous I/O」に相当します。この記事の後半は、要するにLinuxが前者から後者へ軸足を移していく話です。

select - 最初の多重化API、そしてその限界

select(2) は監視したいfdを fd_set というビットマップに詰めて渡し、準備ができたfdだけを残して返してもらうAPIです。歴史は古く、POSIXにも入っています。

問題は、そのビットマップが固定長だという点です。man 2 select は次のように書いています。

select() can monitor only file descriptors numbers that are less than FD_SETSIZE (1024)—an unreasonably low limit for many modern applications.

さらに、FD_SETSIZE 以上の値や負の値を FD_SET() / FD_CLR() に渡した場合の挙動は未定義です。glibcの実装では fd_set は固定長の型で、FD_SETSIZE は1024と定義されています。ulimit -n を上げてfd番号が1024を超えた瞬間、select を使ったコードはビットマップの外を踏みます。しかもこれはエラーにならず、静かにスタックを壊すことがあります。

構造的な問題はもう2つあります。

  • 毎回すべてのfdを渡し直す。カーネルは呼ばれるたびに監視対象の集合を先頭から走査します。監視するfdが n 本なら、準備できているのが1本でも計算量は O(n) です
  • fd_set が破壊される。戻り値として同じ領域が書き換えられるので、ループのたびに集合を作り直す必要があります

加えてLinux固有の注意点として、manページは timeout について次のように書いています。

On Linux, select() modifies timeout to reflect the amount of time not slept; most other implementations do not do this.

select から戻ったあとの timeout は未定義だと考えること」というのが公式の助言です。移植性のあるコードではループのたびに timeout を再設定してください。

poll - FD_SETSIZE からは解放される、が

poll(2) はビットマップをやめて構造体の配列を渡す設計にしました。

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

要求(events)と結果(revents)が別のフィールドなので、集合を毎回作り直す必要がありません。配列長 nfds はアプリケーション側が決めるので、FD_SETSIZE のような固定上限もありません(manページが挙げる上限は RLIMIT_NOFILE だけです)。poll は POSIX.1-2001、Linuxでは 2.1.23 から。ナノ秒精度のタイムアウトとシグナルマスクを扱える ppoll は POSIX.1-2024 / Linux 2.6.16 で追加されています。

しかし本質的な問題は残ります。呼び出しのたびに監視対象の配列全体をカーネルに渡し、カーネルは全部を走査するという構造は select と同じです。1万本のうち10本しかイベントが無くても、毎回1万エントリをコピーして1万回チェックします。C10K規模ではここが効いてきます。

epoll - カーネルに集合を預ける

epoll はこの「毎回渡す」をやめました。Linux 2.5.44 で登場し、glibc 2.3.2 からラッパが提供されています(man 7 epoll の VERSIONS)。使い方は3段階です。

  1. epoll_create1() で epoll インスタンスを作る
  2. epoll_ctl() で監視対象を1回だけ登録する(EPOLL_CTL_ADD / MOD / DEL)
  3. epoll_wait() で「準備できたものだけ」を受け取る

カーネル側のデータ構造を見ると、速さの理由がはっきりします。fs/eventpoll.cstruct eventpoll には次の2つが並んでいます。

	/* List of ready file descriptors */
	struct list_head rdllist;
	...
	/* RB tree root used to store monitored fd structs */
	struct rb_root_cached rbr;

rbr関心リスト(interest list)で、登録したfdを赤黒木で保持します。登録・削除・検索が O(log n) で済み、しかもこの木はカーネル側に居座るのでアプリケーションが毎回渡す必要がありません。

rdllistレディリスト(ready list)です。ソース中のコメントは "doubly-linked FIFO; the current ready list." と説明しています。fdでイベントが起きると、待ち行列に仕掛けられたコールバック ep_poll_callback() が呼ばれ、その epitem をこの双方向リストに繋ぎます。epoll_wait() はこのリストから取り出すだけなので、監視対象の総数 n には比例せず、準備できた数 k に比例するコストで済みます。これが「epoll は O(1)、select/poll は O(n)」と言われるときの実体です。

登録には当然メモリコストがあります。man 7 epoll と Documentation/admin-guide/sysctl/fs.rst はどちらも「1つの watch あたり32ビットカーネルで約90バイト、64ビットカーネルで約160バイト」と書いており、上限は /proc/sys/fs/epoll/max_user_watches で、既定値は「利用可能なlow memoryの4%を watch のコストで割った値」とされています。

# ユーザあたりの登録上限(実効ユーザIDごと)
cat /proc/sys/fs/epoll/max_user_watches
 
# プロセスが開けるfdの上限
ulimit -n

最小サンプル(Linux専用)

レベルトリガのエコーサーバです。gcc -O2 -Wall -o epoll_echo epoll_echo.c でビルドできます。

/* epoll_echo.c - Linux 専用のエコーサーバ (レベルトリガ) */
#define _GNU_SOURCE
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
 
#define PORT       9000
#define MAX_EVENTS 64
 
static void die(const char *msg) { perror(msg); exit(EXIT_FAILURE); }
 
int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    if (listen_fd < 0) die("socket");
 
    int on = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
 
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family      = AF_INET;
    addr.sin_port        = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) die("bind");
    if (listen(listen_fd, SOMAXCONN) < 0) die("listen");
 
    /* カーネル側に関心リストを作る。fd集合を毎回渡す必要がなくなる */
    int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd < 0) die("epoll_create1");
 
    struct epoll_event ev;
    ev.events  = EPOLLIN;          /* EPOLLET を足せばエッジトリガ */
    ev.data.fd = listen_fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) die("epoll_ctl:add");
 
    printf("listening on :%d (epoll, level-triggered)\n", PORT);
    fflush(stdout);
 
    struct epoll_event events[MAX_EVENTS];
    for (;;) {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            die("epoll_wait");
        }
        printf("epoll_wait -> %d ready\n", n);
 
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
 
            if (fd == listen_fd) {
                for (;;) {            /* バックログを取り切る */
                    int cfd = accept4(listen_fd, NULL, NULL,
                                      SOCK_NONBLOCK | SOCK_CLOEXEC);
                    if (cfd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        die("accept4");
                    }
                    struct epoll_event cev;
                    cev.events  = EPOLLIN | EPOLLRDHUP;
                    cev.data.fd = cfd;
                    if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev) < 0)
                        die("epoll_ctl:add client");
                    printf("  accepted fd=%d\n", cfd);
                }
                continue;
            }
 
            if (events[i].events & (EPOLLHUP | EPOLLERR)) {
                printf("  fd=%d error/hup, closing\n", fd);
                close(fd);           /* close で関心リストから外れる */
                continue;
            }
 
            char buf[1024];
            ssize_t r = read(fd, buf, sizeof buf);
            if (r > 0) {
                printf("  fd=%d read %zd bytes\n", fd, r);
                ssize_t off = 0;     /* 実運用では EAGAIN 時の送信バッファが要る */
                while (off < r) {
                    ssize_t w = write(fd, buf + off, (size_t)(r - off));
                    if (w < 0) { if (errno == EINTR) continue; break; }
                    off += w;
                }
            } else if (r == 0) {
                printf("  fd=%d closed by peer\n", fd);
                close(fd);
            } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
                printf("  fd=%d read error: %s\n", fd, strerror(errno));
                close(fd);
            }
        }
        fflush(stdout);
    }
}

3本のクライアントを繋いで文字列を投げたときの実際の出力です。

listening on :9000 (epoll, level-triggered)
epoll_wait -> 1 ready
  accepted fd=5
epoll_wait -> 1 ready
  accepted fd=6
epoll_wait -> 1 ready
  accepted fd=7
epoll_wait -> 1 ready
  fd=5 read 8 bytes
epoll_wait -> 1 ready
  fd=6 read 8 bytes
epoll_wait -> 1 ready
  fd=7 read 8 bytes
epoll_wait -> 1 ready
  fd=5 closed by peer
epoll_wait -> 1 ready
  fd=6 closed by peer
epoll_wait -> 1 ready
  fd=7 closed by peer

epoll_wait が返すのは常に「準備できた数」だけで、登録している3本すべてが毎回返ってくるわけではない点に注目してください。

レベルトリガとエッジトリガ

man 7 epoll はこの2つのモードをこう説明しています。

The epoll event distribution interface is able to behave both as edge-triggered (ET) and as level-triggered (LT).

既定はレベルトリガで、そのときの epoll は「単に速い poll」だと同manページは述べています(when used as a level-triggered interface (the default, when EPOLLET is not specified), epoll is simply a faster poll(2))。一方エッジトリガは「監視対象に変化が起きたときにだけ通知する」動作です。

言葉だけでは分かりにくいので、1本のfdで差を観察する実験を書きました。4000バイトを一度に流し込んでおき、1イベントにつき1024バイトだけ読む(=わざと読み切らない)というものです。

/* et_lt.c - LT と ET の差を1本のfdで観察する (Linux 専用)
 * run: ./et_lt lt   /   ./et_lt et */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
 
int main(int argc, char *argv[]) {
    int et = (argc > 1 && strcmp(argv[1], "et") == 0);
 
    int sv[2];
    socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv);
 
    int epfd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN | (et ? EPOLLET : 0),
                              .data.fd = sv[0] };
    epoll_ctl(epfd, EPOLL_CTL_ADD, sv[0], &ev);
 
    char payload[4000];
    memset(payload, 'x', sizeof payload);
    write(sv[1], payload, sizeof payload);   /* 4000バイトを一度に流し込む */
 
    printf("mode=%s\n", et ? "edge-triggered" : "level-triggered");
 
    char buf[1024];
    struct epoll_event out[1];
    for (int round = 1; round <= 6; round++) {
        int n = epoll_wait(epfd, out, 1, 300);   /* 300ms でタイムアウト */
        if (n == 0) { printf("  round %d: timeout (通知なし)\n", round); break; }
        ssize_t r = read(sv[0], buf, sizeof buf); /* わざと全部読み切らない */
        printf("  round %d: ready -> read %zd bytes\n", round, r);
    }
    return 0;
}

実行結果です。

--- LT ---
mode=level-triggered
  round 1: ready -> read 1024 bytes
  round 2: ready -> read 1024 bytes
  round 3: ready -> read 1024 bytes
  round 4: ready -> read 928 bytes
  round 5: timeout (通知なし)
--- ET ---
mode=edge-triggered
  round 1: ready -> read 1024 bytes
  round 2: timeout (通知なし)

レベルトリガは「まだ読めるデータが残っている」という状態を毎回報告するので、読み切るまで4回通知が来ます。エッジトリガは「データが到着した」という変化だけを報告するので、1回きりです。残り2976バイトはバッファに居るのに二度と起こされない、これがET最大の落とし穴です。

WARNING

エッジトリガでは、read/writeEAGAIN を返すまでループで呼び切るのが鉄則です。man 7 epoll も「EPOLLET を使うアプリケーションはノンブロッキングのfdを使うべきで、そうしないとブロッキングする read や write が、他のfdを処理するタスクを飢えさせる」と警告しています。

もう2つ、実務で刺さりやすい注意点を挙げておきます。

  • close とfdの寿命。man 7 epoll のQ&Aは「fdをcloseすれば関心リストから消えるか」という問いに「Yes, but」と答えています。取り除かれるのは、そのオープンファイル記述を指すすべてのfdが閉じられたあとです。dup() した相方が残っていると、close() したはずのfdのイベントが届き続けます
  • サンダリングハード。同じリスニングソケットを複数プロセスで待つと、1接続の到着で全員が起こされます。対策は EPOLLEXCLUSIVE(Linux 4.5 / glibc 2.24 で追加、排他的な起こし方をする)か、SO_REUSEPORT(Linux 3.9 以降。プロセスごとに別のリスニングソケットを持ち、カーネルが振り分ける)です。man 7 socket は後者について「単一のacceptスレッドが配るやり方や、同じソケットを複数スレッドで奪い合うやり方より負荷分散が改善する」と説明しています

なお、1つのfdの処理を1回に限りたい場合は EPOLLONESHOT が使えます。通知後にそのfdは無効化されるので、再度使うには EPOLL_CTL_MOD で明示的に再武装する必要があります。マルチスレッドのワーカーで「同じfdを2スレッドが同時に触る」事故を防ぐ定番です。

kqueue と IOCP - 他のOSはどうしているか

epoll はLinux固有のAPIです。他のOSにも同じ問題意識から生まれた仕組みがあります。

kqueue(FreeBSD 4.1 で登場、macOS を含むBSD系で利用可能)は、kevent() 1回の呼び出しで変更リスト(changelist)と取得リスト(eventlist)を同時に処理します。epollepoll_ctlepoll_wait に分かれているのに対し、kqueue は登録と待機が1つのシステムコールにまとまっているぶんシステムコール数で有利です。またフィルタという概念があり、EVFILT_READ / EVFILT_WRITE だけでなく EVFILT_VNODE(ファイルの変更)、EVFILT_PROC(プロセスの状態)、EVFILT_SIGNALEVFILT_TIMEREVFILT_USER まで同じインタフェースで扱えます。エッジトリガに相当するのは EV_CLEAR フラグで、manページは「ユーザが取得したあとイベントの状態をリセットする」と説明しています。

IOCP (I/O Completion Ports)は Windows のもので、思想が根本的に違います。Microsoft のドキュメントはこう書いています。

I/O completion ports provide an efficient threading model for processing multiple asynchronous I/O requests on a multiprocessor system.

CreateIoCompletionPort でポートを作ってファイルハンドルを紐付け、非同期I/Oが完了した時点で完了パケットがFIFOでキューに積まれます。ワーカースレッドは GetQueuedCompletionStatus でそれを取り出す、つまり「読めるようになったよ」ではなく「読み終わったよ」を受け取るモデルです。ポート作成時の concurrency value で同時に走れるスレッド数を制限でき、ドキュメントは「CPU数を上限の目安にするのがよい」としています。

比較表

方式通知モデル監視集合の置き場所1回の呼び出しのコスト主な対象プラットフォーム
selectレディネス呼び出しごとにユーザ空間から渡す監視数 n に比例、fd番号は FD_SETSIZE 未満に限るfd全般POSIX全般
pollレディネス呼び出しごとにユーザ空間から渡す監視数 n に比例、fd番号の上限なしfd全般POSIX全般
epollレディネス(LT/ET選択可)カーネル(赤黒木 + レディリスト)準備できた数 k に比例fd全般Linux 2.5.44 以降
kqueueレディネス(EV_CLEAR でET相当)カーネル準備できた数に比例、登録と待機が1回のsyscallfd、プロセス、シグナル、タイマ等FreeBSD 4.1 以降、macOS等
IOCPコンプリーションカーネル(完了キュー)完了パケット単位、concurrency valueで並行数を制御オーバーラップI/O対応ハンドルWindows
io_uringコンプリーションカーネルとユーザで共有するリングバッファ複数の要求をまとめて投入、SQPOLL時はsyscallなしも可能ソケット、ファイル、その他多数の操作Linux 5.1 以降

io_uring - 「多重化」から「非同期I/O」へ

epoll は「どのfdが読めるか」を高速に答えてくれますが、その先の read/write は結局アプリケーションがシステムコールで行います。1接続あたり最低でも「起こされる + 読む + 書く」で複数回のシステムコールが必要で、接続数と要求レートが上がるとここが次のボトルネックになります。しかもファイルI/Oは epoll の対象になりません(通常ファイルは常に「読める」と報告されます)。

Linux には以前から io_submit 系の非同期I/O(libaio)がありましたが、評判は芳しくありませんでした。LWN の Jonathan Corbet による記事「Ringing in a new asynchronous I/O API」(2019年1月15日)は、既存のAIOが「使いにくく非効率で、しかもI/Oの種類によってサポートに差がある」と見なされてきたと書いています。バッファードI/Oが使えず O_DIRECT に限られる、という制約が特に大きいものでした。

その置き換えとして Jens Axboe が設計したのが io_uring です。Linux 5.1 で導入され、fs/io_uring.c が追加されたのもこのバージョンからです(v5.0 のツリーには存在しません)。同時にシステムコール番号 425/426/427 が io_uring_setup / io_uring_enter / io_uring_register に割り当てられました。

SQ/CQ という2本のリング

io_uring の中核は、カーネルとユーザ空間が mmap共有する2本のリングバッファです。man 7 io_uring の説明が簡潔です。

  • SQ (submission queue): アプリケーションが SQE (submission queue entry) をリングの末尾に積む。カーネルは先頭から読む
  • CQ (completion queue): カーネルが CQE (completion queue entry) を末尾に積む。アプリケーションは先頭から読む

同manページは「io_uring の通信は共有されるカーネル・ユーザ空間の2本のリングバッファ経由で行われ、5.4 以降のカーネルでは1回の mmap() でまとめてマップできる」と述べています。マップに使うオフセットは IORING_OFF_SQ_RING / IORING_OFF_CQ_RING / IORING_OFF_SQES です。

この設計が効くのは、複数の操作をまとめて1回のシステムコールで投入できる点です。epoll のように「イベントを受け取る」「読む」「書く」とカーネル境界を往復するのではなく、「read してから write して」をSQEとして積み、io_uring_enter() 1回で渡せます。

SQPOLL - システムコールをゼロにする

さらに IORING_SETUP_SQPOLL を指定すると、man 7 io_uring の言うとおり「io_uring はカーネルスレッドを起動し、SQEを積むことで投入されるI/O要求を submission queue に対してポーリング」します。io_uring_setup(2) の説明はもっと直接的で、この構成は「アプリケーションがカーネルへコンテキストスイッチすることなくI/Oを発行できるようにする」とあります。リングに書き込むだけでI/Oが始まるので、定常状態ではシステムコールが1回も要りません。

代償として、そのカーネルスレッドがCPUを回し続けます。アイドルが一定時間続くとスレッドはスリープし、アプリケーション側は起こし直す必要があります。CPUコアを1つ献上できる高スループット用途向けの機能だと考えてください。

NOTE

ベンチマーク数値はワークロード・カーネルバージョン・ハードウェアで桁が変わります。この記事では、一次情報として確認できた具体的な性能数値が無いため、あえて数字を出していません。導入を検討する際は必ず自分のワークロードで測ってください。

io_uring が扱えるのはソケットだけではありません。read/write に加えてファイルのオープン・クローズ・statxfsync、さらに accept/connect/send/recv まで、多数の操作がSQEの opcode として用意されています。「fdのレディネスを教えてもらう仕組み」ではなく「操作そのものをカーネルに委譲する仕組み」だという点で、io_uring は epoll の延長ではなく別カテゴリのAPIです。

io_uring のセキュリティ - なぜ止められているのか

io_uring は速さと引き換えに、カーネルの攻撃面をかなり広げました。Google の Security Blog は2023年6月14日の記事「Learnings from kCTF VRP's 42 Linux kernel exploits submissions」で、次のように書いています。

in the past year, there has been a clear trend: 60% of the submissions exploited the io_uring component of the Linux kernel (we paid out around 1 million USD for io_uring alone). Furthermore, io_uring vulnerabilities were used in all the submissions which bypassed our mitigations.

そのうえで同記事は、Google 製品での対応を具体的に列挙しています。

  • ChromeOS: io_uring を無効化した(サンドボックス化の新しい方法を模索中)
  • Android: seccomp-bpf フィルタでアプリから io_uring に到達できないようにしている。将来のリリースでは SELinux で一部のシステムプロセスだけに限定する予定
  • GKE Autopilot: 既定で無効化することを検討中
  • Google の本番サーバでは無効化済み

結論として同記事は「現時点では信頼できるコンポーネントによる利用に限って安全と考えている」としています。

ここで実務的に厄介なのが seccomp との相性です。seccomp-bpf はシステムコール番号と引数を見てフィルタする仕組みですが、io_uring では実際の操作(open、read、connect など)がシステムコールではなくSQEの opcode として渡されます。Google の対応が「io_uring の個々の操作を細かく制限する」ではなく「そもそも io_uring に到達させない」になっているのは、この非対称性の現れだと読めます(ただし、seccompでopcode単位のフィルタが原理的にできない、と明言する一次資料は今回確認できていません。ここは筆者の解釈です)。コンテナのデフォルトseccompプロファイルが io_uring 系システムコールを許可しているかどうかは、使っているランタイムとバージョンで異なるので個別に確認してください。

io_uring_disabled sysctl

Linux 6.6 で、システム全体で io_uring を止めるための sysctl が入りました。Matteo Rizzo (Google) のパッチ(コミット 76d3ccecfa186af3120e206d62f03db1a94a535f、コミッタは Jens Axboe)によるもので、Documentation/admin-guide/sysctl/kernel.rst にこの項目が現れるのは v6.6 からです(v6.5 には存在しません)。

意味は次の通りです。

挙動
0すべてのプロセスが通常どおり io_uring インスタンスを作れる。既定値
1io_uring_group に属さない非特権プロセスでは io_uring_setup()-EPERM で失敗する。既存のインスタンスは使い続けられる
2すべてのプロセスで io_uring_setup() が常に -EPERM で失敗する。既存のインスタンスは使い続けられる

io_uring_group は値1のときに使うグループIDで、既定は -1、つまり CAP_SYS_ADMIN を持つプロセスだけが作成できます。

# 現在の設定を確認する
sysctl kernel.io_uring_disabled kernel.io_uring_group
 
# 非特権プロセスからの利用を止める(再起動後も残すなら /etc/sysctl.d/ に書く)
sudo sysctl -w kernel.io_uring_disabled=1

値を変えても既存のインスタンスは動き続ける点は要注意です。実質的に効かせたいならプロセスの再起動が要ります。ミドルウェアの挙動が変わる可能性があるので、本番で1や2にする前に必ずステージングで確認してください。

実装は何を使っているか - libuv / nginx / Redis

libuv (Node.js)

libuv の設計ドキュメントは、バックエンドの選択をこう明記しています。

all (network) I/O is performed on non-blocking sockets which are polled using the best mechanism available on the given platform: epoll on Linux, kqueue on OSX and other BSDs, event ports on SunOS and IOCP on Windows.

つまり Linux は epoll、macOS/BSD は kqueue、Windows は IOCP です。ファイルI/Oと getaddrinfo/getnameinfo はイベントループではなくグローバルなスレッドプールで処理され、その既定サイズは4、UV_THREADPOOL_SIZE で最大1024まで変更できます(1.30.0 で上限が128から1024に引き上げ)。Node.js の非同期処理が「ネットワークはイベントループ、ファイルはスレッドプール」という二層構造になっているのはこのためです。JavaScript側から見たイベントループの姿はJavaScriptの非同期処理とイベントループで扱っています。

io_uring 対応の経緯は、ChangeLog を追うとかなり紆余曲折しています。

バージョン変更
1.45.0io_uring サポートを導入、epoll_ctl のバッチ化にも利用
1.46.0IORING_OP_CLOSE のカーネルバグを回避
1.47.05.10.186 未満のカーネルでは io_uring を使わないように変更
1.48.0hppa では 6.1.51 未満のカーネルで無効化
1.49.0SQPOLL の io_uring を既定で無効化。UV_LOOP_USE_IO_URING_SQPOLL オプションを追加
1.50.0epoll のバッチ化には常に io_uring を使う

現在の v1.x ブランチ(1.52系)のソース src/unix/linux.c を読むと、状況はこうなっています。

  • uv__platform_loop_init()epoll_create1(O_CLOEXEC) でepollを作ったうえで、epoll_ctl をまとめて発行するための io_uring リング(256エントリ)を常に初期化しようとする
  • ファイル操作用の SQPOLL リングはオプトインで、ループに UV_LOOP_USE_IO_URING_SQPOLL を設定し、かつ環境変数 UV_USE_IO_URING が正の数であるときにだけ遅延生成される
  • Android、32ビットARM、powerpc64 では io_uring を一切使わない(それぞれ seccomp によるブロック、カーネルのバグ、シグナルハンドラでの SIGSEGV が理由としてコメントされています)

「libuv は io_uring を使うのか」への答えは、2026年時点では「epoll_ctl のバッチ化には使う。ファイルI/Oの SQPOLL 経路は明示的に有効化したときだけ使う」が正確です。

nginx

nginx の公式ドキュメント「Connection processing methods」が挙げる方式は、selectpollkqueue(FreeBSD 4.1以降、OpenBSD 2.9以降、NetBSD 2.0、macOS)、epoll(Linux 2.6以降)、/dev/polleventport の6つです。epoll については EPOLLRDHUPEPOLLEXCLUSIVE を 1.11.3 からサポートすると明記されています。use ディレクティブで明示指定できますが、ドキュメントは「複数の方式をサポートするプラットフォームでは通常もっとも効率的なものが自動的に選ばれる」としているので、基本は触らないのが正解です。公式ドキュメントの一覧に io_uring は含まれていません

Redis

Redis は ae という自前のイベントライブラリを持ち、src/ae.c の先頭でバックエンドをコンパイル時に選びます。

/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

src/config.h を見ると HAVE_EPOLL__linux__ で、HAVE_KQUEUE は Apple / FreeBSD / OpenBSD / NetBSD / DragonFly で、HAVE_EVPORT は Solaris 系で定義されます。優先順位は「evport > epoll > kqueue > select」で、select は最後の砦です。

Python の selectors で確かめる

自分の環境がどれを使うかは、Python の selectors モジュールで簡単に確認できます。公式ドキュメントは DefaultSelector を「現在のプラットフォームで利用可能なもっとも効率的な実装へのエイリアス」と説明しています。

import selectors
import socket
 
sel = selectors.DefaultSelector()
print("backend:", type(sel).__name__)
 
lsock = socket.socket()
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind(("127.0.0.1", 9001))
lsock.listen()
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
 
def accept(sock):
    conn, addr = sock.accept()
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, data=addr)
    print(f"accepted {addr} fd={conn.fileno()}")
 
def serve(conn, addr):
    data = conn.recv(1024)
    if data:
        print(f"fd={conn.fileno()} recv {len(data)} bytes")
        conn.sendall(data)
    else:
        print(f"fd={conn.fileno()} closed")
        sel.unregister(conn)
        conn.close()
 
try:
    while True:
        events = sel.select(timeout=1.0)
        if not events:
            print("idle (timeout)")
            break
        for key, _mask in events:
            if key.data is None:
                accept(key.fileobj)
            else:
                serve(key.fileobj, key.data)
finally:
    sel.close()
    lsock.close()

同じスクリプトを2つの環境で動かした実際の出力です。

# macOS 26.5 / Python 3.14
backend: KqueueSelector
accepted ('127.0.0.1', 54781) fd=5
fd=5 recv 6 bytes
fd=5 closed
...
idle (timeout)

# Linux コンテナ / Python 3.12
backend: EpollSelector
accepted ('127.0.0.1', 47758) fd=5
fd=5 recv 6 bytes
fd=5 closed
...
idle (timeout)

同じコードのまま、OSに応じて kqueue と epoll が使い分けられているのが確認できます。

実務でどう選ぶか

  • まず高水準ランタイムに任せる。Node.js、Go、Python の asyncio、Rust の tokio はいずれもプラットフォームごとに最適なバックエンドを選んでくれます。自前で epoll を書くのは、その抽象が邪魔になるほど尖った要件があるときだけです
  • 移植性が要るなら poll、Linux専用でよいなら epollselect を新規に選ぶ理由はほぼありません。FD_SETSIZE の壁は「テストでは踏まないが本番で踏む」典型的なバグです
  • エッジトリガは必要になってから。レベルトリガでも epoll_wait は準備できたfdしか返しません。ETの利点はシステムコール数の削減ですが、EAGAIN まで読み切る責務をアプリケーションが正しく背負えることが前提です
  • io_uring は「効く場所」を測ってから。ディスクI/Oが支配的、あるいは1接続あたりのシステムコール数がボトルネックだと計測で分かってから検討する機能です。セキュリティポリシー上そもそも使えない環境(ChromeOS、Android、Google本番、io_uring_disabled を1や2にした環境)がある点も設計時に織り込んでください
  • 接続数の増加はプロトコル層でも考える。ロングポーリングよりWebSocketやSSE、という選び方はWebSocket/SSE/ポーリングの使い分けに、TCPのハンドシェイクや接続確立のコストはTCPとUDP、3ウェイハンドシェイクにまとめてあります。UDP上に載るHTTP/3とQUICでは、多重化の主戦場がカーネルからユーザ空間へさらに移っています

まとめ

  • selectFD_SETSIZE(1024)という固定上限を持ち、fd_set を毎回作り直す必要がある。新規採用する理由はほぼない
  • poll は固定上限から解放されたが、「呼び出しのたびに全fdを渡してカーネルが全走査する」構造は変わらない
  • epoll はカーネル側に赤黒木で関心リストを保持し、イベントが起きたfdをレディリスト(rdllist)に繋ぐ。epoll_wait は準備できた数にしか比例しない
  • レベルトリガは状態を、エッジトリガは変化を報告する。ETでは EAGAIN まで読み切らないとデータが取り残される。実測でも4000バイト中1024バイトしか読めなかった
  • kqueue は登録と待機を1回のsyscallにまとめ、fd以外のイベントも扱う。IOCPは完了を通知するモデル
  • io_uring は Linux 5.1 で導入。共有リング(SQ/CQ)に操作そのものを積む設計で、SQPOLL を使えば定常状態でシステムコールを0にできる
  • io_uring はカーネルの攻撃面を広げた。Google は kCTF の提出物の60%が io_uring 由来だったとして ChromeOS で無効化、Android では seccomp でブロック、本番サーバでも無効化している。Linux 6.6 の kernel.io_uring_disabled で 0/1/2 の3段階に制御できる
  • libuv は Linux で epoll、macOS/BSD で kqueue、Windows で IOCP。io_uring は epoll_ctl のバッチ化に使われ、SQPOLL 経路はオプトイン。nginx の公式ドキュメントに io_uring は無く、Redis は evport > epoll > kqueue > select の順でコンパイル時に選ぶ

抽象化されたランタイムの上で書いていると、これらのAPIを直接叩く機会はまず訪れません。それでも、epoll_wait が何を返しているのか、なぜファイルI/Oだけスレッドプールに追いやられるのか、なぜ本番環境で io_uring が禁止されているのかを知っていると、詰まったときにどの層を疑えばよいかの見当がつきます。

参考リンク

SSH の仕組み - 鍵交換・ホスト鍵検証・公開鍵認証を一次ソースで理解する

SSH の仕組み - 鍵交換・ホスト鍵検証・公開鍵認証を一次ソースで理解する

18

リモートサーバー運用の土台である SSH プロトコルを、一次ソースの RFC 4251/4252/4253/4254(SSH-2 の Architecture / Authentication / Transport / Connection)を軸に整理します。TCP 接続からバージョン交換、KEXINIT による鍵交換と Diffie-Hellman、ホスト鍵の検証(known_hosts と TOFU の弱点)、公開鍵認証(authorized_keys と署名)、チャネルの多重化とポートフォワーディング(ssh -L / -R / -D)まで、その仕組みと OpenSSH での確認方法をインフラ担当者・開発者向けにまとめます。

zram 実践入門 - 圧縮スワップの効果測定と「優先度でティアリング」の落とし穴

zram 実践入門 - 圧縮スワップの効果測定と「優先度でティアリング」の落とし穴

30

メモリ2GBのVPSにzramを導入し、mm_statの実測値で効果を検証しました。337MBのページが97MBに圧縮され実効235MBのRAMを取り戻せた一方で、よく紹介される「zramを優先度100、ディスクswapを低優先度」という構成にはLRU反転という設計上の問題があります。カーネル文書とzramctlの実出力をもとにzramの仕組み・mm_statの読み方・swappinessの決め方を整理し、記事後半では実際に同じサーバーをzswapへ切り替えた手順と、916MBのswapoffにかかった時間・zswapの圧縮率3.43倍という実測値も記録しました。

Linuxコンテナの仕組み入門 - namespaceとcgroupsから理解するDocker

Linuxコンテナの仕組み入門 - namespaceとcgroupsから理解するDocker

47

コンテナは軽量な仮想マシンではありません。Linuxカーネルのnamespace・cgroups・capabilities・seccomp・overlayfsという既存機能を組み合わせた「普通のプロセス」です。man7.orgのmanページ、kernel.orgのcgroup-v2ドキュメント、OCI仕様、Docker公式ドキュメントを一次ソースに、8種類のnamespaceの役割、cgroup v1とv2の違い、unshareやlsnsで手を動かす確認手順、runcとcontainerdの関係、そしてコンテナがセキュリティ境界として弱い理由までを積み上げて整理します。