Apache httpd dominated the early web and stayed dominant for decades,
and its thesis is the inverse of nginx's minimalism: the server is an
extension platform, not a fast path. Everything โ URL-to-file mapping,
authentication, response generation, even the connection concurrency
model โ is a registered hook (ap_hook_*) that any module can slot
into, and data flows between hooks as bucket brigades through stacked
filters. This deep-dive teaches how that hook-and-filter architecture
scaled an ecosystem (one binary served CGI farms, PHP hosts, and
proxies), what the per-request cost of runtime-extensibility is, and
how the Multi-Processing Module seam lets prefork, event, and the new
motorz MPM live behind one server API.
Read it against the nginx dive to see the two answers to "what is a web server": a pipeline of modules over pools, versus a state machine in a worker process.
๐ง Explain like I'm 10
A restaurant where the conveyor belt is the restaurant. In Apache's
kitchen there is no head chef doing everything โ there is a conveyor
belt (the filter chain: buckets of soup/bytes pass from station to
station) and a big rulebook of stations (the hooks): one for
understanding the order, one for checking the guest list, one for
cooking, one for garnishing, one for the bill. Anyone can rent a station
(modules register hooks with ap_hook_*), and the restaurant itself
just runs the belt and calls stations in order โ even "who carries the
plates to the table" is a rented station (the MPM: prefork hires one
waiter per table; event hires a team with a dispatcher watching the
dining room). The magic rule: every plate served on a paper mat that is
thrown away when the guest leaves (a memory pool per request โ nothing
is ever washed and reused, the mess vanishes with the table).
Why the grown-ups did it this way: if every job is a hook, then one compiled binary can become anything a module author imagines โ the server stays small because it refuses to do anything itself.
1. Architecture overview
flowchart TD
subgraph apr["APR (separate repo, pinned separately)"]
POOLS["apr_pool_t โ bump allocator + cleanup list<br/>(apr_pools.c:823 apr_palloc)"]
BUCKETS["bucket brigades โ apr_bucket_t chains"]
end
subgraph mpm["MPM โ swappable process/thread model"]
P1["prefork: N processes,<br/>one connection each (prefork.c:412)"]
P2["event: listener + worker threads +<br/>timer skiplist (event.c:1947)"]
P3["motorz: APR poll-callback + APR thread pool (motorz.c)"]
end
subgraph core["httpd core (server/)"]
CONN["ap_process_connection (connection.c:244)<br/>โ ap_run_process_connection hook"]
HTTPC["http_core: ap_process_http_async_connection<br/>(http_core.c:139) โ ap_read_request"]
REQ["ap_process_async_request (http_request.c:406)<br/>โ ap_process_request_internal walk (request.c:192)"]
INV["ap_invoke_handler (config.c:389)"]
FILTERS["filter stacks in/out<br/>ap_pass_brigade (util_filter.c:756)"]
COREOUT["ap_core_output_filter (core_filters.c:359)<br/>โ writev to socket"]
end
subgraph mods["modules (modules/)"]
M1["config-time: directives,<br/>per-dir config merge"]
M2["request-time: hooks"]
M3["filters registered per request"]
end
MPM_C["clients"] --> mpm --> CONN --> HTTPC --> REQ --> INV
INV --> M2
M2 --> FILTERS --> COREOUT --> MPM_C
mods --> core
apr --> core
Diagram source โ enable JavaScript to render the diagram.- APR (Apache Portable Runtime) is the environment seam: pools,
buckets, threads, and OS calls behind one C API. The pool allocator
(
memory/unix/apr_pools.c:823, in the separately-pinned APR clone) is a bump allocator whose "free" is a lifetime event. - httpd core (
server/) is a hook dispatcher plus config engine. The request spine:ap_process_connection(server/connection.c:244) โap_run_process_connection(hook, implemented bymodules/http/http_core.c:139for plain HTTP) โap_read_request(server/protocol.c:1306) โap_process_async_request(modules/http/http_request.c:406) โap_process_request_internal(server/request.c:192, the phase walk) โap_invoke_handler(server/config.c:389) โ output filter stack โap_core_output_filter(server/core_filters.c:359) โ writev. - Modules register hooks at startup (
register_hooks, e.g.modules/http/http_core.c:355) and directives that build per-server and per-directory config.mod_so(modules/core/mod_so.c) loads compiled modules at config time viaapr_dso_sym(line 276) โ theLoadModuleline in every httpd.conf. - The MPM is the one hook set that decides process/thread layout
(
server/mpm/): at this pinned commit trunk shipsprefork,event,motorz,simple,winnt,mpmt_os2,netware,workerโ the cross-platform MPMmotorzarrived (APR poll callbacks with built-in timers over a simple APR thread pool), andeventhas supersededworkeras the default threaded MPM, keeping its threading but adding an async listener for keep-alive connections.
Walkthrough: one GET, accept to writev
The spine as a story โ on the site you can step through it with Next; in the repo it reads top to bottom.
sequenceDiagram
autonumber
participant C as Client
participant M as MPM (event: listener_thread)
participant W as worker thread
participant R as ap_read_request (protocol.c:1306)
participant P as request walk (request.c:192)
participant H as ap_invoke_handler (config.c:389)
participant F as filters โ core_output_filter
C->>M: TCP connect (keep-alive conn)
M->>W: ap_queue_push_socket โ ap_queue_pop_something
W->>R: ap_process_http_async_connection โ ap_read_request
R->>R: request line + headers into r->pool
W->>P: ap_process_request_internal
P->>P: ap_directory_walk merges per-dir config (+ .htaccess)
P->>P: translate โ map_to_storage โ authn/authz hooks
W->>H: ap_invoke_handler โ ap_run_handler (module hook)
H->>F: handler writes brigade โ ap_pass_brigade
F-->>C: coalesced writev โ connection parked for next request
Diagram source โ enable JavaScript to render the diagram.The MPM hands the connection to the machinery
In event,
the single listener_thread (server/mpm/event/event.c:1947) polls
listening sockets plus parked connections, and pushes ready ones onto
the fd queue; a worker_thread (line 2460) pops one and calls
ap_process_connection (server/connection.c:244), which immediately
delegates to the process_connection hook โ the MPM does not know what
HTTP is.
The connection becomes a request
The core HTTP module's
ap_process_http_async_connection (modules/http/http_core.c:139)
drives the state machine and calls ap_read_request
(server/protocol.c:1306): request line, headers, all allocated from
the request pool r->pool โ nothing will be individually freed.
The policy gauntlet walks
ap_process_async_request
(modules/http/http_request.c:406) runs ap_process_request_internal
(server/request.c:192): URI normalization, translate_name and
map_to_storage hooks decide the file, then ap_directory_walk (line
680) merges per-directory config down the path โ opening .htaccess
files per directory via ap_parse_htaccess (server/config.c:2000)
when AllowOverride permits โ then authn/authz hooks run.
A handler is invoked, not called
ap_invoke_handler
(server/config.c:389) gives modules one insert_filter chance, then
ap_run_handler walks the registered handlers until one returns
something other than DECLINED. For GET /index.html the core
default handler streams the file as bucket brigades into the output
filter stack.
Brigades flow downhill
Each filter (mod_deflate,
chunking, HTTP headers) transforms a brigade and calls
ap_pass_brigade (server/util_filter.c:756) to hand it to the next;
the stack ends in ap_core_output_filter (server/core_filters.c:359),
which coalesces buckets and writevs to the socket (its
writev_nonblocking, line 649).
The request dies, the memory vanishes with it
The response completes; the request pool is cleared/destroyed (its cleanup callbacks close files, unlock mutexes), and the connection either parks in the event MPM's timer queue for the next keep-alive request or lingering-closes.
The client has its bytes
One request walked MPM โ connection hooks โ request walk โ module handler โ filter stack โ writev, touching six extension seams โ and none of those seams was written by the server core.
2. Core data structures โ and why these
The hook array โ extension as sorted callback lists (server/config.c:85-175)
AP_IMPLEMENT_HOOK_RUN_ALL(int, pre_config, ...) and friends expand to
a per-hook-type array of {function, order} entries, sorted by module
and declared precedence (APR_HOOK_FIRST/MIDDLE/LAST), with generated
ap_run_* and ap_hook_* functions. The server core itself registers
almost nothing (see modules/http/http_core.c:355 โ even "speak HTTP"
is a module). Why arrays-of-callbacks over direct calls: the binary
must serve content types and protocols that did not exist when it was
compiled; the cost is one indirect call plus ordering ambiguity per
phase.
Per-directory config vectors + merge functions (server/config.c:285)
Every module gets one config slot per directory context โ an array of
pointers (ap_conf_vector_t) โ and ap_merge_per_dir_configs
(server/config.c:285) merges two vectors by calling each module's
merger_func (cached in merger_func_cache). ap_directory_walk
(server/request.c:680) applies <Directory> sections down the path
and .htaccess contents (ap_parse_htaccess, server/config.c:2000)
on top, per request, with a walk cache (prep_walk_cache) to avoid
re-stats on subrequests. This is the feature that made shared hosting
work: users configure the server without touching its config.
Bucket brigades โ streaming without buffering (server/util_filter.c:756)
A brigade is a doubly-linked list of apr_buckets (memory, file, mmap,
pipe, flush, EOS โ each with its own read/copy/setaside vtable). Filters
consume and produce brigades; a file download is a handful of
file-backed buckets with offsets, not bytes in RAM; ap_pass_brigade
(line 756) is the single "next, please" call. Why: handlers must be
composable over unbounded bodies; the brigade is the unit of flow
control between them, and EOS is the end-of-stream signal every filter
sees.
APR pools โ allocation by lifetime (apr/memory/unix/apr_pools.c:823)
apr_palloc bumps a pointer inside the pool's active node, keeping
partially-free nodes ordered by max free space (the free_index dance
in apr_pools.c:823-880); apr_pool_clear (line 933) resets the
allocator and runs registered cleanups; apr_pool_destroy (line 985)
frees. The hierarchy โ pconf (config lifetime, survives graceful
restart) โ pchild (process) โ ptrans ("transaction", tagged at
prefork.c:464) โ r->pool (request) โ means no per-object free
exists on the request path at all. Why over malloc/free: leak-proofing
a world of a thousand modules, at the price of peak memory
(under-estimated pools grow) and no eager reclamation inside a request.
The scoreboard โ cross-process state (server/scoreboard.c:44)
ap_scoreboard_image is a shared-memory table of per-slot
worker_scores (what each process/thread is doing, per-request timing
when ExtendedStatus is on). It is how the parent MPM decides to spawn
or retire children (perform_idle_server_maintenance,
server/mpm/event/event.c:3240) and how mod_status renders a live picture โ
supervision as data structure, not protocol.
3. Concurrency and failure model
flowchart TD
subgraph parent["parent process (retained state, generations)"]
SUP["supervises children, graceful restart<br/>ap_mpm_pod / ap_graceful_shutdown_timeout (mpm_common.c:182)"]
SB["scoreboard in shared memory (scoreboard.c:44)"]
end
subgraph pf["prefork child (prefork.c:412)"]
PF1["accept mutex (SAFE_ACCEPT)<br/>one connection at a time<br/>ptrans pool per connection"]
end
subgraph ev["event child"]
EV1["listener_thread (event.c:1947):<br/>poll listen + parked conns,<br/>timer skiplist, fd queue"]
EV2["worker threads (event.c:2460):<br/>pop queue, run request machinery,<br/>park keep-alives back"]
end
SUP --> PF1
SUP --> EV2
EV1 -->|queue| EV2
PF1 --> SB
EV2 --> SB
Diagram source โ enable JavaScript to render the diagram.- The MPM is the concurrency answer, chosen at build time. Prefork
(
server/mpm/prefork/prefork.c:412child_main): each child serialize-accepts with a mutex (SAFE_ACCEPT(accept_mutex_on()), line 591) and handles one connection at a time โ a crashing module kills one child, one connection. Event (server/mpm/event/event.c): onelistener_threadper child polls everything (line 1947) and pushes work toworker_threads (line 2460); keep-alive connections that are just waiting are parked and polled instead of occupying a thread โ the C10K answer, retrofitted under the same server API.motorz(server/mpm/motorz/motorz.c, per itsMOTORZ.README) is the trunk's bid for a single MPM on every platform: Prefork's process framework with the Simple MPM's poll-callback event structure, feeding a simple APR thread pool. - Crash containment is the process model. A segfault kills a child;
the parent (via scoreboard +
ap_reclaim_child_processes) respawns it. Graceful restart sends children a "finish current requests then exit" signal through the pipe-of-death and starts a new generation with the re-parsed config โ old processes drain while new ones already accept (bounded byap_graceful_shutdown_timeout,server/mpm_common.c:182). - Per-request failure is pool failure. Any module can abort a
request by returning an error from a hook; cleanup happens by pool
destruction, so half-written requests do not leak per-object state.
The lingering-close path (
ap_lingering_close) drains the socket so a client never sees RST mid-response. - Backpressure lives in the filters:
ap_core_output_filterbuffers bounded amounts and returnsAP_EAGAINup the stack when the client stalls โ in event MPM that parks the connection instead of blocking a worker; in prefork it simply blocks that child.
4. API and extension design
- The module is the unit of extension: a
modulestruct (STANDARD20_MODULE_STUFF,include/http_config.h:489) with a directive table (per-dir/per-server config creators + merger) and aregister_hooksthat callsap_hook_translate_name,ap_hook_handler,ap_hook_access_checker, โฆ Loadable at config time throughmod_so'sLoadModuleโapr_dso_sym(modules/core/mod_so.c:276). The same server binary plus differentLoadModulesets is a static file server, a PHP host (mod_proxy+ php-fpm), or a TLS-terminating reverse proxy. - Filters are the streaming seam:
ap_register_output_filter(server/util_filter.c:290) with a type (AP_FTYPE_PROTOCOL,AP_FTYPE_CONTENT_SET, โฆ) fixes the stacking order; a module inserts its filter per request from theinsert_filterhook. This is howmod_deflateormod_headerscompose with any handler. - The MPM seam makes even the process model replaceable
(
server/mpm/), selected by--with-mpmat build time โ the decision nginx hard-codes (master + workers) exposed as a plug-in contract.ap_mpm_query(AP_MPMQ_IS_ASYNC, ...)lets modules adapt. - The honest warts: (1) hook ordering is module-relative
(FIRST/MIDDLE/LAST), so two modules fighting for a phase need
mod_so-eraLoadModuleorder tricks โ there is no total order contract; (2).htaccesscosts filesystem stats on every request (ap_directory_walkโap_parse_htaccess), which is exactly why nginx refuses the feature and why every httpd tuning guide saysAllowOverride None; (3) pool lifetimes are global policy, so a module wanting fine-grained memory discipline has to fight the runtime.
5. The defining trade-offs
Trade-off 1: extensibility as the product over a fixed fast path
- Tension: be everything to every deployment (CGI, PHP, proxy, auth schemes) without forking the server per use.
- Choice: every phase is a hook array; content flows as brigades through stacked filters; config is data merged per directory at request time.
- Evidence: the walk in
ap_process_request_internal(server/request.c:192) is hook call after hook call;ap_invoke_handler(server/config.c:389) doesn't even have a built-in "serve a file" โap_run_handlerdelegates, and the core default file handler is just another registrant (server/core.c:6152,APR_HOOK_REALLY_LAST);modules/http/http_core.c:355registers HTTP itself via hooks. - Given up: the fast path. Every request pays hook dispatch, per-dir config merging, and brigade indirection that nginx's single-purpose state machine skips; the RPS gap between the two servers on static files is this trade measured in benchmark units.
- Verdict: right when third parties must extend the server without recompiling it (the shared-hosting and enterprise eras โ this is why httpd won them). Wrong when the workload is one fixed thing at maximum efficiency โ which is exactly the niche nginx took.
Trade-off 2: pools over individual allocation
- Tension: a thousand modules, millions of requests, zero leak review authority.
- Choice: allocation by lifetime (
r->poolper request,ptransper connection,pconfper config generation); cleanup as registered callbacks at pool destruction. - Evidence:
apr_palloc(apr/memory/unix/apr_pools.c:823) never frees individually;apr_pool_clear(line 933) is the request-recycle primitive; the prefork child creates aptrans"transaction" pool per connection (server/mpm/prefork/prefork.c:464). - Given up: eager reclamation (a 1GB response buffers up to its
pool's lifetime if a filter holds it), peak-memory spikes on
mis-sized pools, and any module that wants real streaming discipline
must use
setasidebuckets and careful brigade hygiene. - Verdict: right for a plugin ecosystem where no one can audit frees; a leak becomes impossible by construction. Wrong for tight-memory embeddings โ and it is no accident nginx, with no plugin API, chose pools too but scopes them to the request cycle only.
Trade-off 3: per-directory runtime config over compiled-in layout
- Tension: let unprivileged users configure server behavior for their subtree (shared hosting) without reloading the server.
- Choice: config layers merge per request โ
<Directory>walk,<Location>walk, then.htaccessfiles parsed off disk mid-request. - Evidence:
ap_directory_walk(server/request.c:680) walking sections and callingap_merge_per_dir_configs(server/config.c:285);ap_parse_htaccess(server/config.c:2000) opening and parsing user files on the hot path, with a per-request cache (r->htaccesschain) and ther->taint |= AP_TAINT_HTACCESSmarker showing the security cost being tracked. - Given up: per-request filesystem stats and parse cost, plus a whole class of surprises (options a user re-enabled in a subdir) โ the feature is both the reason shared hosting existed and the reason every performance guide disables it.
- Verdict: right when your users are tenants you cannot trust with the main config; wrong when you own the whole server โ then it is pure overhead, and the industry's move to nginx/Caddy configs with a reload is the verdict playing out.
6. Reading path
server/main.cโ startup: config tree parse โpre_config/post_confighooks โ MPM'sap_mpm_run. Notice how early the core hands everything to hooks.include/http_config.h:461-489โ themodulestruct andSTANDARD20_MODULE_STUFF; thenAP_DECLARE_MODULEat 467 to see the DSO plumbing fields.server/config.c:85-175โAP_IMPLEMENT_HOOK_RUN_ALL/FIRSTexpansions: the arrays, the sort, the generatedap_run_*. This is the whole extension mechanism in forty lines of macros.modules/http/http_core.c:139ap_process_http_async_connectionโ HTTP itself as a module: read request, runap_process_async_request, park or close the connection.server/protocol.c:1306ap_read_requestโ request line + headers intor->pool; noteap_get_mime_headersand how nothing is freed individually.server/request.c:192ap_process_request_internalโ the phase walk: normalize โ translate โmap_to_storageโ directory walk โ authn/authz โtype_checkerโ done. Read it once for one request, once for a subrequest (ap_sub_req_lookup_urireuses the walk cache).server/request.c:680ap_directory_walk+server/config.c:2000ap_parse_htaccessโ the per-directory merge story, including the.htaccesscache chain onr.server/config.c:285ap_merge_per_dir_configsโ the vector merge with cached merger functions; config composition as an algebra.server/config.c:389ap_invoke_handlerโ insert_filter, filter init, thenap_run_handlerwalking registrants until one commits.server/util_filter.c:756ap_pass_brigadeandserver/core_filters.c:359ap_core_output_filterโ brigades downhill to coalescedwritev; this is where streaming meets the socket.apr/memory/unix/apr_pools.c:823(apr_palloc) and 933 (apr_pool_clear) โ the allocator and its recycle primitive, from the separately-pinned APR clone.server/mpm/prefork/prefork.c:412child_main, thenserver/mpm/event/event.c:1947listener_threadand 2460worker_threadโ the two concurrency answers behind the same hooks; finish withserver/mpm_common.c:182for graceful shutdown timeout plumbing.
You have now traced one GET from accept to writev across six extension seams, and seen the process model swap (prefork โ event) without the request machinery noticing.
7. What to steal
- Make the platform the product, not any one behavior: when every
phase is a hook, your ecosystem โ not your code โ answers future
requirements (
server/config.chook macros). - Give extension points data to own and merge rules to compose
(per-dir config vectors +
merger_funcs): modules stay orthogonal because composition is defined, not improvised. - Stream as a linked list of typed chunks (bucket brigades): file, memory, and generated content compose through one interface, and EOS is a first-class citizen.
- Allocate by lifetime, not by object (APR pools): free an epoch, not a pointer โ leak-proofness is structural, not disciplinary.
- Put the concurrency model behind a seam (the MPM): you will need to change it someday (thread โ async โ io_uring) and the request machinery must not care.
- Supervise via a shared data structure (the scoreboard): spawning,
retiring, and observability read the same truth, so the parent, the
children, and
mod_statusnever disagree.
8. Difficulty and prerequisites
- Difficulty: demanding. Macro-heavy C, a two-repo codebase, and the architecture is in the conventions (hooks, pools, filters), not in any single file.
- Budget: ~8-10 hours for the reading path; the phases and filter stacks repay a second pass with a debugger attached.
- Prerequisites: C preprocessor fluency (the hook system is macros); what a DSO is; the nginx dive for the contrasting model; basic POSIX process/thread semantics.
9. Further reading
- Apache HTTP Server documentation โ https://httpd.apache.org/docs/ โ especially "Modules" and the MPM sections.
- N. Kew, The Apache Modules Book: Application Development with Apache (Prentice Hall, 2007) โ the module/hook model explained by a modules author.
- The
CHANGESfile at the pinned commit โ the arrival ofmotorzandevent's supersession ofworkeras the threaded default read as a history of the process-model trade-offs.