Skip to content

✍️ 필사 모드: 非同期 I/O モデル完全ガイド 2025: epoll、io_uring、Reactor/Proactor、async/await の内部

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

TL;DR

  • 非同期 I/O = 現代サーバの基盤: Nginx、Node.js、Redis、あらゆる高性能サーバ。
  • 進化: select → poll → epoll/kqueue → io_uring。
  • Reactor vs Proactor: イベント通知か、作業完了通知か。
  • io_uring: Linux 5.1+ の革命。epoll より効率的で、真の async。
  • async/await: コンパイラが state machine に変換する。

1. I/O モデルの進化

1.1 5 つの I/O モデル

POSIX の分類:

  1. Blocking I/O: 作業完了まで待機。
  2. Non-blocking I/O: 即座に返り、ポーリング。
  3. I/O Multiplexing: select/poll/epoll。
  4. Signal-driven I/O: シグナルで通知。
  5. Asynchronous I/O: 真の async(POSIX AIO、io_uring)。

1.2 Blocking I/O — 最もシンプル

data = sock.recv(1024)  # データが来るまでブロック
process(data)

問題点:

  • 1 スレッド = 1 接続。
  • 1 万接続 = 1 万スレッド(メモリ爆発)。
  • C10K 問題。

1.3 マルチスレッドで解決?

def handle_client(sock):
    data = sock.recv(1024)
    process(data)

while True:
    client = server.accept()
    threading.Thread(target=handle_client, args=(client,)).start()

限界:

  • スレッドあたりメモリ(1〜8 MB)。
  • コンテキストスイッチのコスト。
  • 1 万スレッド = 死。

1.4 Non-blocking I/O

sock.setblocking(False)
try:
    data = sock.recv(1024)
except BlockingIOError:
    pass  # データなし、他の処理へ

問題: 無限ループポーリングで CPU 100%。

1.5 I/O Multiplexing の登場

アイデア: 1 つの syscall で複数の fd を同時に監視する。

ready, _, _ = select([sock1, sock2, sock3], [], [])
for sock in ready:
    data = sock.recv(1024)

単一スレッドで大量の接続を捌ける


2. select と poll

2.1 select (1983)

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);

int n = select(max_fd + 1, &readfds, NULL, NULL, &timeout);

if (FD_ISSET(sock, &readfds)) {
    // 読み取り可能
}

問題点:

  • FD_SETSIZE 制限(通常 1024)。
  • O(n) スキャン: 毎回すべての fd を検査。
  • fd_set コピー: 呼び出しごとにユーザ ↔ カーネル。

2.2 poll (1986)

struct pollfd fds[1024];
fds[0].fd = sock;
fds[0].events = POLLIN;

int n = poll(fds, 1024, timeout);

if (fds[0].revents & POLLIN) {
    // 読み取り可能
}

改善点:

  • fd 数の上限なし
  • 動的配列。

依然として残る問題:

  • O(n) スキャン
  • 呼び出しごとに fd リストを渡す。

2.3 select/poll の限界

1 万接続 + アクティブ 100 の場合:

  • select/poll は毎回 1 万をスキャン。
  • CPU の無駄

新しい機構が必要


3. epoll — Linux の革命

3.1 登場 (2002, Linux 2.5.44)

int epfd = epoll_create1(0);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);

for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    // 処理
}

3.2 epoll の強み

1. O(1) イベント検知:

  • カーネルは ready 状態の fd だけを返す。
  • 1 万接続 + アクティブ 100 → 100 件だけ返る。

2. 登録は一度だけ:

  • epoll_ctl で fd を登録する。
  • 毎回渡す必要なし。

3. トリガモード:

  • Level-Triggered (LT): データがある限り通知し続ける(デフォルト)。
  • Edge-Triggered (ET): 状態が変わったときのみ通知(Nginx)。

3.3 ET vs LT

LT (Level-Triggered):

// データが 16KB あり、8KB だけ読む → 次の epoll_wait でも通知される

扱いやすいが、少し遅い。

ET (Edge-Triggered):

// 新しいデータが到着したときだけ通知される → すべて読み切る必要あり
while (true) {
    n = recv(fd, buf, sizeof(buf), 0);
    if (n < 0 && errno == EAGAIN) break;  // 読み切った
    process(buf, n);
}

速いが、処理漏れのリスクがある。

3.4 epoll を使うシステム

  • Nginx — 中核。
  • Node.js (libuv)。
  • Redis
  • HAProxy
  • Memcached
  • ほぼすべての Linux 高性能サーバ。

3.5 他 OS の同等機能

OSAPI
Linuxepoll
macOS/BSDkqueue
WindowsIOCP
Solaris/dev/poll (deprecated), event ports

4. kqueue (BSD/macOS)

4.1 epoll より強力

int kq = kqueue();

struct kevent change;
EV_SET(&change, sock, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);

struct kevent events[64];
int n = kevent(kq, NULL, 0, events, 64, NULL);

epoll より豊富:

  • ファイル変更 (EVFILT_VNODE)
  • シグナル (EVFILT_SIGNAL)
  • タイマー (EVFILT_TIMER)
  • プロセス終了 (EVFILT_PROC)
  • ディスク I/O など。

欠点: epoll よりユーザが少ない。


5. io_uring — 新たな革命

5.1 epoll の限界

epoll にも限界がある:

  • 同期 I/O: epoll_wait のあとに read/write の呼び出しが必要。
  • syscall コスト: 毎回ユーザ ↔ カーネル。
  • 真の async ではない: read/write 自体がブロックしうる。

5.2 io_uring (2019, Linux 5.1)

Jens Axboe(Linux カーネル開発者)が作成。真の async I/O

核心:

  • 2 つの ring buffer(Submission、Completion)。
  • 共有メモリ(ユーザ ↔ カーネル)。
  • syscall ほぼゼロ
[User Space]                    [Kernel Space]
[Submission Queue (SQ)] ────→ [Worker]
[Completion Queue (CQ)] ←──── [I/O 完了]

5.3 使用例

struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

// 作業を提出
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_submit(&ring);

// 完了待ち
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// cqe->res = 読み取ったバイト数
io_uring_cqe_seen(&ring, cqe);

5.4 io_uring の強み

1. 真の async:

  • ディスク I/O もブロックしない。
  • ネットワーク I/O と統合。

2. 少ない syscall:

  • バッチ提出。
  • 一部のモードでは syscall 0 も可能。

3. 多彩な操作:

  • read、write、send、recv。
  • accept、connect。
  • fsync、fallocate。
  • splice、tee。
  • statx、openat、close。

4. より高速:

  • epoll 比で 30〜50% 向上(特定ワークロード)。

5.5 io_uring の採用事例

  • Nginx 1.21+: オプションでサポート。
  • PostgreSQL 17: 一部で使用。
  • Tokio (Rust): io-uring バックエンド。
  • ScyllaDB: 中核技術。
  • Cloud Hypervisor

5.6 io_uring の未来

  • eBPF 統合
  • NVMe 直接アクセス
  • GPU/FPGA I/O
  • すべての syscall を async に

io_uring は Linux I/O の未来である。


6. Reactor vs Proactor パターン

6.1 Reactor パターン

[Event Loop]
   ↓ epoll_wait
[Event: fd=5 readable]
[Handler]
[read(5, ...)]  ← ユーザが直接 read

特徴:

  • イベント発生の通知。
  • ユーザが read/write を呼ぶ。
  • epoll、kqueue ベース。

: Nginx、Node.js、Redis。

6.2 Proactor パターン

[User] → 「この fd から 1024 byte 読んで」
[Kernel] → 非同期処理
[Completion: データ準備完了]
[Handler] → ユーザが受け取る

特徴:

  • 作業そのものをカーネルに委譲。
  • 完了時に通知。
  • IOCP、io_uring ベース。

: ASIO (Boost)、Windows IOCP、io_uring。

6.3 比較

ReactorProactor
イベント「ready」「completed」
読み取りユーザが呼ぶカーネルが担当
OS サポート広範(epoll、kqueue)限定的(io_uring、IOCP)
抽象化シンプル複雑
性能良いより良い(理論上)

6.4 ASIO の統合モデル

Boost.ASIO(C++)は Reactor と Proactor を同じインターフェースで提供する:

async_read(socket, buffer, [](error_code ec, size_t bytes) {
    // 完了時に呼ばれる
});

内部的には、Linux は epoll、Windows は IOCP、最新版では io_uring。


7. async/await の内部

7.1 async/await は何をしているのか

async def fetch_data():
    data = await http_request("https://api.example.com")
    return process(data)

これは魔法ではない。コンパイラが state machine に変換している。

7.2 変換結果(擬似コード)

class FetchDataStateMachine:
    state = 0

    def resume(self):
        if self.state == 0:
            self.future = http_request("https://api.example.com")
            self.future.set_callback(self.resume)
            self.state = 1
            return  # 一時停止

        if self.state == 1:
            data = self.future.result()
            return process(data)

核心:

  • await で関数を一時停止。
  • 結果が準備できたら再開。
  • スタックをイベントループに譲る。

7.3 イベントループ

loop = asyncio.get_event_loop()

while True:
    # 1. ready キューのタスクを実行
    while ready_queue:
        task = ready_queue.pop()
        task.run()

    # 2. epoll_wait で I/O イベント待ち
    events = epoll.wait(timeout)

    # 3. 待機していたタスクを ready キューへ
    for event in events:
        task = pending[event.fd]
        ready_queue.append(task)

7.4 Python asyncio

import asyncio

async def main():
    # 並行実行
    results = await asyncio.gather(
        fetch_user(1),
        fetch_user(2),
        fetch_user(3),
    )
    return results

asyncio.run(main())

内部:

  • selectors モジュール(epoll/kqueue/select の抽象化)。
  • イベントループ。
  • Task と Future。
  • Coroutine。

7.5 JavaScript (Node.js)

async function main() {
    const data = await fetch("https://api.example.com")
    const json = await data.json()
    console.log(json)
}

内部:

  • libuv(C ライブラリ)。
  • イベントループ。
  • epoll/kqueue/IOCP。

7.6 Rust Tokio

#[tokio::main]
async fn main() {
    let data = fetch("https://api.example.com").await;
    println!("{:?}", data);
}

内部:

  • mio(epoll/kqueue の抽象化)。
  • io-uring オプション。
  • M:N スケジューリング(複数 OS スレッド)。
  • Work-stealing。

8. シングルスレッド vs マルチスレッド

8.1 シングルスレッド(Node.js、Redis、Nginx)

[1 スレッド]
[Event Loop] (epoll)
[Callback 1] [Callback 2] [Callback 3]

利点:

  • ロックなし(race condition なし)。
  • シンプル。
  • メモリ効率がよい。

欠点:

  • CPU 作業でブロックする。
  • シングルコアしか使えない。

8.2 マルチスレッド + イベントループ

[N スレッド(CPU コア数)]
[Event Loop per thread]
[Work-stealing]

: Tokio (Rust)、Akka (JVM)、Kotlin Coroutines。

利点:

  • マルチコアを活用。
  • 単一プロセスで数十万の同時タスク。

欠点:

  • 同期が必要。
  • デバッグが難しい。

8.3 SO_REUSEPORT

Linux 3.9+ の機能:

setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));

複数プロセスが同じポートを listen:

  • カーネルが自動でロードバランス。
  • 各プロセスが独立した epoll を持つ。
  • Nginx、HAProxy が使用。
[Port 80]
   ├─ [Worker 1] (epoll)
   ├─ [Worker 2] (epoll)
   └─ [Worker 3] (epoll)

9. 性能比較

9.1 1 万接続の処理

モデルメモリスループット
Thread per connection約 10 GB低い
select少ない非常に低い
poll少ない低い
epoll少ない高い
io_uring少ない非常に高い

9.2 実ベンチマーク

HTTP サーバ(10K connections):

Apache (prefork):    5,000 req/s
Apache (worker):     20,000 req/s
Nginx (epoll):       100,000 req/s
Nginx (io_uring):    150,000 req/s

ScyllaDB (io_uring):

  • Cassandra 比で 10 倍以上のスループット。
  • 単一ノードで 100 万+ ops/s。

9.3 NewSQL / ストレージ

ScyllaDB の秘密:

  • io_uring + DPDK。
  • コアごとの shard。
  • ロックなし。
  • ユーザ空間 TCP スタック。

10. 実戦 — 高性能サーバを作る

10.1 Python (asyncio)

import asyncio

async def handle_client(reader, writer):
    while True:
        data = await reader.read(1024)
        if not data:
            break
        writer.write(data)
        await writer.drain()
    writer.close()

async def main():
    server = await asyncio.start_server(handle_client, '0.0.0.0', 8080)
    async with server:
        await server.serve_forever()

asyncio.run(main())

内部的には selectors → epoll を使う。

10.2 Rust (Tokio)

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();

    loop {
        let (mut socket, _) = listener.accept().await.unwrap();

        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = socket.read(&mut buf).await.unwrap();
                if n == 0 { return }
                socket.write_all(&buf[..n]).await.unwrap();
            }
        });
    }
}

Tokio が裏で担当:

  • mio (epoll)。
  • M:N スケジューリング。
  • Work-stealing。

10.3 Go

listener, _ := net.Listen("tcp", ":8080")

for {
    conn, _ := listener.Accept()
    go handle(conn)
}

func handle(conn net.Conn) {
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)
        if err != nil { return }
        conn.Write(buf[:n])
    }
}

Go runtime が裏で担当:

  • netpoll (epoll/kqueue)。
  • Goroutine スケジューリング。
  • チャネルとの統合。

10.4 比較

Python asyncioRust TokioGo
構文async/awaitasync/awaitgo func()
性能ふつう最高非常に良い
学習コスト低い低い
メモリふつう非常に少ない少ない
推奨素早いプロトタイプ最高性能バランス

クイズ

1. select と epoll の核心的な違いは?

答え: select: 呼び出しごとにすべての fd をスキャン(O(n))、FD_SETSIZE 制限(通常 1024)、fd_set をユーザ/カーネル間でコピー。epoll: イベントが発生した fd だけを返す(O(1))、fd 数の制限なし、epoll_ctl で一度だけ登録。1 万接続 + アクティブ 100 件の場合、select は毎回 1 万をスキャンするが、epoll は 100 件だけ返す。Nginx、Node.js、Redis が epoll ベースである理由

2. io_uring が epoll より優れているのはなぜ?

答え: epoll にも限界がある — 同期 read/write(epoll_wait 後に直接呼ぶ必要)、syscall コスト、ディスク I/O は依然としてブロックしうる。io_uring: (1) 2 つの ring buffer (SQ/CQ) + 共有メモリ、(2) syscall ほぼゼロ(バッチ提出)、(3) 真の async(ディスク I/O もブロックしない)、(4) read/write 以外に accept、connect、fsync など多彩な操作。30〜50% 高速。ScyllaDB、PostgreSQL 17 が採用。Linux I/O の未来

3. Reactor と Proactor パターンの違いは?

答え: Reactor(epoll ベース): イベントループが「fd が ready」を通知 → ユーザが read を呼ぶ。Proactor(io_uring、IOCP ベース): ユーザが「このデータを読んで」と依頼 → カーネルが処理 → 完了時に通知。Reactor はユーザが動く、Proactor はカーネルが動く。Boost.ASIO は両パターンを同じインターフェースで抽象化する。

4. async/await はどう動くのか?

答え: 魔法ではない。コンパイラが state machine に変換するawait のたびに関数が一時停止し、future/promise にコールバックを登録。結果が用意できたら再開。イベントループは (1) ready キューのタスクを実行、(2) epoll_wait で I/O を待機、(3) 完了したタスクを ready キューに戻す。Python asyncio、JS Node.js、Rust Tokio、Go の goroutine はすべてこのパターンに従う。

5. SO_REUSEPORT とは何で、どう使うのか?

答え: Linux 3.9+ の機能。複数プロセスが同じポートを同時に listen できる。カーネルが新規接続を自動で分散。各プロセスは自前の epoll インスタンスを持つ → ロックなしで真の並列処理。Nginx、HAProxy が利用 — 各ワーカープロセスが同じポートを listen し、カーネルがロードバランスする。単一プロセスの限界を回避する。CPU コア数に等しいワーカー数が一般的。


参考資料

현재 단락 (1/408)

- **非同期 I/O = 現代サーバの基盤**: Nginx、Node.js、Redis、あらゆる高性能サーバ。

작성 글자: 0원문 글자: 11,030작성 단락: 0/408