1.4 The Architecture: Master, Workers, and the Event Loop
The most important conceptual lesson in the series. Everything from reload semantics to load-balancer behavior traces back to what you learn here.
Basic
What you will learn
The process family: master, workers, and the two cache helpers.
What the master does — and the three things only it may do.
How a worker's event loop actually works (a traced walk-through).
Worker capacity math:
worker_processes × worker_connectionsand what a "connection" really is.The shared-memory seam: what workers share, what they don't, and why.
What happens when a worker dies — demonstrated, not theorized.
1. The process family
Here is the real process table of a running instance (a 2-core machine, so
worker_processes auto → 2 workers):
$ ps -o pid,ppid,user,etime,args \
--pid $(cat /run/nginx.pid) \
--ppid $(cat /run/nginx.pid)
PID PPID USER ELAPSED COMMAND
2397 1 z 00:12 nginx: master process /…/nginx -p /srv/edge/ -c conf/nginx.conf
2449 2397 z 00:12 nginx: worker process
2450 2397 z 00:12 nginx: worker processRead three facts from that output:
The master's parent is PID 1 — it daemonized away from the shell that launched it (Lesson 1.5 shows you how to keep it in the foreground).
Workers are children of the master, and their process title has been rewritten to advertise their role (
nginx: worker process).There is one worker per CPU core —
autoasked the kernel, got2, and spawned exactly two.
Two more family members appear only when proxy caching (Module 8) is on:
cache manager — periodically trims the on-disk cache back to its size limits (
managerin the title);cache loader — one-shot at startup, walks the cache directory to "warm" the in-memory index so a restart doesn't forget what's cached.
Neither serves traffic. When you see
nginx: cache manager process in
ps you can infer that instance runs
proxy_cache without reading its config.
2. What the master does (and never does)
The master is small, privileged, and boring — deliberately. Its whole job list:
Master responsibility | Why it must be the master |
|---|---|
Read & validate configuration (
| one authority on "what is the config" |
Bind listening sockets (80/443, or unprivileged ports) | needs privileges for ports < 1024 |
Spawn / reap workers | it owns the process family |
React to signals (reload, reopen logs, shutdown) | signals address the master, not workers |
Maintain shared-memory zones' lifecycle | zones are created at config load |
Equally important — the never list:
The master never parses an HTTP request. Not one.
It never talks to a client and never talks to an upstream.
It doesn't write your access log.
3. Inside a worker: the event loop, traced
A worker's life is one loop. Stripped of detail, every iteration is:
┌──────────────────────────────────────────┐
│ worker (one per CPU core) │
│ │
sockets ─▶│ 1. epoll_wait(events[]) │
(clients, │ "kernel: which sockets are READY?" │
upstreams)│ │ │
│ 2. for each ready socket: │
│ run its state machine ONE step │
│ • read() → non-blocking │
│ • write() → non-blocking │
│ • next timeout armed │
│ 3. run expired timers (timeouts, │
│ keepalive culls, rate-limit ticks) │
│ 4. loop → epoll_wait again │
└──────────────────────────────────────────┘The crucial property:
step 2 never waits. If
read() would block because the client sent half a request and stalled, the worker doesn't wait — it moves that connection's state machine to "waiting for readability," registers interest with epoll, and services someone else. When the kernel later has bytes ready, that socket appears in a future
epoll_wait return and the machine advances one more step.
NGINX calls these per-connection state machines
events; you can watch them named in the error log of a debug build (
Module 16 does exactly this). The startup log even tells you which readiness mechanism the kernel offers:
$ head -1 /var/log/nginx/error.log
2026/09/22 20:12:32 [notice] 2396#2396: using the "epoll" event method
epoll is Linux's; FreeBSD/macOS would say
kqueue; ancient fallbacks say
select/
poll (if you ever see those in production, investigate — they scale badly and usually mean a container stripped kernel capabilities).
One request, end to end, as the loop sees it
For
curl fetching
/index.html through a reverse proxy, the
same worker loop joggles both halves of the journey concurrently with thousands of others:
Loop step | Client socket | Upstream socket |
|---|---|---|
1 | readable: full request head + body arrived | — |
2 | parse headers → route (location match) → request enters content phase | — |
3 | proxy module: connect() started — non-blocking | connect in progress |
4 | — (client idle, costs nothing) | writable: connected |
5 | — | write request to backend |
6 | — | readable: response headers arrived |
7 | readable: kernel socket drained by client | response body streaming through |
8 | keep-alive timer armed; connection parked, costing zero CPU | connection reused or closed |
Seven of those steps happen because a socket became ready; the gaps between them cost nothing. This table is the whole secret of C10K.
4. Capacity math: what a "connection" is
max simultaneous connections = worker_processes × worker_connectionsFor that instance:
auto (2 cores) × 1024 =
2048 connection slots. But count what consumes a slot, because it's not just clients:
each client connection: 1 slot
each upstream/backend connection opened on its behalf: 1 slot (Module 4/5 — this is why
worker_connections 1024cannot serve 1024 proxied clients; budget ~2 slots per active proxied request)TLS adds no slot (same socket), but adds CPU per handshake (Module 6)
And capacity is not just slots — each connection also costs kernel memory (socket buffers, ~few KB each). Real-world planning numbers for a typical edge box: tens of thousands of connections per worker is routine; hundreds of thousands need the OS tuning of Module 9 (
rlimit_nofile,
somaxconn, port ranges). The classic fail mode is not "NGINX died" but the kernel refusing resources — NGINX then logs
[alert] worker_connections are not enough and drops accept()s. If you ever see that alert, you have already been losing traffic.
5. The shared-memory seam
Workers are separate processes — per-request state is private, which is why one worker's crash doesn't corrupt others. But a load balancer that forgets its session table on every reload would be useless, and rate limits enforced "per worker" would be N× too generous. NGINX's answer: explicit shared memory zones, created by the master at config load, mapped read/write into every worker:
Zone (directive) | Shared thing | Module |
|---|---|---|
| leaky-bucket state per key | 7 |
| connection counters per key | 7 |
| cache index (URL→file map) | 8 |
| the live peer list + stats | 5, 13 |
| TLS session tickets/IDs | 6 |
| consistent-hash ring points | 5 |
Two consequences you will feel in practice:
Reloads preserve zone state. The master keeps the old zone alive for old workers and the new generation maps the same zone when the directive is unchanged. Change the directive (e.g. a new
keys_zonesize) and you get a fresh, empty zone — your cache "mysteriously" empties after certain deploys (Module 8 shows how to notice).Zones are the only cross-worker blackboard. Anything else —
$sent_httptricks, njs variables (Module 15) — is per-worker. If you need cross-worker truth, there must be a zone (or an external store) behind it.
6. When a worker dies — live demo
Theory says a dead worker is the master's problem. Watch it happen on a running instance:
$ M=$(cat /run/nginx.pid)
$ W=$(pgrep -P $M | head -1) # pick a worker
$ kill -9 $W # murder it
$ tail -4 /var/log/nginx/error.log
2026/09/22 20:17:25 [notice] 2397#2397: signal 17 (SIGCHLD) received from 2449
2026/09/22 20:17:25 [alert] 2397#2397: worker process 2449 exited on signal 9
2026/09/22 20:17:25 [notice] 2397#2397: start worker process 2563
2026/09/22 20:17:25 [notice] 2397#2397: signal 29 (SIGIO) received
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8100/
200The forensic sequence in the log, line by line:
SIGCHLD received from 2449— the kernel told the master a child died.worker process 2449 exited on signal 9— the master noticed it was killed, not exited (this is[alert]level: worth paging attention).start worker process 2563— replacement spawned, within the same second.signal 29 (SIGIO)— the async I/O housekeeping nudge that follows.
What was
lost? Only the requests in flight on that one worker — every other worker kept serving, and the site never blinked for anyone else. This is why
kill -9 on workers is a legitimate (if blunt) chaos test, and why hostile environments (flaky plugins via third-party modules) degrade gracefully
provided the master survives. The master, in turn, has no network-facing code to crash — defense by not listening.
Try it yourself
Reproduce the family tree — exact commands from §1. Count workers, compare with
nproc, and confirm every worker's PPID is the master:
ps -o pid,ppid,args --pid $(cat /run/nginx.pid) \
--ppid $(cat /run/nginx.pid)
nprocKill a worker, read the autopsy — the §6 demo, on your machine. Then try killing all workers at once (
pkill -9 -P $M): does the site survive? For how long? (Watch the respawn cadence in the error log.)Find the event method — what did the kernel offer your worker?
head -3 /var/log/nginx/error.logWatch the rlimit line —
getrlimit(RLIMIT_NOFILE)in the startup notice tells you the worker's file-descriptor ceiling, the real cap behindworker_connections(Module 9 comes back to this number):
grep getrlimit /var/log/nginx/error.log | tail -1Q&A
Q1. Why does the master — not workers — own the listening sockets?
Answer
Sockets must survive across config generations for zero-downtime reload; only a single long-lived owner can pass them forward. It also concentrates privilege: binding ports < 1024 requires root, and keeping that power in the non-network-facing process confines the blast radius.
Q2.
worker_processes 4 on an 8-core machine,
worker_connections 1024. A reverse-proxy workload where every client request holds exactly one upstream connection. Roughly how many simultaneous
clients can be fully served, and why isn't it 4096?
Answer
≈ 4096 slots ÷ 2 sockets per client ≈ 2048 clients. Each proxied request consumes one client slot + one upstream slot. (Keepalive reuse, TLS, and pipelining blur this; the 2:1 budgeting rule is the planning tool.)
Q3. You add a third-party module that occasionally segfaults. What failure mode do you expect, and why is it survivable?
Answer
The faulting worker dies mid-request; the master gets SIGCHLD, logs an
[alert], and respawns it within milliseconds (§6). Survivors on other workers never notice. Chronic crashing → flapping workers → capacity loss, so it's
survivable, not
acceptable — Module 16 shows how to capture the core dump and find the faulting module.
Q4. Two workers each maintain their own
$counter incremented by
set $counter. After 1000 requests split evenly, is the counter 1000? Anywhere?
Answer
No — each worker saw 500 in its private copy; neither is 1000 and the values only exist per-worker (they also reset on reload). Cross-worker state requires a shared-memory zone (§5) or an external store. This exact trap reappears with njs globals in Module 15.
Takeaways
Process family: one privileged, non-serving master; one worker per core; optional cache manager/loader when caching is on.
The master's job list is short and complete: config, sockets, workers, signals, zones. It never parses HTTP.
A worker is an event loop over non-blocking state machines; idle connections cost ~nothing, and one blocking syscall would tax everyone — the ban on blocking shapes all of NGINX's design.
Capacity = workers ×
worker_connections, and proxied traffic spends ~2 slots per client. Watch for theworker_connections are not enoughalert as your early-warning signal.Cross-worker state lives in shared-memory zones — created by the master, preserved across reloads, reset only when their defining directive changes.
Workers are cattle:
kill -9one and the master replaces it in under a second, log lines as receipts.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.