Tech Learning Daily

2026-10-24 (Sat) — 第 99 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Infra 🌿 基礎 ⏱ 約 7 分

スレッドは数本しかないのに、なぜ nginx は何千もの接続を同時に捌けるのか?

Go で書いた API サーバーの conn.Read() は、データが届くまでその場で待つ「ブロックする」呼び出しに見える。それなら接続の数だけスレッドが要りそうだが、nginx は多くの構成で CPU コア数ぶんのワーカープロセスだけで何千もの接続を抱え、Node.js に至ってはメインスレッドが 1 本だ。待っている接続が何千あっても、なぜ少数のスレッドで足りるのだろうか。

🎯 3 行まとめ
  • 1 接続に 1 スレッドを貼り付ける方式は、何もしていない接続のためにもスレッドを抱え続けるので、接続数が増えるとメモリとコンテキストスイッチで頭打ちになる。
  • epoll(macOS では kqueue)は監視したい fd をカーネルに一度登録しておき、「いま読み書きできる fd」だけをまとめて返すので、1 スレッドが大量の接続を順番に捌ける。
  • nginx・Node.js・Go の netpoller はどれもこの仕組みの上にあり、同じ理由で「1 本のループの中で重い処理をすると全接続が止まる」という弱点も共有する。

1 接続 1 スレッドという素朴なモデル

ソケットの read() は、デフォルトではブロッキング I/O(データが届くまで呼び出したスレッドを眠らせて待たせる I/O)だ。この前提で素直にサーバーを書くと、接続を 1 本受け付けるたびにスレッドを 1 本作り、そのスレッドに読み書きを任せる形になる。コードは上から下へ読めて書きやすい。

問題は、HTTP の keep-alive やスマホの遅い回線のせいで、接続の大半は「つながっているが何も送ってこない」時間が長いことだ。待っている間もスレッドはスタックなどのメモリを握ったまま消えない。nginx のエンジニアリングブログは、この方式を「アクティブな HTTP 接続 1 本ごとに専用のプロセスかスレッドが必要」で重いと書き、I/O が多い状況ではコンテキストスイッチ(CPU が実行するスレッドを切り替える処理)が増えて性能が大きく落ちると説明している。1 台で 1 万接続を捌く難しさは、「C10K 問題」と呼ばれてきた。

解決の方向ははっきりしている。「待つ」ことをスレッドに任せるのをやめ、待っている接続の一覧をどこか 1 か所で監視して、準備ができた接続にだけスレッドを向けることだ。

ノンブロッキングにするだけでは足りない

ソケットをノンブロッキングモードにすると、データが無いときの read() は眠らずに即座に EAGAIN(今は無い、あとで試せ)というエラーで返る。これで 1 スレッドが複数の接続を扱えそうに見える。しかし全接続に順番に read() を試すループを回すと、何も届いていない間も CPU を空回しし続けることになる。

欲しいのは「この fd の中で、読み書きできる状態になったものを教えて、それまでは眠らせて」とカーネルに頼む仕組みで、これを I/O 多重化と呼ぶ。fd(ファイルディスクリプタ)はプロセスが開いたファイルやソケットを指す整数の番号だ。最古の API である select() は、監視したい fd をビットの並び(fd_set)にして渡し、カーネルがその中から準備完了のものを探して返す。

select() には 2 つの癖がある。1 つは、man ページが明言するとおり FD_SETSIZE(1024)未満の番号の fd しか監視できないこと。もう 1 つは、渡した fd_set が「準備完了の fd だけ」に上書きされて返るので、ループのたびに監視対象を全部詰め直して渡し直す必要があることだ。監視する接続が増えるほど、毎回の受け渡しと走査が重くなる。man ページ自身が「現代のアプリケーションは代わりに poll(2) か epoll(7) を使うべき」と書いている。poll() は 1024 の制限を取り除いたが、毎回全リストを渡す構造は変わらない。

epoll: 登録は一度、返すのは準備できた分だけ

Linux の epoll は、監視の一覧をカーネル側に持たせることでこの往復をやめた。epoll_create1() で epoll インスタンスを作り、epoll_ctl() で監視したい fd を一度だけインタレストリスト(監視対象の登録簿)に加える。以後、パケットが届くなどの I/O が起きるたびに、カーネル自身がその fd をレディリスト(準備完了の fd の一覧)に載せていく。

1 接続 1 スレッド
 conn1 ── thread1 (read で寝ている)
 conn2 ── thread2 (read で寝ている)
 connN ── threadN (read で寝ている)

epoll(1 スレッド)
┌─ kernel ─────────────────────┐
│ interest list (epoll_ctl)    │
│  conn1  conn2  ...  connN    │
│          │ data arrives      │
│          ▼                   │
│ ready list: conn2            │
└──────────┬───────────────────┘
           │ epoll_wait() が返す
           ▼
 イベントループ: conn2 だけ read

アプリ側のスレッドは epoll_wait() を呼んで眠り、レディリストに何か載ると起こされて、載っていた fd だけを受け取る。1 万接続を監視していても、届いたのが 3 本なら返ってくるのは 3 本だ。監視対象を毎回渡し直す必要がないので、待っているだけの接続はほとんど費用をかけずに置いておける。この「眠って、起こされて、準備できた fd を順に処理して、また眠る」繰り返しがイベントループの正体だ。BSD 系と macOS では kqueue が同じ役割を担う。

🍱 たとえるなら

病棟の看護師だ。select() は、夜勤の看護師が毎回全病室を一部屋ずつ回って「何かありますか」と聞く巡回に近い。患者が増えるほど一周が長くなる。epoll はナースコールで、入院時に各ベッドへボタンを付けておけば(epoll_ctl)、看護師はナースステーションで待ち(epoll_wait)、ランプが点いた部屋(レディリスト)にだけ向かう。病床が倍になっても、呼ばれない部屋に足を運ぶ手間は増えない。

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

epoll の通知には 2 つのモードがある。デフォルトのレベルトリガは「読めるデータが残っている間は毎回通知する」方式で、man ページは「単に速い poll」と説明している。データを半分だけ読んで次の epoll_wait() を呼んでも、残りがあればまた通知されるので取りこぼしは起きにくい。

EPOLLET フラグを付けるとエッジトリガになり、通知は「状態が変わったとき」だけになる。届いたデータの一部しか読まずに次の epoll_wait() を呼ぶと、新しいデータが来ない限り二度と起こされず、残ったデータが宙に浮く。そのため man ページは、fd をノンブロッキングにし、read() が EAGAIN を返すまで読み切ってから次を待つ使い方を推奨している。

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; /* EPOLLET を外せばレベルトリガ */
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);

ここで lead の疑問に戻る。Go の conn.Read() はブロックしているように見えるが、ランタイム内部では fd はノンブロッキングで、エッジトリガで epoll(macOS では kqueue)に登録されている。read() が EAGAIN を返すと、ランタイムはスレッドではなく goroutine だけを止めて脇に置き、その OS スレッドで別の goroutine を走らせる。netpoller と呼ばれるこの部品が準備完了の通知を受けると、止めていた goroutine を再開させる。「ブロックする」のは goroutine であって OS スレッドではない、というのが Go の書きやすさと接続数を両立させている仕掛けだ。Node.js の libuv も同じ API を使っている。

nginx の設定で効くところ

nginx はこの設計を設定にそのまま映している。worker_processes はイベントループを回すワーカープロセスの数で、デフォルトは 1。auto にすると CPU コア数を検出してその数だけ起動する。ワーカーが CPU コアに対応して並ぶので、コンテキストスイッチは仕事が無いときくらいしか起きない。

worker_processes auto;
worker_rlimit_nofile 16384;

events {
    worker_connections 4096;
}

worker_connections は 1 ワーカーが同時に開ける接続数の上限で、デフォルトは 512 だ。公式ドキュメントは 2 つ注意している。この数にはクライアントだけでなくプロキシ先(upstream)との接続も含まれるので、リバースプロキシでは 1 リクエストあたり 2 本消費しうる。また実際の同時接続数はオープンファイル数の上限(RLIMIT_NOFILE)を超えられない。worker_connections だけを上げても fd の上限で頭打ちになるので、worker_rlimit_nofile で上限も一緒に上げる。上の数値は組み合わせ方の例であって推奨値ではない。

💼 実務でどう出会うか

最も出会いやすいのは「1 本のイベントループの中で CPU を食う処理を書いてしまう」事故だ。Node.js のリクエストハンドラで巨大な JSON を同期的にパースしたり重い正規表現を回したりすると、その間ループは次の epoll_wait() に戻れず、準備完了の通知が溜まったまま全接続のレイテンシが一斉に悪化する。もう 1 つは nginx の worker_connections の見積もりで、プロキシ先との接続も数に入ることを忘れていると、捌けるクライアント数を倍に見積もってしまう。strace で nginx や Node.js のプロセスを覗くと、epoll_wait の呼び出しが繰り返し現れるのがわかる。

⌨️ 手を動かす(5 分)

Python 標準の selectors モジュールで、1 スレッドのエコーサーバーを動かし、複数の接続を同時に捌けることを確かめる。DefaultSelector はプラットフォームで最も効率的な実装を選ぶので、Mac では kqueue、Linux では epoll が使われる。

cat > /tmp/echo.py <<'EOF'
import selectors, socket
sel = selectors.DefaultSelector()
print("selector:", type(sel).__name__)

def accept(sock):
    conn, addr = sock.accept()
    print("accepted", addr)
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, read)

def read(conn):
    data = conn.recv(1000)
    if data:
        conn.send(data)
    else:
        sel.unregister(conn)
        conn.close()

sock = socket.socket()
sock.bind(("localhost", 1234))
sock.listen(100)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept)
while True:
    for key, mask in sel.select():
        key.data(key.fileobj)
EOF
python3 /tmp/echo.py

別のターミナルを 2〜3 個開き、それぞれで nc localhost 1234 を実行して文字を打つ。最初に selector: KqueueSelector と表示され、どの nc に打った文字も即座にエコーされるはずだ。さらに別ターミナルで ps -M -p $(pgrep -f /tmp/echo.py) を実行すると、接続が複数あってもスレッドの行は 1 行だけのはずだ。read() の中に import time; time.sleep(5) を足して再実行すると、1 つの接続に文字を打った瞬間、他の接続のエコーも 5 秒止まる。イベントループの弱点がそのまま見える。

🙅 よくある誤解
  • イベントループは 1 スレッドだから CPU 負荷の高い処理にも強い — 強いのは「待ち」が多い処理だけだ。ループは準備完了の fd を 1 つずつ順に処理するので、1 つの処理が CPU を握っている間は他の接続が全部待たされる。CPU の重い処理はワーカースレッドや別プロセスに逃がす必要がある。
  • Go の conn.Read() は OS スレッドをブロックする — ネットワーク I/O の場合、止まるのは goroutine で、OS スレッドは netpoller のおかげで別の goroutine の実行に回される。だから接続ごとに goroutine を作るコードでも、OS スレッドの数は接続数に比例して増えない。
  • worker_connections を上げれば同時接続数はいくらでも増やせる — 実際の上限はプロセスのオープンファイル数の制限で決まり、プロキシ先への接続も同じ枠を消費する。worker_rlimit_nofile や OS 側の上限と揃えて上げないと効かない。
📖 用語ミニ辞典
ブロッキング I/O
データが揃うまで呼び出したスレッドを眠らせて待たせる I/O。
ノンブロッキング I/O
データが無ければ待たずに EAGAIN で即座に返る I/O。
I/O 多重化
多数の fd のうち準備完了のものをカーネルに教えてもらい、1 スレッドで捌く手法。select/poll/epoll/kqueue がこれにあたる。
epoll
監視対象をカーネル側に登録しておき、準備完了の fd だけを返す Linux の I/O 多重化 API。
エッジトリガ / レベルトリガ
状態が変わったときだけ通知するか、準備完了の間は毎回通知するかの違い。epoll のデフォルトはレベルトリガ。
イベントループ
準備完了の通知を待ち、届いた fd を順に処理し、また待つことを繰り返す処理の骨格。
🔗 もっと深く