- Published on
非同期 I/O モデル完全ガイド 2025: epoll、io_uring、Reactor/Proactor、async/await の内部
- Authors

- Name
- Youngju Kim
- @fjvbn20031
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 の分類:
- Blocking I/O: 作業完了まで待機。
- Non-blocking I/O: 即座に返り、ポーリング。
- I/O Multiplexing: select/poll/epoll。
- Signal-driven I/O: シグナルで通知。
- 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 の同等機能
| OS | API |
|---|---|
| Linux | epoll |
| macOS/BSD | kqueue |
| Windows | IOCP |
| 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 比較
| Reactor | Proactor | |
|---|---|---|
| イベント | 「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 asyncio | Rust Tokio | Go | |
|---|---|---|---|
| 構文 | async/await | async/await | go 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 コア数に等しいワーカー数が一般的。
参考資料
- The C10K Problem — Dan Kegel
- epoll man page
- io_uring 紹介 — Jens Axboe
- tokio — Rust async runtime
- Boost.ASIO
- libuv — Node.js の基盤
- Nginx Event Loop
- What is io_uring?
- ScyllaDB — io_uring を使用
- The Reactor Pattern
- Python asyncio Internals