メインコンテンツまでスキップ

I/O モデル

所要時間: 40分

前提知識: ファイルディスクリプタ、ソケットの基礎

学べること:

  • Blocking I/O vs Non-blocking I/O
  • 同期 vs 非同期
  • select, poll, epoll の違い
  • なぜ Node.js や Nginx が速いのか

この章で答える疑問

「同期と非同期の違いは?」
「Blocking と Non-blocking の違いは?」
「epoll って何?」
「なぜ Node.js や Nginx は少ないスレッドで高性能?」

1. I/O とは

1.1 I/O の種類

┌─────────────────────────────────────────────────────────────────────┐
│ I/O (Input/Output) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ファイル I/O: │
│ ├── ディスクからの読み込み │
│ └── ディスクへの書き込み │
│ │
│ ネットワーク I/O: │
│ ├── ソケットからのデータ受信 │
│ └── ソケットへのデータ送信 │
│ │
│ 共通点: │
│ └── CPU から見ると「待ち時間」が発生する │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 速度の違い(概算) │ │
│ │ │ │
│ │ CPU演算 : 1 ns │ │
│ │ L1キャッシュ : 1 ns │ │
│ │ L2キャッシュ : 4 ns │ │
│ │ メモリ : 100 ns │ │
│ │ SSD : 100,000 ns (0.1 ms) │ │
│ │ HDD : 10,000,000 ns (10 ms) │ │
│ │ ネットワーク : 1,000,000〜100,000,000 ns (1ms〜100ms) │ │
│ │ │ │
│ │ → I/O は CPU の 10万〜1億倍遅い │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

1.2 I/O 待ちの問題

┌─────────────────────────────────────────────────────────────────────┐
│ I/O 待ちの無駄 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ DB クエリを実行する場合: │
│ │
│ 時間軸 ───────────────────────────────────────────────────→ │
│ │
│ CPU │██│ │██│ │
│ │処│ │結│ │
│ │理│ 待ち時間(無駄) │果│ │
│ └──┘ └──┘ │
│ ↓ ↓ │
│ リクエスト送信 レスポンス受信 │
│ │
│ 問題: CPU は I/O 完了を待っている間、何もしていない │
│ → この待ち時間を有効活用したい │
│ │
└─────────────────────────────────────────────────────────────────────┘

2. Blocking I/O と Non-blocking I/O

2.1 Blocking I/O

┌─────────────────────────────────────────────────────────────────────┐
│ Blocking I/O(デフォルト) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ read() を呼ぶと、データが届くまでスレッドが止まる │
│ │
│ スレッド カーネル ネットワーク │
│ │ │ │
│ │ read(fd) │ │
│ │──────────────→│ │ │
│ │ │ データ待ち │ │
│ │ (ブロック) │←─────────────────│ データ到着 │
│ │ │ コピー │ │
│ │←──────────────│ │ │
│ │ データ返却 │ │
│ ▼ │ │
│ │
│ 特徴: │
│ ├── 実装が簡単(順番に書くだけ) │
│ ├── データが届くまでスレッドが止まる │
│ └── 多数の接続を扱うには多数のスレッドが必要 │
│ │
└─────────────────────────────────────────────────────────────────────┘

2.2 Non-blocking I/O

┌─────────────────────────────────────────────────────────────────────┐
│ Non-blocking I/O │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ read() を呼んでもブロックせず、すぐに戻る │
│ │
│ スレッド カーネル ネットワーク │
│ │ │ │
│ │ read(fd) │ │
│ │──────────────→│ │ │
│ │←──────────────│ EWOULDBLOCK │ ← データがない │
│ │ │ │ │
│ │ 他の処理 │ │ │
│ │ │ │ │
│ │ read(fd) │ │ │
│ │──────────────→│ │ │
│ │←──────────────│ EWOULDBLOCK │ ← まだない │
│ │ │ │ │
│ │ 他の処理 │←─────────────────│ データ到着 │
│ │ │ │ │
│ │ read(fd) │ │ │
│ │──────────────→│ │ │
│ │←──────────────│ データ │ ← 今度はある │
│ ▼ │
│ │
│ 特徴: │
│ ├── ブロックしないので他の処理ができる │
│ ├── ポーリング(何度も確認)が必要 │
│ └── CPU を無駄に使う可能性 │
│ │
└─────────────────────────────────────────────────────────────────────┘

2.3 設定方法

// Non-blocking に設定
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// read() の結果
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// データがまだない(エラーではない)
} else {
// 本当のエラー
}
}

3. 同期 vs 非同期

3.1 用語の整理

┌─────────────────────────────────────────────────────────────────────┐
│ Blocking/Non-blocking と Sync/Async │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ この2つは別の概念: │
│ │
│ Blocking / Non-blocking: │
│ └── I/O 操作を呼んだときにスレッドが止まるかどうか │
│ │
│ Synchronous / Asynchronous: │
│ └── データの準備ができたことを誰が通知するか │
│ ├── Sync: アプリが確認しに行く(polling) │
│ └── Async: カーネルが通知してくる(callback/event) │
│ │
│ 組み合わせ: │
│ ┌────────────────┬────────────────────┬─────────────────────┐ │
│ │ │ Blocking │ Non-blocking │ │
│ ├────────────────┼────────────────────┼─────────────────────┤ │
│ │ Synchronous │ 従来の read/write │ ポーリング │ │
│ │ │ (一般的) │ │ │
│ ├────────────────┼────────────────────┼─────────────────────┤ │
│ │ Asynchronous │ (存在しない) │ epoll, kqueue │ │
│ │ │ │ io_uring, IOCP │ │
│ └────────────────┴────────────────────┴─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

4. I/O 多重化

4.1 問題: 複数のソケットを監視したい

┌─────────────────────────────────────────────────────────────────────┐
│ 複数接続の処理 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ サーバーは複数のクライアントから接続を受ける: │
│ │
│ クライアントA ──────→ ┌───────────────┐ │
│ クライアントB ──────→ │ サーバー │ │
│ クライアントC ──────→ │ │ │
│ ... │ FD: 4, 5, 6...│ │
│ クライアントN ──────→ └───────────────┘ │
│ │
│ 方法1: スレッドを複数作る │
│ ├── 1接続 = 1スレッド │
│ ├── Blocking I/O でも OK │
│ └── 問題: 10,000 接続 = 10,000 スレッド(重い) │
│ │
│ 方法2: I/O 多重化 │
│ ├── 1スレッドで複数の FD を監視 │
│ ├── どれかの FD が ready になったら処理 │
│ └── 少ないスレッドで大量の接続を処理 │
│ │
└─────────────────────────────────────────────────────────────────────┘

4.2 select

┌─────────────────────────────────────────────────────────────────────┐
│ select (古い方式) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 使い方: │
│ 1. 監視したい FD をセットに登録 │
│ 2. select() を呼ぶ(ブロック) │
│ 3. どれかの FD が ready になったら戻る │
│ 4. どの FD が ready か全部チェック │
│ │
│ fd_set readfds; │
│ FD_ZERO(&readfds); │
│ FD_SET(fd1, &readfds); │
│ FD_SET(fd2, &readfds); │
│ FD_SET(fd3, &readfds); │
│ │
│ select(maxfd + 1, &readfds, NULL, NULL, NULL); │
│ │
│ // どれが ready か全部チェック │
│ if (FD_ISSET(fd1, &readfds)) { ... } │
│ if (FD_ISSET(fd2, &readfds)) { ... } │
│ if (FD_ISSET(fd3, &readfds)) { ... } │
│ │
│ 問題: │
│ ├── FD 数の上限が 1024(FD_SETSIZE) │
│ ├── 毎回全部の FD をカーネルにコピー │
│ ├── 戻ってきたら全部の FD をチェック: O(n) │
│ └── FD 数が増えると非常に遅くなる │
│ │
└─────────────────────────────────────────────────────────────────────┘

4.3 poll

┌─────────────────────────────────────────────────────────────────────┐
│ poll (select の改良版) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ struct pollfd fds[3]; │
│ fds[0].fd = fd1; fds[0].events = POLLIN; │
│ fds[1].fd = fd2; fds[1].events = POLLIN; │
│ fds[2].fd = fd3; fds[2].events = POLLIN; │
│ │
│ poll(fds, 3, -1); // -1 = 無限に待つ │
│ │
│ if (fds[0].revents & POLLIN) { ... } │
│ if (fds[1].revents & POLLIN) { ... } │
│ if (fds[2].revents & POLLIN) { ... } │
│ │
│ 改善点: │
│ ├── FD 数の上限がない │
│ └── ビットマスクではなく配列で管理 │
│ │
│ 残る問題: │
│ ├── 毎回全部の FD をカーネルにコピー │
│ └── 戻ってきたら全部の FD をチェック: O(n) │
│ │
└─────────────────────────────────────────────────────────────────────┘

4.4 epoll (Linux)

┌─────────────────────────────────────────────────────────────────────┐
│ epoll (Linux の高性能 I/O 多重化) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ // 1. epoll インスタンスを作成 │
│ int epfd = epoll_create1(0); │
│ │
│ // 2. 監視する FD を登録(1回だけ) │
│ struct epoll_event ev; │
│ ev.events = EPOLLIN; │
│ ev.data.fd = fd1; │
│ epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev); │
│ // fd2, fd3 も同様に登録 │
│ │
│ // 3. イベントを待つ │
│ struct epoll_event events[10]; │
│ int n = epoll_wait(epfd, events, 10, -1); │
│ │
│ // 4. ready な FD だけが返される │
│ for (int i = 0; i < n; i++) { │
│ int fd = events[i].data.fd; │
│ // この fd は必ず ready │
│ } │
│ │
│ 利点: │
│ ├── FD の登録は1回だけ(毎回コピーしない) │
│ ├── ready な FD だけが返される: O(1) per ready FD │
│ ├── 数万の接続でも効率的 │
│ └── Edge Triggered / Level Triggered を選べる │
│ │
└─────────────────────────────────────────────────────────────────────┘

4.5 比較

┌─────────────────────────────────────────────────────────────────────┐
│ select / poll / epoll の比較 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┬────────────┬────────────┬────────────┐ │
│ │ │ select │ poll │ epoll │ │
│ ├───────────┼────────────┼────────────┼────────────┤ │
│ │ FD上限 │ 1024 │ なし │ なし │ │
│ ├───────────┼────────────┼────────────┼────────────┤ │
│ │ 毎回の │ O(n) │ O(n) │ O(1) │ │
│ │ FDコピー │ 全FDコピー │ 全FDコピー │ 不要 │ │
│ ├───────────┼────────────┼────────────┼────────────┤ │
│ │ 戻り値の │ O(n) │ O(n) │ O(ready) │ │
│ │ 確認 │ 全FDチェック│ 全FDチェック│ readyだけ │ │
│ ├───────────┼────────────┼────────────┼────────────┤ │
│ │ 計算量 │ O(n) │ O(n) │ O(1) │ │
│ │ (per wait)│ │ │ │ │
│ └───────────┴────────────┴────────────┴────────────┘ │
│ │
│ n = 監視している FD の数 │
│ ready = イベントが発生した FD の数 │
│ │
│ 10,000 接続で 10 件の ready: │
│ ├── select/poll: 10,000 回のチェック │
│ └── epoll: 10 回のチェック │
│ │
└─────────────────────────────────────────────────────────────────────┘

4.6 他の OS

┌─────────────────────────────────────────────────────────────────────┐
│ OS ごとの I/O 多重化 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Linux : epoll │
│ BSD/Mac : kqueue │
│ Windows : IOCP (I/O Completion Ports) │
│ Linux : io_uring (新しい、さらに高性能) │
│ │
│ 多くのフレームワークはこれらを抽象化: │
│ ├── libuv (Node.js) │
│ ├── libevent │
│ ├── libev │
│ └── Netty (Java) │
│ │
└─────────────────────────────────────────────────────────────────────┘

5. イベント駆動モデル

5.1 Reactor パターン

┌─────────────────────────────────────────────────────────────────────┐
│ Reactor パターン │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1つのスレッドでイベントループを回す: │
│ │
│ while (true) { │
│ // 1. イベントを待つ(epoll_wait) │
│ events = wait_for_events(); │
│ │
│ // 2. 各イベントを処理 │
│ for (event in events) { │
│ if (event.type == ACCEPT) { │
│ // 新しい接続を受け入れ │
│ new_fd = accept(listen_fd); │
│ register(new_fd); │
│ } │
│ else if (event.type == READ) { │
│ // データを読んで処理 │
│ data = read(event.fd); │
│ process(data); │
│ } │
│ else if (event.type == WRITE) { │
│ // データを書き込み │
│ write(event.fd, response); │
│ } │
│ } │
│ } │
│ │
│ 使用例: Node.js, Redis, Nginx (worker) │
│ │
└─────────────────────────────────────────────────────────────────────┘

5.2 Node.js の仕組み

┌─────────────────────────────────────────────────────────────────────┐
│ Node.js のイベントループ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ シングルスレッド + 非同期 I/O: │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ メインスレッド │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ イベントループ (libuv) │ │ │
│ │ │ │ │ │
│ │ │ 1. timers (setTimeout, setInterval) │ │ │
│ │ │ 2. pending (I/O コールバック) │ │ │
│ │ │ 3. poll (I/O イベントを待つ) │ │ │
│ │ │ 4. check (setImmediate) │ │ │
│ │ │ 5. close (close イベント) │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ I/O 操作は OS に委譲 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ libuv / OS │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ epoll │ │ DNS │ │ File │ │ │
│ │ │ (network) │ │ (別スレッド)│ │ (別スレッド) │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ なぜ速い? │
│ ├── I/O 待ちでブロックしない │
│ ├── 1スレッドなのでコンテキストスイッチなし │
│ └── epoll で効率的にイベントを処理 │
│ │
│ 注意点: │
│ ├── CPU集約的な処理はイベントループをブロック │
│ └── Worker Threads で別スレッドに逃がす必要あり │
│ │
└─────────────────────────────────────────────────────────────────────┘

5.3 Nginx の仕組み

┌─────────────────────────────────────────────────────────────────────┐
│ Nginx のアーキテクチャ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ マスター + ワーカープロセス: │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Master Process │ │
│ │ ├── 設定の読み込み │ │
│ │ ├── ワーカーの管理 │ │
│ │ └── シグナル処理 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Worker 1 │ │ Worker 2 │ │ Worker N │ │
│ │ │ │ │ │ │ │
│ │ イベント │ │ イベント │ │ イベント │ │
│ │ ループ │ │ ループ │ │ ループ │ │
│ │ (epoll) │ │ (epoll) │ │ (epoll) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ worker_processes = CPUコア数 │
│ │
│ 各ワーカーが数千〜数万の接続を処理可能: │
│ ├── 非同期 I/O + epoll │
│ ├── シングルスレッド(ワーカー内) │
│ └── スレッド間の競合なし │
│ │
│ Apache (prefork) との違い: │
│ ├── Apache: 1接続 = 1プロセス │
│ └── Nginx: 1ワーカー = 数千接続 │
│ │
└─────────────────────────────────────────────────────────────────────┘

6. Linux での確認

6.1 strace で I/O を確認

# システムコールをトレース
$ strace -e read,write,epoll_wait curl http://example.com

epoll_wait(3, [{EPOLLIN, {u32=3, u64=3}}], 1, 0) = 1
read(3, "HTTP/1.1 200 OK\r\nContent-Type: "..., 16384) = 1256
write(1, "<!DOCTYPE html>\n<html>\n<head>\n "..., 1256) = 1256

# -c で統計情報
$ strace -c curl http://example.com 2>&1 | grep -E "^%|epoll|read|write"
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
45.12 0.000234 11 21 read
32.45 0.000168 8 21 write
12.34 0.000064 16 4 epoll_wait

6.2 プロセスの I/O モデルを確認

# epoll を使っているか確認
$ lsof -p $(pgrep nginx) | grep -E "eventpoll|epoll"
nginx 12345 root 3u a]_eventpoll 0,13 0t0 12345 [eventfd]

# /proc で確認
$ ls -la /proc/$(pgrep nginx)/fd | grep eventpoll
lrwx------ 1 root root 64 Jan 1 12:00 3 -> anon_inode:[eventpoll]

7. Java の I/O モデル

7.1 Java NIO

// Java NIO (Non-blocking I/O)
Selector selector = Selector.open();

ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // Non-blocking
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
selector.select(); // epoll_wait 相当

Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
// 新しい接続
} else if (key.isReadable()) {
// データ受信可能
}
}
}

7.2 Virtual Thread との関係

┌─────────────────────────────────────────────────────────────────────┐
│ Virtual Thread と I/O │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Virtual Thread は Blocking I/O を書きながら Non-blocking の │
│ メリットを得られる: │
│ │
│ // コードは Blocking I/O のように書く │
│ Thread.startVirtualThread(() -> { │
│ InputStream in = socket.getInputStream(); │
│ byte[] data = in.readAllBytes(); // ブロックするように見える │
│ process(data); │
│ }); │
│ │
│ // 内部では: │
│ // 1. read() が OS Thread をブロックしようとする │
│ // 2. JVM が Virtual Thread をアンマウント │
│ // 3. OS Thread は他の Virtual Thread を実行 │
│ // 4. データが届いたら Virtual Thread をリマウント │
│ │
│ 利点: │
│ ├── シンプルな Blocking スタイルのコード │
│ ├── 内部的には Non-blocking の効率性 │
│ └── NIO の複雑さを避けられる │
│ │
└─────────────────────────────────────────────────────────────────────┘

8. まとめ

┌─────────────────────────────────────────────────────────────────────┐
│ この章で学んだこと │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Blocking vs Non-blocking │
│ ├── Blocking: read/write でスレッドが止まる │
│ └── Non-blocking: すぐ戻る、データがなければ EWOULDBLOCK │
│ │
│ 2. 同期 vs 非同期 │
│ ├── 同期: アプリがデータの準備を確認 │
│ └── 非同期: カーネルが通知 │
│ │
│ 3. I/O 多重化 │
│ ├── select/poll: O(n)、遅い │
│ └── epoll: O(1)、高性能 │
│ │
│ 4. イベント駆動モデル │
│ ├── 1スレッドで多数の接続を処理 │
│ └── Node.js, Nginx が採用 │
│ │
│ 5. Java │
│ ├── NIO: Selector で epoll 相当 │
│ └── Virtual Thread: Blocking スタイルで Non-blocking の効率 │
│ │
└─────────────────────────────────────────────────────────────────────┘

確認問題

  1. Blocking I/O と Non-blocking I/O の違いは?
  2. select と epoll の性能差の理由は?
  3. Node.js がシングルスレッドで高性能な理由は?
  4. Nginx と Apache の違いを I/O の観点から説明してください
  5. Virtual Thread が Blocking I/O でも効率的な理由は?

次のステップ