raptor 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/Dockerfile +7 -1
- data/README.md +184 -69
- data/docs/assets/profile_picture.png +0 -0
- data/docs/assets/startup_output.svg +37 -0
- data/docs/raptor-vs-puma.md +105 -429
- data/docs/talk.md +262 -169
- data/ext/raptor_http/raptor_http.c +82 -123
- data/lib/rackup/handler/raptor.rb +2 -1
- data/lib/raptor/cli.rb +1 -0
- data/lib/raptor/cluster.rb +85 -39
- data/lib/raptor/control_server.rb +2 -0
- data/lib/raptor/http.rb +0 -1
- data/lib/raptor/http1.rb +52 -30
- data/lib/raptor/http2.rb +46 -13
- data/lib/raptor/plugin.rb +98 -0
- data/lib/raptor/reactor.rb +102 -72
- data/lib/raptor/version.rb +1 -1
- data/lib/raptor.rb +1 -1
- data/sig/generated/raptor/cluster.rbs +18 -5
- data/sig/generated/raptor/http.rbs +0 -2
- data/sig/generated/raptor/http1.rbs +21 -13
- data/sig/generated/raptor/http2.rbs +12 -9
- data/sig/generated/raptor/plugin.rbs +52 -0
- data/sig/generated/raptor/reactor.rbs +31 -16
- data/sig/generated/raptor.rbs +1 -1
- metadata +6 -2
data/docs/raptor-vs-puma.md
CHANGED
|
@@ -1,117 +1,43 @@
|
|
|
1
|
-
# Raptor vs Puma
|
|
1
|
+
# Raptor vs Puma
|
|
2
2
|
|
|
3
|
-
Raptor
|
|
3
|
+
Puma and Raptor both run Rack applications on threads inside worker processes. Their main differences are how they handle protocol work, admit connections, and support long-lived responses. Raptor also implements HTTP/2. Puma leaves that to a proxy.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Raptor began as an experiment with Ruby's Ractors and is still a young server. Puma has a long production history and a much larger operational ecosystem.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This guide compares their architecture and relates it to the [current README benchmarks](../README.md#micro-benchmarks). Those benchmarks measure the whole server, so architectural differences alone do not explain individual results.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## At a glance
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
| Dimension | Puma | Raptor |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| Ruby requirement | 3.0 and up | 4.0 and up |
|
|
14
|
+
| Process model | Single process or cluster | Preloaded, pre-fork cluster only |
|
|
15
|
+
| Application execution | Thread pool within each worker | Thread pool within each worker |
|
|
16
|
+
| Protocol processing | App threads and reactor share the worker's GVL | Eager parsing inline, separate protocol Ractors on the pipeline path |
|
|
17
|
+
| Pool coordination | Mutex and condition variables | Lock-free work queue with idle-thread parking |
|
|
18
|
+
| Pool growth | Adds threads when queued work exceeds waiting capacity | Adds threads when queued work and blocking time justify growth |
|
|
19
|
+
| Idle and partial connections | `nio4r` reactor | `io-event` reactor |
|
|
20
|
+
| Cluster dispatch | Shared listeners with load-sensitive accept delay | Shared listeners, plus load-aware BPF dispatch on supported Linux TCP bindings |
|
|
21
|
+
| HTTP/2 | Not implemented | Native parsing, HPACK and stream flow control |
|
|
22
|
+
| Worker monitoring | Pipe messages | Shared-memory stats |
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
The application does not need to be Ractor-safe to use Raptor. Rack runs on ordinary threads in the worker's main Ractor. It still needs to be thread-safe, just as it does with a multithreaded Puma configuration.
|
|
14
25
|
|
|
15
|
-
Falcon is
|
|
26
|
+
Falcon is a useful third comparison. It uses fibers through the `async` ecosystem and supports HTTP/2. Fibers can keep many requests in flight without assigning each one an OS thread. That depends on application I/O cooperating with the fiber scheduler. The README includes Falcon, but this is not a full comparison of its design.
|
|
16
27
|
|
|
17
|
-
|
|
28
|
+
## How Puma moves a request
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
In cluster mode, a master supervises worker processes. Each worker accepts connections and has its own application thread pool. Puma can preload the application before forking so workers initially share memory through copy-on-write. It can also run without a cluster.
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
With the default request queueing enabled, a newly accepted socket first goes to the thread pool. An app thread tries to read and parse a complete request without waiting for more bytes. If it succeeds, it calls Rack and writes the response. If the request is incomplete, it returns the socket to the reactor and becomes available for other work.
|
|
22
33
|
|
|
23
|
-
The
|
|
34
|
+
The reactor watches partial requests and idle keep-alive connections. When bytes arrive, it can read and parse them through the client object. Once the request is complete, it queues the client for an app thread. Puma's native HTTP/1 parser therefore runs on both app threads and the reactor path. All of that Ruby coordination shares the worker's GVL with the application.
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
After a response, Puma can handle a ready follow-up request on the same app thread when pool capacity allows. Otherwise it queues the client again or returns an idle connection to the reactor. An idle keep-alive connection does not need to hold an application thread.
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
### Puma request flow
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
- On CPU-bound HTTP/1.1, extra threads cannot execute the Rack application in parallel within one worker's main Ractor. This profile checks whether adaptive scaling adds overhead when more threads cannot help.
|
|
31
|
-
- On HTTP/2, Raptor and Falcon both implement it; Puma doesn't. The samples vary substantially more between runs than HTTP/1.1, so the README reports the spread as well as the medians.
|
|
32
|
-
- The IO + SSE scenario repeats the keep-alive IO run while server-sent event streams stay open on every worker. Raptor hands each stream to the reactor with `Raptor::DetachedBody`, so the streams cost it no application threads, while a thread-per-request server parks one thread per open stream. The README table shows how much throughput each server keeps.
|
|
33
|
-
|
|
34
|
-
The rest of this doc explains the design differences behind those comparisons; the README carries the measured rankings.
|
|
35
|
-
|
|
36
|
-
## The two servers at a glance
|
|
37
|
-
|
|
38
|
-
| Dimension | Puma | Raptor |
|
|
39
|
-
| ---------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
40
|
-
| Ruby requirement | 3.0 and up | 4.0 and up (needs `Ractor::Port`) |
|
|
41
|
-
| Process model | Single, or cluster (pre-forks by default with 2+ workers); optional refork | Cluster only, always pre-forks |
|
|
42
|
-
| Threading model | `ThreadPool` with `Mutex` + `ConditionVariable`; autoscaling min/max, or fixed when min == max | `AtomicThreadPool` with a CAS-based queue; GVL-aware adaptive scaling from a fixed baseline, with an optional limit |
|
|
43
|
-
| True parallelism | One GVL inside each worker; parser runs on an app thread | Protocol Ractors can parse in parallel with the app on the pipeline path |
|
|
44
|
-
| I/O multiplexing | `nio4r` reactor for keep-alive idle and slow reads | `nio4r` reactor for the same, plus a red-black tree for O(log n) timeouts |
|
|
45
|
-
| Cluster dispatch | Workers race on inherited listeners with a load-proportional accept delay | Two-choice load-aware BPF dispatch for TCP on Linux; shared-listener fallback |
|
|
46
|
-
| Work queue | Ruby `Queue` coordinated under the pool mutex | Lock-free Michael-Scott FIFO queue |
|
|
47
|
-
| HTTP/2 | Not implemented | Native C parser + HPACK, reactor-owned frame scheduler |
|
|
48
|
-
| Keep-alive fast path | Same-thread inline dispatch when spare threads exist | Same-thread inline dispatch for bytes that are already waiting |
|
|
49
|
-
| Native extensions | 1 (Ragel HTTP/1 parser + MiniSSL) | 3, all Ractor-safe (Ragel HTTP/1 parser; HTTP/2 parser + HPACK; `writev`, `sched_setaffinity`, `prctl` wrappers) |
|
|
50
|
-
| Shared state (worker↔master) | Pipes and signals | Anonymous shared-memory `mmap` region |
|
|
51
|
-
| Restart primitives | Phased (USR1), hot (USR2 re-exec, inherits FDs via env), refork (SIGURG) | Phased (USR1), hot (USR2 re-exec, inherits FDs via env), refork (SIGURG) |
|
|
52
|
-
| systemd integration | `sd_notify` via plugin, `LISTEN_FDS` via binder | Native `sd_notify` + `LISTEN_FDS` socket activation |
|
|
53
|
-
|
|
54
|
-
The rest of the document expands on each row.
|
|
55
|
-
|
|
56
|
-
## Part I: Puma
|
|
57
|
-
|
|
58
|
-
### Process model
|
|
59
|
-
|
|
60
|
-
Puma is bimodal. In single mode the CLI runs a single `Puma::Server` in the current process; in cluster mode the CLI runs a master process that forks N workers, each running its own `Puma::Server`. Cluster mode is the interesting one because it is what almost everyone uses in production, and since Puma 5 it pre-forks by default; with two or more workers, `preload_app` is enabled unless explicitly turned off. The app is loaded once in the master, then N workers are forked from it, which preserves copy-on-write memory. If your app boots threads during load, copy-on-write breaks; every mutation to a page in the child dirties it and forces a private copy. Puma tries to detect this and warns.
|
|
61
|
-
|
|
62
|
-
Puma also has a `fork_worker` option, which is Puma's answer to CoW decay over time. Worker 0 becomes a "fork server", spawning new workers from itself rather than from the master, so state built up in worker 0 (JIT caches, initialised gems, warm-up allocations) is shared with siblings via CoW. This is triggered manually with SIGURG or automatically after a request threshold. It is a clever workaround for a real problem, but it also mixes the "master supervises workers" and "worker services requests" concerns.
|
|
63
|
-
|
|
64
|
-
Workers write their vitals (pid, boot state, timestamp) into a per-worker pipe read by the master. If a worker misses its `worker_check_interval` for longer than `worker_timeout` the master kills it and forks a replacement. Booting workers get a separate `worker_boot_timeout`.
|
|
65
|
-
|
|
66
|
-
Signals: INT and TERM start a graceful shutdown, USR1 does a phased restart (increment phase counter, replace workers one at a time waiting for each new worker to boot), USR2 does a hot restart (re-exec the master with the same argv), TTIN and TTOU adjust the worker count, HUP reopens logs, and URG triggers a fork-worker refork.
|
|
67
|
-
|
|
68
|
-
### Threading model
|
|
69
|
-
|
|
70
|
-
Inside a worker, request work is handled by `Puma::ThreadPool`. The pool has a minimum and maximum thread count, and it autoscales; when work arrives and there are more items queued than there are waiting threads, it spawns a new thread up to the max. Work sits in Ruby's thread-safe `Queue`, while a pool-level `Mutex` protects enqueue/dequeue coordination, thread counts, autoscaling, trimming, and shutdown. A `ConditionVariable` parks idle threads. Adding work signals the condvar; a waiting thread wakes, dequeues an item, and processes it.
|
|
71
|
-
|
|
72
|
-
This is a textbook thread pool. It works, and it has for a decade. But it has three characteristics worth noting for the comparison:
|
|
73
|
-
|
|
74
|
-
1. **Every enqueue and every dequeue takes the mutex.** Under heavy load, the mutex becomes a serialisation point. It is not the dominant cost, but it is a cost.
|
|
75
|
-
2. **The autoscaling is not free.** Spawning and reaping OS threads on demand means allocations, thread startup, and reaping accounting that all cost CPU.
|
|
76
|
-
3. **`busy_threads`, `waiting`, and `pool_capacity` are computed by reading multiple fields inside the mutex.** Fine, but again: mutex.
|
|
77
|
-
|
|
78
|
-
The pool exposes `busy_threads`, `waiting`, and `pool_capacity` metrics that the accept loop reads to decide whether to keep accepting or apply back-off. In cluster mode there is also an `accept_loop_delay` that sleeps proportionally to the busy ratio, which prevents a thundering herd where every worker accepts every connection.
|
|
79
|
-
|
|
80
|
-
### I/O model
|
|
81
|
-
|
|
82
|
-
Puma has two threads doing I/O per worker: a **server thread** running the accept loop, and a **reactor thread** running `NIO::Selector`.
|
|
83
|
-
|
|
84
|
-
The server thread is straightforward. It calls `IO.select` on all listener sockets (no timeout by default, so it blocks until one is readable), then `accept_nonblock` on any that are, wraps the resulting socket in a `Puma::Client` object, and pushes the client into the thread pool. That is all it does. Every accepted client is handed off to the thread pool, even if the request would have been readable immediately.
|
|
85
|
-
|
|
86
|
-
What happens next depends on `queue_requests`. `queue_requests` defaults to true and is the mode almost everyone runs in. When it is true, a thread-pool worker picks up the client and calls `client.eagerly_finish`, which loops non-blocking reads while bytes are already buffered, parsing after each. If the request is complete by the time the socket runs dry, the worker proceeds to invoke the Rack app inline. If it is not complete, the worker hands the client to the reactor and returns to the pool. When `queue_requests` is false, the worker skips the eagerly_finish attempt and does a blocking `client.finish(first_data_timeout)`. The `queue_requests: false` path is fine for fast trusted clients but does not scale; a slow client will hold a thread indefinitely.
|
|
87
|
-
|
|
88
|
-
The reactor is a separate thread running `Puma::Reactor`, which wraps an `NIO::Selector` from `nio4r`. Its job is to babysit two kinds of sockets:
|
|
89
|
-
|
|
90
|
-
1. **Sockets in the middle of a request.** A client sent some headers, was not yet complete, and needs more data before the app can be called.
|
|
91
|
-
2. **Keep-alive sockets between requests.** The previous request finished, the connection is still alive, and we are waiting for the next request to start.
|
|
92
|
-
|
|
93
|
-
The reactor stores clients in a data structure keyed by timeout, and every time through the loop it computes the deadline of the earliest-expiring client, calls `selector.select(timeout)`, and then dispatches. Any client whose socket is readable calls `try_to_finish`, which does a `read_nonblock` and attempts to parse whatever it got. If the request is now complete, the client is handed to the thread pool. If the socket is not ready or the request is still incomplete, the client goes back to sleep in the reactor.
|
|
94
|
-
|
|
95
|
-
Puma stores its reactor timeouts in a Ruby array of `Client` objects. New clients are pushed onto the end and the array is re-sorted with `sort_by!(&:timeout_at)` after each batch of inserts. Deletion is a linear scan (`@timeouts.delete client`). That is fine for small numbers of clients but scales linearly in the number of connections. It is not a hot spot at moderate load, but the cost is there.
|
|
96
|
-
|
|
97
|
-
Cross-thread waking uses `NIO::Selector#wakeup`. When work needs to enter the reactor from another thread (a Client being registered), the client is pushed onto a Ruby `Queue` and the selector is woken; the selector drains the queue and gets on with it.
|
|
98
|
-
|
|
99
|
-
### HTTP/1.1 request lifecycle
|
|
100
|
-
|
|
101
|
-
Putting the pieces together, a request through Puma looks like this:
|
|
102
|
-
|
|
103
|
-
1. Server thread `IO.select` returns a readable listener.
|
|
104
|
-
2. Server thread `accept_nonblock` produces a new socket. Puma wraps it in a `Client` and pushes it to the thread pool.
|
|
105
|
-
3. A thread-pool worker picks up the client and calls `client.eagerly_finish`, which loops `read_nonblock` while bytes are already buffered and tries to parse after each.
|
|
106
|
-
4. If the request is complete after that read, the worker calls `handle_request(client)` inline, which invokes the Rack app, formats the response, writes it back.
|
|
107
|
-
5. If the request is not complete, the worker hands the client to the reactor and returns to the pool. The reactor waits for more bytes, retries the parse, and pushes the client back to the thread pool once complete.
|
|
108
|
-
6. After the response is written, the client is either closed or, if keep-alive, kept inline on the same thread (if `has_back_to_back_requests?` is true, or `eagerly_finish` on the next request returns true and there is a spare thread), pushed back to the thread pool, or re-added to the reactor with `@persistent_timeout` as the new deadline.
|
|
109
|
-
|
|
110
|
-
Puma's parser is a Ragel-generated FSM in C. It is invoked from Ruby via `Puma::HttpParser#execute(env, buffer, offset)`. The parser calls back into Ruby via a set of C functions that set entries in the env hash: `REQUEST_METHOD`, `REQUEST_URI`, `PATH_INFO`, `QUERY_STRING`, `SERVER_PROTOCOL`, and one `HTTP_*` entry per header. It also detects the end of headers and reports where the body starts. Everything after that (body reading, chunked decoding, spooling large bodies to Tempfile) is pure Ruby in `Puma::Client`.
|
|
111
|
-
|
|
112
|
-
Response writing is done from the app thread. Puma sets `TCP_CORK` on Linux, writes the status line and headers, writes the body (chunked, Content-Length, or via `IO.copy_stream` for File bodies to trigger sendfile), unsets `TCP_CORK`, and calls `Rack::BodyProxy#close` and any `rack.after_reply` hooks.
|
|
113
|
-
|
|
114
|
-
### Puma request flow diagram
|
|
40
|
+
This diagram shows the normal queued HTTP/1.1 path in cluster mode. The keep-alive branch is simplified. Ready requests may continue inline or be queued again.
|
|
115
41
|
|
|
116
42
|
```mermaid
|
|
117
43
|
flowchart TB
|
|
@@ -136,10 +62,10 @@ flowchart TB
|
|
|
136
62
|
|
|
137
63
|
STA["Stat thread<br/>writes to worker pipe<br/>on worker_check_interval"]
|
|
138
64
|
|
|
139
|
-
C1{"Complete<br/>request<br/>
|
|
140
|
-
C2{"Complete<br/>request<br/>after read?"}
|
|
65
|
+
C1{"Complete<br/>request<br/>after eager read?"}
|
|
66
|
+
C2{"Complete<br/>request<br/>after read and parse?"}
|
|
141
67
|
KA{"Keep-alive?"}
|
|
142
|
-
BB{"
|
|
68
|
+
BB{"Next request<br/>ready?"}
|
|
143
69
|
|
|
144
70
|
SRV -->|"push new socket"| TP
|
|
145
71
|
TP -->|"eagerly_finish"| C1
|
|
@@ -148,7 +74,7 @@ flowchart TB
|
|
|
148
74
|
RCT -->|"socket readable"| C2
|
|
149
75
|
C2 -->|"complete, push"| TP
|
|
150
76
|
C2 -->|"incomplete, keep waiting"| RCT
|
|
151
|
-
RCT -->|"timeout"| TO["
|
|
77
|
+
RCT -->|"timeout"| TO["timeout handling, close"]
|
|
152
78
|
APP -->|"write response"| KA
|
|
153
79
|
KA -->|"no"| CLS["close socket"]
|
|
154
80
|
KA -->|"yes"| BB
|
|
@@ -172,190 +98,24 @@ flowchart TB
|
|
|
172
98
|
class PIPE storage
|
|
173
99
|
```
|
|
174
100
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
## Part II: Raptor
|
|
178
|
-
|
|
179
|
-
Raptor takes a different position on nearly every axis. It is opinionated in a way that Puma is not, largely because it does not have to support every deployment scenario Puma does.
|
|
180
|
-
|
|
181
|
-
### Process model
|
|
182
|
-
|
|
183
|
-
There is no single mode. Raptor is always a cluster. A master forks N workers, monitors them, and restarts crashed workers. The Rack app is always loaded in the master before forking, so copy-on-write is preserved by default (no user-visible `preload_app` knob).
|
|
184
|
-
|
|
185
|
-
The process and app-thread counts can be set with `RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS`, which lets a deployment tune them without generating a config file. Config files override the built-in defaults, environment variables override config files, and command-line options override both. `RAPTOR_MAX_THREADS=unlimited` leaves adaptive growth uncapped.
|
|
186
|
-
|
|
187
|
-
The master is a supervisor. It never handles requests. It forks workers, watches them via a shared-memory region (more on that in a moment), traps signals, restarts crashed workers, and orchestrates restarts.
|
|
188
|
-
|
|
189
|
-
Worker boot and shutdown hooks receive the worker's slot index, so setup and cleanup can identify the same slot across restarts. Hooks that do not take an argument continue to work.
|
|
190
|
-
|
|
191
|
-
Two kinds of restart are supported:
|
|
192
|
-
|
|
193
|
-
1. **Phased restart on SIGUSR1.** Same idea as Puma. Kill each worker in sequence, wait for its replacement to boot, move on. Existing workers drain their connections while their replacements come up. This is cheap and safe when the change does not require a fresh master.
|
|
194
|
-
|
|
195
|
-
2. **Hot restart on SIGUSR2.** Also known as "zero-downtime restart with FD inheritance". The master clears the close-on-exec flag on every listener, JSON-encodes the map from bind URI to file descriptor into an environment variable (`RAPTOR_INHERITED_FDS`), and re-execs itself with the original command line. The new master reads the environment variable and rebuilds its `Binder` from the inherited FDs instead of binding fresh sockets. Not a single connection is dropped, and the new master runs its initialisation from scratch (loading a newer Rack app, applying new config, whatever).
|
|
196
|
-
|
|
197
|
-
Systemd socket activation is a native feature and slots straight into this model. When the service unit is `Type=notify` and there is a socket unit, systemd passes listener FDs via `LISTEN_FDS`. Raptor detects this exactly the same way it detects a hot restart handoff: `Systemd.listen_fds` returns the FDs, the binder is built from them, and the master sends `READY=1` back to systemd once workers have booted. `STOPPING=1` and `RELOADING=1` fire on the corresponding lifecycle events.
|
|
198
|
-
|
|
199
|
-
Routine worker monitoring does not use pipes. Every worker writes its stats (pid, request count, backlog, busy and available threads, last checkin timestamp, booted flag) into a fixed-size slot in an anonymous shared-memory region allocated with `mmap-ruby` before the fork. The master reads the region directly. There is no serialisation, pipe drain, or signal to trigger the read; it is 49 bytes per worker of native memory. `bundle exec raptor stats` prints a JSON snapshot. Refork coordination is separate and does use a pair of pipes between the master and seed.
|
|
200
|
-
|
|
201
|
-
On Linux, `cpu_affinity: true` pins each worker to a distinct CPU via `sched_setaffinity` when the worker count fits within the process's allowed CPU set, so it stays on one core and its L1/L2 caches stay warm. It is off by default because an allowed CPU in a container is not necessarily dedicated to that container. When workers outnumber available CPUs the pin is skipped and the kernel scheduler manages placement.
|
|
202
|
-
|
|
203
|
-
### Refork
|
|
204
|
-
|
|
205
|
-
Long-lived workers accumulate mutations. Every allocation, every YJIT-compiled block, every gem that lazy-loads on first use, every cache that populates over the first few thousand requests, all of it dirties pages that were shared copy-on-write with the master. After a few hours of traffic the CoW savings are mostly gone and each worker's RSS climbs toward its steady-state size. Reforking off a warmed source is the standard answer.
|
|
206
|
-
|
|
207
|
-
Raptor's version is driven by `refork_after`, an `Integer`, `Array<Integer>`, or `nil`. When any worker's request count crosses the next threshold, the master picks the most-experienced booted worker in the current phase and sends it `SIGURG`. That worker breaks out of its main loop, runs any `before_refork` hooks (typically to close database connections and any other file descriptors the fresh workers should not inherit), drains in-flight requests, and then transitions into a seed loop instead of exiting. From that point on the seed's only job is to fork replacement workers on the master's request; it never accepts another connection. The master then rolls the remaining workers through a phased restart, forking each replacement from the seed.
|
|
208
|
-
|
|
209
|
-
Successive thresholds promote fresh seeds. When the next threshold trips, the master picks a new candidate from the currently-serving generation, terminates the previous seed, and starts the same drain-and-promote cycle on the new candidate. Each generation's fork source is therefore a worker that has served enough traffic to warm its VM, its YJIT caches, and its lazy-loaded gems, and the previous generation's seed is retired the moment it is no longer needed. Passing an array to `refork_after` (e.g. `[1000, 5000, 20_000]`) sets successive thresholds; the last one repeats.
|
|
210
|
-
|
|
211
|
-
Master and seed communicate over two pipes opened before the initial fork. The fork pipe (`@fork_r`, `@fork_w`) carries an 8-byte packed `[slot_index, phase]` from master to seed for each replacement to fork. The response pipe (`@resp_r`, `@resp_w`) carries a 4-byte packed pid back the other way, plus a one-shot 4-byte zero the seed writes when it enters its loop so the master knows drain is complete. The master polls the ready marker non-blockingly on the supervision loop; the phased restart that fills the vacated slot and cycles the remaining workers only starts once the marker arrives (or the drain deadline expires, in which case the master clears the seed reference and falls back to direct forks).
|
|
212
|
-
|
|
213
|
-
Workers forked by the seed are not the master's direct children. Before spawning anything the master calls `PR_SET_CHILD_SUBREAPER` (via a small `prctl` wrapper, `Raptor::Subreaper`). The seed then forks each worker through a short-lived intermediate process that exits as soon as the worker exists, so the worker reparents to the master immediately and `Process.wait2` sees it like any other child, including its real exit status when it dies. When a seed is retired on the next promotion its still-running descendants stay attached to the master through the same subreaper bit. Refork depends on this. On platforms without `PR_SET_CHILD_SUBREAPER` (everything except Linux), `refork_after` defaults to `nil` and is ignored with a warning if the user sets it anyway.
|
|
214
|
-
|
|
215
|
-
The design is Pitchfork's, adapted for Raptor's process model. [Pitchfork](https://github.com/Shopify/pitchfork) was the first Ruby server built around `PR_SET_CHILD_SUBREAPER` and multi-generation mould promotion; Puma's shipping `fork_worker` keeps worker 0 serving traffic alongside its fork-server duties, and its `mold_worker` mode, merged in [PR #4038](https://github.com/puma/puma/pull/4038) after starting life as Instacart's [PR #3643](https://github.com/puma/puma/pull/3643), adopts the same shape Pitchfork established. Puma's mold forks workers directly and reaps them itself, so the master detects the workers it did not fork by probing them with signals; Raptor's double fork lets the master wait on every worker directly. Raptor's seed matches the Pitchfork shape: pure fork source, promoted from a warm worker, retired when the next generation takes over.
|
|
216
|
-
|
|
217
|
-
### Threading model
|
|
218
|
-
|
|
219
|
-
This is where Raptor diverges dramatically. Inside a worker there are several distinct layers of concurrent activity:
|
|
220
|
-
|
|
221
|
-
1. **One server thread** running the accept loop.
|
|
222
|
-
2. **One reactor thread** running the NIO event loop plus timeout tree.
|
|
223
|
-
3. **A `RactorPool` for HTTP/1.1 parsing** sized by `http1.ractors`, defaulting to `round(cores / workers)` clamped to `[1, 3]`.
|
|
224
|
-
4. **A `RactorPool` for HTTP/2 parsing** sized by `http2.ractors`, defaulting to `round(cores / workers)` clamped to `[1, 2]`.
|
|
225
|
-
5. **A collector thread per Ractor pool** that receives parsed results via a `Ractor::Port`.
|
|
226
|
-
6. **An `AtomicThreadPool` of T baseline app threads** running the Rack app and writing responses, with optional adaptive growth.
|
|
227
|
-
7. **One stats thread** that writes the shared-memory slot every second.
|
|
228
|
-
8. **One load reporter thread when BPF dispatch is active** that publishes the worker's load to the kernel map.
|
|
229
|
-
|
|
230
|
-
That is a lot of moving parts. Let us go through why.
|
|
231
|
-
|
|
232
|
-
**Why Ractors for parsing.** Ractors are Ruby's answer to true parallelism. Multiple Ractors can execute Ruby code simultaneously on different OS threads, each with its own GVL. But Ractors are heavily restricted. Shared objects must satisfy Ractor's shareability rules, most global mutable state is inaccessible, and many existing gems assume shared-state semantics that are incompatible with isolation.
|
|
233
|
-
|
|
234
|
-
For a web server, this restriction turns out to be almost exactly right for HTTP parsing. Parsing a request is CPU-bound (tokenising bytes, uppercasing header names, decoding chunked bodies), it does not need to touch any global state, and it produces a result (a hash) that can be safely frozen and handed off. The native HTTP/1 parser (`raptor_http.c`) is declared `rb_ext_ractor_safe(true)`; it holds no per-parser Ruby state in the extension itself, and it writes only into the caller-supplied env hash. Same for the HTTP/2 parser plus HPACK.
|
|
235
|
-
|
|
236
|
-
The HTTP/1 parser also pre-interns the ~40 most common header keys (`HTTP_HOST`, `HTTP_USER_AGENT`, the `HTTP_ACCEPT_*` family, `CONTENT_LENGTH`, `HTTP_X_FORWARDED_*`, the `HTTP_SEC_FETCH_*` client hints, and so on) once at load time. During parsing, a `memcmp` lookup against that table returns the shared frozen `VALUE` for known keys and falls back to `rb_enc_interned_str` for the rest. Every request's env hash therefore reuses the same String object for its header names, which both skips per-request allocation and lets Ruby's hash lookup use the interned key's cached hash code.
|
|
237
|
-
|
|
238
|
-
Your Rack app still runs on ordinary threads under one worker GVL, so the app does not need to be Ractor-safe. Requests that need the reactor pipeline can have their protocol work run in parallel in another Ractor. Complete requests found by the eager accept and keep-alive paths parse inline instead, avoiding a Ractor handoff when the bytes are already available. The same applies when the next request arrives on an idle keep-alive connection.
|
|
239
|
-
|
|
240
|
-
**How the Ractor pools actually work.** Raptor uses the `ractor-pool` gem, which is another one of my libraries. Each pool has one coordinator Ractor and M pipeline Ractors. When a pipeline Ractor is idle, it sends itself back to the coordinator via `coordinator.send(Ractor.current, move: true)`. When work arrives at the coordinator, it either forwards it to a waiting Ractor (if any) or queues it. This coordinator-dispatch pattern guarantees that no Ractor sits idle while there is work. Results flow back through a shared `Ractor::Port` (a many-to-one channel added in recent Ruby versions and stable in 4.0) to a Ruby-side collector thread. If `M == 1` the coordinator is skipped and work goes straight to the single pipeline Ractor.
|
|
241
|
-
|
|
242
|
-
Raptor always runs an HTTP/1.1 pool because TCP, Unix, and SSL bindings support HTTP/1.1. It starts an HTTP/2 pool when an SSL or cleartext HTTP/2 binding is present. SSL bindings need both pools because ALPN allows each client to choose HTTP/2 or HTTP/1.1, while `h2c://` bindings accept only HTTP/2 clients using prior knowledge. Both pool sizes scale with headroom via `round(cores / workers)`, clamped to `[1, 3]` for `http1.ractors` and `[1, 2]` for `http2.ractors`. Keeping the pools separate means h1 and h2 parsing never share ractor slots, so a burst of small HTTP/1.1 requests cannot delay HTTP/2 frame handling on the same connection, and vice versa.
|
|
243
|
-
|
|
244
|
-
**Why a custom thread pool.** The `AtomicThreadPool` in `atomic-ruby` (another one of my libraries) is backed by an `AtomicQueue`. The queue is a Michael-Scott multi-producer, multi-consumer FIFO: a singly linked list with a dummy sentinel and atomic head and tail pointers. Producers append nodes at the tail; consumers advance the head. Both operations are O(1) and make progress through compare-and-swap rather than a queue-wide mutex. Separate atoms track queue size and active app threads for backpressure.
|
|
245
|
-
|
|
246
|
-
The pool starts at `threads` and scales without a fixed limit by default. Set `max_threads` to cap its growth, or set it to the same value as `threads` to keep the pool fixed. A native CRuby thread-event hook measures how much time workers spend running Ruby, blocked outside the GVL, and waiting to acquire it. While work stays queued and every worker is busy, the pool adds temporary threads when workers spend most of their time blocked and Ruby execution is using less than three quarters of a CPU. When Ruby execution becomes the bottleneck it retires them again. That distinction matters: adding threads helps when existing threads are asleep in database or network calls, but hurts when CPU-bound Ruby threads are already fighting over the GVL. Temporary threads also retire after the queue has stayed empty for five seconds.
|
|
247
|
-
|
|
248
|
-
The pool still uses an `AtomicConditionVariable` under the hood to park idle threads (idle threads call `Thread.stop` and get woken with `Thread#wakeup`; there is no spinning), because idle spinning would waste CPU. The difference from Puma's pool is not "no locks anywhere" but rather "the hot path (enqueue and dequeue when the queue has items) is lock-free". Once every worker is busy the mechanics look similar; where things diverge is under contention when you have many threads all trying to push and pop.
|
|
249
|
-
|
|
250
|
-
Application thread locals are cleared when each request finishes. Running every request in a fresh Fiber is independently configurable, but opt-in. The pool keeps its own parser and response buffers in private thread variables so the server can reuse them without preserving application state between requests.
|
|
251
|
-
|
|
252
|
-
The knock-on effect is that the server thread can read `pool.queue_size + pool.active_count` on every accept-loop iteration without acquiring the queue's mutation lock. Those are still synchronised atomic reads, but they do not serialise producers and consumers behind one mutex.
|
|
253
|
-
|
|
254
|
-
### I/O model
|
|
255
|
-
|
|
256
|
-
The server accept loop is an `IO.select` + `accept_nonblock` loop, similar to Puma. What is different is the backpressure check right before `accept`:
|
|
257
|
-
|
|
258
|
-
```ruby
|
|
259
|
-
backpressure_threshold = [(@thread_pool.size * BACKPRESSURE_THRESHOLD_MULTIPLIER).ceil, MIN_BACKPRESSURE_THRESHOLD].max
|
|
260
|
-
if @reactor.backlog >= backpressure_threshold || @thread_pool.queue_size > @thread_pool.size
|
|
261
|
-
CPU.yield
|
|
262
|
-
next
|
|
263
|
-
end
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
The server thread only accepts while two things hold. The worker's total load, `@reactor.backlog` (queued plus active work), must be below its threshold, and the queue alone must not exceed the pool size. Otherwise the thread yields and returns to polling the listeners. On Linux, the yield also gives native threads returning from I/O a chance to run without putting the server thread to sleep. Under saturation this consumes CPU to keep checking capacity rather than parking until an app thread signals a change; that is a deliberate throughput and latency tradeoff.
|
|
267
|
-
|
|
268
|
-
The threshold is 1.2 times the current pool size, with a floor of 8, so a three-thread pool trips at 8 concurrent items rather than 4. The floor avoids overreacting to a few active requests while still bounding the amount of work a worker pulls from the kernel. Because it is recalculated from the current size, a pool that has grown to handle blocking work accepts proportionally more.
|
|
269
|
-
|
|
270
|
-
This limit applies to new connections, not to new requests on existing keep-alive connections. Existing connections can therefore keep a worker busy enough that it stops accepting new ones. Polling does not guarantee those new connections will make progress.
|
|
271
|
-
|
|
272
|
-
On Linux, Raptor also sets `TCP_DEFER_ACCEPT` on its TCP listeners, so the kernel only hands over a connection once the client has sent data. Fresh connections therefore usually arrive with their request already readable, which is what the eager fast path below relies on.
|
|
273
|
-
|
|
274
|
-
On Linux, Raptor can give each worker its own `SO_REUSEPORT` listener on every plain TCP address it binds and attach a small BPF program to each address's group. One program serves them all: its socket map holds a row of worker listeners per address, and it routes each connection to the chosen worker's listener in the group the connection arrived on. A reporter thread publishes each worker's load into a kernel map every millisecond: its queued and active work plus any connections still waiting in its accept queue, which the worker cannot see from Ruby until it accepts them. For each new connection, the program hashes to two distinct workers and selects the one with the lower reported load: the power-of-two-choices strategy. Once the connection is routed, it atomically increments that worker's map slot, reserving capacity immediately rather than waiting for the next reporter tick. The accept path also publishes its load as soon as Ruby receives the socket. These reservations stop a burst of connections from repeatedly choosing the same stale minimum while retaining hash-based spread across the cluster.
|
|
275
|
-
|
|
276
|
-
This path only wraps plain `tcp://` bindings. Every worker still accepts on the shared listeners inherited from the master, which carry TLS and non-TCP bindings as well as any TCP connection the BPF program cannot route, such as one arriving before every worker has booted. If `libbpf-ruby` or the compiled BPF object is unavailable, Raptor silently uses those shared listeners for TCP too; if the prerequisites exist but the kernel refuses the program, startup raises. In either mode, a worker under Raptor's explicit backpressure stops competing for new accepts until its current work drains.
|
|
277
|
-
|
|
278
|
-
The BPF-based approach was inspired by [a comment](https://github.com/puma/puma/issues/3934#issuecomment-4356462590) by John Hawthorn ([@jhawthorn](https://github.com/jhawthorn)) on a Puma issue about `EPOLLEXCLUSIVE`, where he floated `SO_ATTACH_REUSEPORT_EBPF` as a way to route each connection to the least-busy worker.
|
|
279
|
-
|
|
280
|
-
The reactor is again an `NIO::Selector` loop. Two things make it different from Puma's:
|
|
281
|
-
|
|
282
|
-
1. **Read strategy.** When a socket is readable, the reactor does one `read_nonblock(64KB)` in the reactor thread, updates the buffered state, makes that state shareable, and sends it to the protocol's Ractor pool. The Ractor decides whether the request or frame batch is complete. The collector then sends incomplete state back to the reactor or dispatches completed requests to the app pool. The reactor itself does I/O, not protocol parsing.
|
|
283
|
-
|
|
284
|
-
2. **Timeout data structure.** Instead of a sorted linked list, timeouts are stored in a red-black tree (`red-black-tree` gem, yes, also one of mine). Each connection is represented by a `TimeoutClient < RedBlackTree::Node` ordered by its `timeout_at` value. Insertion is O(log n), deletion by key (needed when a connection's timeout is updated mid-flight, which happens on every read) is O(log n), and in-order traversal is O(k) where k is the number of expired connections. After every selector poll, the reactor walks the tree in order and breaks on the first non-expired node.
|
|
285
|
-
|
|
286
|
-
Why a tree instead of a heap? A min-heap gives you O(1) peek and O(log n) insertion, but deleting an arbitrary node (or updating one) is O(n) because you have to find the node first. Since every read on a connection resets its timeout deadline, and every response completion removes a connection from the reactor, you get a lot of "update this specific node" and "remove this specific node" operations. The tree makes those O(log n). It is a small thing, but the reactor manages hundreds to thousands of connections in a busy worker, and the difference between O(log n) and O(n) matters at that scale.
|
|
287
|
-
|
|
288
|
-
Three timeout classes are tracked:
|
|
289
|
-
|
|
290
|
-
- `first_data_timeout` (30s): applied to a fresh connection with no bytes read yet.
|
|
291
|
-
- `chunk_data_timeout` (10s): applied once data has started arriving but the request is incomplete.
|
|
292
|
-
- `persistent_data_timeout` (65s): applied to a keep-alive socket sitting idle between requests.
|
|
293
|
-
|
|
294
|
-
An incomplete HTTP/1.1 request receives `408 Request Timeout` before the connection closes. Once an HTTP/2 connection has received its preface, its deadline instead sends a PING; an acknowledgement restores the idle deadline, while a missing acknowledgement closes the connection.
|
|
295
|
-
|
|
296
|
-
### HTTP/1.1 request lifecycle
|
|
297
|
-
|
|
298
|
-
An HTTP/1.1 request takes one of two paths. The **fast path** fires when the first `read_nonblock` on the accepted socket produces the complete request. `eager_accept` on the server thread reads, parses the request inline, and pushes it straight to the thread pool, bypassing both the reactor and the Ractor pool. An app thread then builds the Rack env, calls the app, and writes the response.
|
|
299
|
-
|
|
300
|
-
The **pipeline path** fires when the first read returns `WaitReadable` (bytes have not arrived yet) or leaves the request incomplete. It goes:
|
|
301
|
-
|
|
302
|
-
1. `eager_accept` calls `@reactor.add(state)` with the socket, an empty buffer, and metadata (remote address, URL scheme).
|
|
303
|
-
2. Reactor thread registers the socket with the NIO selector and adds a `TimeoutClient` node to the RBT with `first_data_timeout`.
|
|
304
|
-
3. Bytes arrive. Selector wakes. Reactor calls `read_nonblock(64KB)`, appends to the state's buffer.
|
|
305
|
-
4. Reactor calls `Ractor.make_shareable(state)` and pushes the state onto the Ractor pool via `ractor_pool << shareable_state`.
|
|
306
|
-
5. Pipeline Ractor parses the buffer using the C extension. If the request has a chunked body, chunk decoding runs inline in the pipeline Ractor as a pure function. The parser returns a result including whether the request is complete.
|
|
307
|
-
6. Result travels through `Ractor::Port` to the collector thread.
|
|
308
|
-
7. Collector thread receives the parsed result. If incomplete, state goes back into the reactor to await more data. If complete, the socket is deregistered from the reactor and a proc is pushed to the `AtomicThreadPool`.
|
|
309
|
-
8. An app thread pops the proc, builds a Rack env, calls `@app.call(env)`, and writes the response.
|
|
310
|
-
9. If the response signals keep-alive (HTTP/1.1 default without `Connection: close`), the app thread enters the **eager keep-alive loop**.
|
|
311
|
-
|
|
312
|
-
The eager keep-alive loop is one of Raptor's more deliberate latency/occupancy trade-offs. Rather than immediately returning the connection to the reactor after a response, the app thread does:
|
|
313
|
-
|
|
314
|
-
```ruby
|
|
315
|
-
loop do
|
|
316
|
-
unless socket.wait_readable(0)
|
|
317
|
-
reactor.persist(socket, id, request_count, ...)
|
|
318
|
-
return
|
|
319
|
-
end
|
|
320
|
-
# There are bytes. Try to parse the next request inline.
|
|
321
|
-
...
|
|
322
|
-
end
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
The thread checks for bytes without waiting. If they are already available, it parses them inline and calls the Rack app again. Otherwise the connection returns to the reactor immediately. Pipelined requests avoid a reactor round-trip without letting an idle keep-alive connection occupy an app thread.
|
|
326
|
-
|
|
327
|
-
Puma has a similar shape. It checks buffered back-to-back requests, then eagerly drains bytes already available on the socket. If a complete request is ready and the pool has a waiting thread, the current thread loops inline; otherwise Puma queues the client or returns it to the reactor.
|
|
328
|
-
|
|
329
|
-
The `reactor.persist` call re-registers the socket with the reactor using `persistent_data_timeout` (65s) as the new deadline. When the next bytes arrive, the reactor hands the socket back to the thread pool, and an app thread reads and parses the request inline. Only a request that is still incomplete takes the Ractor pipeline.
|
|
330
|
-
|
|
331
|
-
A `Raptor::DetachedBody` returns the application thread while keeping the response open, chunked on HTTP/1.1 and ended by closing the connection on HTTP/1.0. The reactor owns the response until it closes, buffers later writes within fixed limits, and pauses the connection's next request without occupying an application thread.
|
|
101
|
+
## How Raptor moves a request
|
|
332
102
|
|
|
333
|
-
|
|
103
|
+
Raptor always preloads the app and forks workers. Each worker has an accept thread, a reactor, protocol Ractors and their collector threads, an app thread pool, and a stats thread. HTTP/2 bindings add a separate HTTP/2 protocol pool.
|
|
334
104
|
|
|
335
|
-
|
|
105
|
+
There are two important request paths.
|
|
336
106
|
|
|
337
|
-
|
|
107
|
+
1. **Eager handling.** On a plain HTTP/1.1 connection, the accept thread tries to read and parse the request immediately. A complete request goes straight to the app pool. TLS connections perform their handshake and initial protocol handling on an app thread instead.
|
|
108
|
+
2. **Protocol pipeline.** If more bytes are needed, the reactor watches the socket and reads available data. A protocol Ractor parses the buffered state. A collector routes the result back to the reactor for more data or to the app pool for Rack execution.
|
|
338
109
|
|
|
339
|
-
|
|
110
|
+
Protocol Ractors can execute in parallel with the main Ractor. The Rack application cannot use that parallelism for its own Ruby execution. Its threads still share the main Ractor's GVL within each worker. Multiple worker processes provide application-level Ruby parallelism in both servers.
|
|
340
111
|
|
|
341
|
-
1.
|
|
342
|
-
2. The HTTP/2 parser (native C, with an HPACK decoder using a static Huffman table) parses the frames in the HTTP/2 Ractor pool.
|
|
343
|
-
3. Completed requests (once `HEADERS` and `DATA` are complete for a stream) go to the thread pool as separate work items. **A single connection can be servicing many streams in parallel across the thread pool.**
|
|
344
|
-
4. Each stream's response frames are queued for the reactor, which owns every socket write after connection setup.
|
|
112
|
+
Raptor does not send every request through a Ractor. After an HTTP/1.1 response, an app thread checks for the next request without waiting. It can parse ready bytes inline and yield to queued work when needed. If the connection is idle, it returns to the reactor. When that connection becomes readable, the reactor first sends it back to an app thread for another eager attempt. Only an incomplete request needs the longer parsing pipeline.
|
|
345
113
|
|
|
346
|
-
|
|
347
|
-
Early hints and response-finished callbacks follow the same Rack lifecycle as HTTP/1.1.
|
|
348
|
-
Trailing request `HEADERS` complete an open request. Rack has no standard request-trailer key, so Raptor validates them without adding them to the environment.
|
|
114
|
+
The distinction matters. Separate parsing can overlap protocol work with Rack execution, but crossing Ractor and collector boundaries also costs work. Eager paths avoid those handoffs. The benchmark does not isolate either path's contribution.
|
|
349
115
|
|
|
350
|
-
|
|
116
|
+
### Raptor request flow
|
|
351
117
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
Frame processing also has an eager loop. After processing one batch of frames, the h2 handler checks `wait_readable(0)` and reads the next batch only if it has already arrived. Up to eight rounds are consumed inline before handing back to the reactor, and the loop bails out early once the app thread pool has more queued work than worker slots so one busy connection cannot starve the collector. This is the same principle as the HTTP/1.1 eager keep-alive: amortise the reactor round-trip when the client is actively sending, but back off under saturation.
|
|
355
|
-
|
|
356
|
-
During worker shutdown, Raptor stops accepting connections and sends GOAWAY with the last stream handed to the Rack application. Later streams are refused while application work and detached bodies drain. The reactor remains active so in-flight responses can receive flow-control updates; detached bodies still open when the drain period expires are cancelled before their connections close.
|
|
357
|
-
|
|
358
|
-
### Raptor request flow diagram
|
|
118
|
+
The eager accept arrows below show plain connections. TLS setup takes an app-thread detour before protocol dispatch. HTTP/2 response writes return to the reactor. Ordinary HTTP/1.1 responses are written by app threads.
|
|
359
119
|
|
|
360
120
|
```mermaid
|
|
361
121
|
flowchart TB
|
|
@@ -363,13 +123,13 @@ flowchart TB
|
|
|
363
123
|
MM["Master loop<br/>Watches shared mmap for<br/>per-worker stats<br/>Handles signals"]
|
|
364
124
|
MM -->|"fork"| WK1["Worker 1"]
|
|
365
125
|
MM -->|"fork"| WKN["Worker N"]
|
|
366
|
-
MM -.->|"SIGUSR2<br/>exec +
|
|
126
|
+
MM -.->|"SIGUSR2<br/>exec + inherited listeners"| MM
|
|
367
127
|
end
|
|
368
128
|
|
|
369
129
|
subgraph Worker["Raptor worker process, one of N"]
|
|
370
|
-
SRV["Server thread<br/>IO.select + accept_nonblock<br/>
|
|
130
|
+
SRV["Server thread<br/>IO.select + accept_nonblock<br/>polls for pool capacity"]
|
|
371
131
|
|
|
372
|
-
RCT["Reactor thread<br/>
|
|
132
|
+
RCT["Reactor thread<br/>io-event selector<br/>read_nonblock 64KB<br/>Red-Black-Tree timeouts"]
|
|
373
133
|
|
|
374
134
|
subgraph RP1["HTTP/1.1 RactorPool"]
|
|
375
135
|
RW1["Pipeline Ractor 1<br/>HTTP/1.x parser<br/>chunked decode"]
|
|
@@ -393,7 +153,7 @@ flowchart TB
|
|
|
393
153
|
|
|
394
154
|
CHK{"Request<br/>complete?"}
|
|
395
155
|
KA{"Keep-alive?"}
|
|
396
|
-
EAG{"
|
|
156
|
+
EAG{"Buffered or<br/>readable bytes?"}
|
|
397
157
|
|
|
398
158
|
SRV -->|"HTTP/1.1 eager_accept, parse inline, push proc"| ATP
|
|
399
159
|
SRV -->|"HTTP/2 eager_accept, parse inline, push proc"| ATP
|
|
@@ -407,9 +167,10 @@ flowchart TB
|
|
|
407
167
|
CHK -->|"yes, push proc"| ATP
|
|
408
168
|
ATP -->|"HTTP/2 response frames"| RCT
|
|
409
169
|
ATP -->|"HTTP/1.1 response write"| KA
|
|
410
|
-
KA -->|"no
|
|
170
|
+
KA -->|"no"| CLS["close socket"]
|
|
411
171
|
KA -->|"yes"| EAG
|
|
412
|
-
EAG -.->|"bytes ready,
|
|
172
|
+
EAG -.->|"bytes ready, attempt inline parse"| ATP
|
|
173
|
+
ATP -->|"HTTP/1.1 incomplete request"| RCT
|
|
413
174
|
EAG -->|"no bytes, reactor.persist"| RCT
|
|
414
175
|
RCT -.->|"keep-alive bytes ready, push proc"| ATP
|
|
415
176
|
RCT -->|"deadline expired"| TO["PING or close"]
|
|
@@ -434,200 +195,115 @@ flowchart TB
|
|
|
434
195
|
class SHM storage
|
|
435
196
|
```
|
|
436
197
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
## Part III: Head to head
|
|
440
|
-
|
|
441
|
-
### Parsing model
|
|
442
|
-
|
|
443
|
-
**Puma.** Parsing happens on an app thread. The C parser callbacks build the env hash. A fresh client first enters the thread pool, where eager reads may complete it immediately; partial and idle keep-alive connections wait in the reactor before returning to the pool. Parsing shares the worker's GVL with the app.
|
|
444
|
-
|
|
445
|
-
**Raptor.** Fresh and immediate keep-alive requests parse inline on the server or app thread. A connection that needs more bytes takes the longer path: reactor (I/O) → protocol Ractor pool (parse) → collector → app thread pool (Rack + write). Between keep-alive requests, the app thread checks for bytes without waiting before returning an idle connection to the reactor, which hands it back to an app thread once the next request arrives. Parsing in the Ractor pipeline has its own GVL; parsing on an eager path does not.
|
|
446
|
-
|
|
447
|
-
In practice, Puma has one process-wide GVL per worker. Every Ruby thread inside that worker takes turns holding it. Raptor has the same main-Ractor GVL plus one GVL per protocol Ractor, so a pipeline Ractor can parse one connection while an app thread executes Rack for another. That parallelism is real, but so are the costs of making state shareable and crossing the Ractor and collector boundaries. The current CPU-bound HTTP/1.1 throughput results favour Raptor in three of four configurations; Puma leads slightly with fixed threads and keep-alive. Raptor's p95 latency is higher on fresh connections and lower with keep-alive. These are full-server measurements, not an isolated comparison of the parsers.
|
|
448
|
-
|
|
449
|
-
### Timeout management
|
|
450
|
-
|
|
451
|
-
**Puma.** Ruby array re-sorted with `sort_by!` after each batch of inserts. Sort is O(n log n). Remove is a linear scan, O(n). Fine at small scale.
|
|
198
|
+
## Where the designs differ
|
|
452
199
|
|
|
453
|
-
|
|
200
|
+
### Thread pools and admission control
|
|
454
201
|
|
|
455
|
-
|
|
202
|
+
Puma coordinates its work queue and pool bookkeeping with a mutex and condition variables. It can grow from a minimum to a maximum thread count as work arrives. Idle threads sleep.
|
|
456
203
|
|
|
457
|
-
|
|
204
|
+
Raptor uses `AtomicThreadPool` from `atomic-ruby`. Enqueue and dequeue use a lock-free queue. Idle threads still park rather than spin. Its adaptive pool considers time spent executing Ruby, waiting for the GVL, and blocked outside it. Growth is intended to help blocking workloads without adding threads just because CPU-bound Ruby work is queued. The default has no upper limit, so deployments should choose a cap where resource limits require one.
|
|
458
205
|
|
|
459
|
-
|
|
206
|
+
Raptor also checks queued and active work before accepting more connections. Under pressure, the accept loop yields and polls for capacity rather than parking until notified. This can consume CPU while the worker is saturated. It limits new accepts, not requests arriving on existing keep-alive connections. It is not a guarantee of progress or fairness for every client.
|
|
460
207
|
|
|
461
|
-
|
|
208
|
+
Puma's cluster accept delay backs off according to worker load. On supported Linux plain TCP bindings, Raptor can instead use BPF reuseport dispatch to choose the less loaded of two sampled workers. Its load estimate includes pending accepts. TLS and Unix sockets use shared listeners. Missing BPF prerequisites also leave Raptor on shared listeners, while a kernel rejection of the BPF program fails startup.
|
|
462
209
|
|
|
463
|
-
|
|
210
|
+
### Connection tracking
|
|
464
211
|
|
|
465
|
-
|
|
212
|
+
Both reactors keep idle and partial connections off application threads. Puma tracks deadlines in a sorted array. Raptor uses a red-black tree for logarithmic insertion and removal of individual deadlines. Updating a connection's deadline does not require scanning the other connections.
|
|
466
213
|
|
|
467
|
-
|
|
214
|
+
Raptor uses `io-event` readiness fibers only on its reactor thread. It does not install a Fiber scheduler or change how the application runs.
|
|
468
215
|
|
|
469
|
-
|
|
216
|
+
### Responses and streaming
|
|
470
217
|
|
|
471
|
-
|
|
218
|
+
Ordinary HTTP/1.1 response writing runs on app threads in both servers. Raptor can gather headers and array-body buffers with `writev` and reuse per-thread response buffers. Puma uses its own buffered writes and Linux TCP corking. Both support file bodies and enumerable streaming bodies.
|
|
472
219
|
|
|
473
|
-
|
|
220
|
+
A blocking Enumerator occupies an app thread while it waits and yields, in either threaded server. Raptor also provides `Raptor::DetachedBody`. It hands a long-lived response to the reactor and releases the app thread. Later writes use bounded buffers and must handle backpressure. This is a Raptor-specific API, not an automatic improvement for every existing streaming Rack app.
|
|
474
221
|
|
|
475
|
-
Raptor
|
|
476
|
-
|
|
477
|
-
### Backpressure
|
|
478
|
-
|
|
479
|
-
**Puma.** Cluster mode uses `accept_loop_delay` (sleep proportional to busy ratio) to prevent thundering herd across workers. Single-worker backpressure is implicit; if all threads are busy and the queue is growing, new accepts pile up in the kernel accept queue. Puma does have `queue_requests` (default true) which pushes partial requests into the reactor, freeing the accept loop, but there is no explicit "stop accepting" signal from the worker.
|
|
480
|
-
|
|
481
|
-
**Raptor.** Explicit backpressure before every accept: the server thread yields and polls again while `backlog >= max(pool_size * 1.2, 8)` or the queue alone exceeds the pool size. On supported Linux TCP bindings, the BPF reuseport program samples two workers, routes to the less loaded one, and reserves its map slot, counting connections still waiting in each worker's accept queue. TLS, Unix sockets, unsupported platforms, and installations without the BPF prerequisites use inherited shared listeners instead.
|
|
482
|
-
|
|
483
|
-
### Shared state (worker ↔ master)
|
|
484
|
-
|
|
485
|
-
**Puma.** Pipes. Each worker writes ping messages to a pipe read by the master. Signals push the master to check status. Simple, works everywhere, but every stat update involves a syscall on both ends.
|
|
486
|
-
|
|
487
|
-
**Raptor.** Anonymous mmap region shared across workers via `mmap-ruby`. Each worker writes a 49-byte slot for its own vitals every second. The master reads the region directly without a pipe exchange.
|
|
488
|
-
|
|
489
|
-
The performance difference here is negligible because the update happens once per worker per second, outside request processing. The design mainly gives the master a fixed-size snapshot it can inspect without draining per-worker messages.
|
|
490
|
-
|
|
491
|
-
For external monitoring, `control_url` can expose a read-only `GET /stats` endpoint over a Unix socket. Its per-worker status includes backlog, busy threads, free capacity, request count, and the thread count currently available. Adaptive pools report their current size as `max_threads`, rather than their configured limit, so `pool_capacity / max_threads` remains a useful measure while the pool grows and shrinks.
|
|
222
|
+
The SSE benchmark uses detached bodies for Raptor and blocking Enumerators for Puma and Falcon. It also runs Raptor with the same Enumerator body as a control. Falcon's fiber scheduler can yield during those sleeps without occupying an OS thread per stream.
|
|
492
223
|
|
|
493
224
|
### HTTP/2
|
|
494
225
|
|
|
495
|
-
|
|
226
|
+
Raptor supports HTTP/2 over TLS with ALPN and cleartext prior-knowledge HTTP/2 on `h2c://` listeners. Completed streams enter the same Rack thread pool as HTTP/1.1 requests. Multiple streams can be in flight on one connection, but their application Ruby still shares the worker's main-Ractor GVL.
|
|
496
227
|
|
|
497
|
-
|
|
228
|
+
The reactor owns HTTP/2 socket writes after setup. App threads queue frames rather than competing to write directly to the shared connection. Ordinary bodies can still wait for stream or connection flow-control capacity. Detached bodies return the app thread and let the reactor schedule buffered output as capacity becomes available.
|
|
498
229
|
|
|
499
|
-
|
|
230
|
+
Puma does not implement HTTP/2. Its [position](https://github.com/puma/puma/issues/2697) is to leave it at the edge. If a proxy terminates HTTP/2 and forwards HTTP/1.1, native HTTP/2 in the app server is not part of that request path. It matters when clients or proxies actually use HTTP/2 to Raptor. Falcon is the direct benchmark comparison for that case.
|
|
500
231
|
|
|
501
|
-
|
|
232
|
+
### Supervision and restarts
|
|
502
233
|
|
|
503
|
-
|
|
234
|
+
Both servers support phased worker replacement and hot restarts that preserve listening sockets. Raptor publishes routine worker stats through shared memory, while Puma uses pipe messages.
|
|
504
235
|
|
|
505
|
-
|
|
236
|
+
Raptor's Linux reforking promotes a warmed worker into a dedicated fork source. New workers can share its warmed state through copy-on-write. Puma also offers worker reforking. Reforking needs care around application threads, database connections and inherited resources. Raptor's benchmark disables it, so the reported memory results do not measure its benefit.
|
|
506
237
|
|
|
507
|
-
|
|
238
|
+
## What the current benchmarks show
|
|
508
239
|
|
|
509
|
-
|
|
240
|
+
The README is the source of truth for versions, full tables and reproduction instructions. The current run compares Raptor 0.23.0, Puma 8.0.2 and Falcon 0.57.0 on Ruby 4.1 development head with YJIT on Linux. It uses ten workers, 120 HTTP/1.1 client connections, or 40 HTTP/2 connections with three streams each.
|
|
510
241
|
|
|
511
|
-
|
|
242
|
+
Each case has three fresh-process samples, interleaved in seeded shuffled rounds. Each sample has a three-second warmup and a fifteen-second measurement. Throughput, p95 and resource figures are independent medians. The reported spread is half the observed range divided by the median, not a confidence interval.
|
|
512
243
|
|
|
513
|
-
|
|
244
|
+
The IO workload interleaves short sleeps with small JSON renders. The CPU workload interleaves larger JSON renders with much shorter sleeps. Both discard the rendered JSON and return a short response. They simulate application work, not a database or a complete Rails stack. A separate prebuilt 64 KiB body exercises response transfer.
|
|
514
245
|
|
|
515
|
-
|
|
246
|
+
Raptor and Puma run with three fixed threads or scale from three to sixteen. The keep-alive IO case also tests sixteen prestarted threads. Falcon uses unbounded fibers. Raptor's request-local cleanup, CPU affinity and reforking are disabled, as is Puma's Fiber-per-request mode. These results do not describe every default configuration.
|
|
516
247
|
|
|
517
|
-
|
|
248
|
+
CPU ms/request includes the master and descendants, including SSE event production, but excludes client CPU and warmup. It is divided by successful ordinary requests. Peak aggregate PSS is sampled every half second and apportions shared pages rather than counting them fully in each worker. This is a short-run memory measurement, not a long-term capacity estimate.
|
|
518
249
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
```mermaid
|
|
522
|
-
sequenceDiagram
|
|
523
|
-
autonumber
|
|
524
|
-
participant Client
|
|
525
|
-
participant PA as Server thread
|
|
526
|
-
participant PR as Reactor thread
|
|
527
|
-
participant PP as App thread
|
|
528
|
-
|
|
529
|
-
Note over Client,PP: Request 1, initial
|
|
530
|
-
Client->>PA: SYN, request bytes
|
|
531
|
-
PA->>PP: push client to thread pool
|
|
532
|
-
PP->>PP: eagerly_finish, complete, parse under GVL
|
|
533
|
-
PP->>Client: response 1
|
|
534
|
-
|
|
535
|
-
Note over Client,PP: Request 2, pipelined a few hundred us later
|
|
536
|
-
Client->>PP: request bytes already buffered
|
|
537
|
-
PP->>PP: has_back_to_back? spare threads? loop inline
|
|
538
|
-
PP->>PP: parse under GVL contention
|
|
539
|
-
PP->>Client: response 2
|
|
540
|
-
|
|
541
|
-
Note over Client,PP: Request 3, arrives sub-millisecond later
|
|
542
|
-
PP->>PP: eagerly_finish returns false, hand to reactor
|
|
543
|
-
PP->>PR: reactor.add with persistent_timeout
|
|
544
|
-
Client->>PR: request bytes
|
|
545
|
-
PR->>PP: dispatch back to thread pool
|
|
546
|
-
PP->>PP: acquire pool mutex, dequeue, parse
|
|
547
|
-
PP->>Client: response 3
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
**Raptor, three keep-alive requests:**
|
|
551
|
-
|
|
552
|
-
```mermaid
|
|
553
|
-
sequenceDiagram
|
|
554
|
-
autonumber
|
|
555
|
-
participant Client
|
|
556
|
-
participant RS as Server thread
|
|
557
|
-
participant RR as Reactor thread
|
|
558
|
-
participant RP as App thread
|
|
559
|
-
|
|
560
|
-
Note over Client,RP: Request 1, initial
|
|
561
|
-
Client->>RS: SYN, request bytes
|
|
562
|
-
RS->>RS: eager_accept, read + parse inline
|
|
563
|
-
RS->>RP: push proc to thread pool
|
|
564
|
-
RP->>Client: response 1
|
|
565
|
-
|
|
566
|
-
Note over Client,RP: Request 2, already buffered
|
|
567
|
-
Client->>RP: request bytes already waiting
|
|
568
|
-
RP->>RP: wait_readable(0) returns true
|
|
569
|
-
RP->>RP: parse inline on app thread
|
|
570
|
-
RP->>Client: response 2
|
|
571
|
-
|
|
572
|
-
Note over Client,RP: Request 3, not yet available
|
|
573
|
-
RP->>RP: wait_readable(0) returns false
|
|
574
|
-
RP->>RR: reactor.persist
|
|
575
|
-
Client->>RR: request bytes
|
|
576
|
-
RR->>RP: dispatch back to thread pool
|
|
577
|
-
RP->>RP: read + parse inline
|
|
578
|
-
RP->>Client: response 3
|
|
579
|
-
```
|
|
250
|
+
### IO over HTTP/1.1
|
|
580
251
|
|
|
581
|
-
|
|
252
|
+
With three fixed threads, Raptor delivers about 2.2–2.3 times Puma's throughput, with and without keep-alive. That comes with roughly 2.4–2.5 times the server CPU per successful request.
|
|
582
253
|
|
|
583
|
-
|
|
254
|
+
With scaling enabled, their throughput and p95 are close. The keep-alive fixed-sixteen control is also close to both scaling results. Pool capacity changes the comparison substantially.
|
|
584
255
|
|
|
585
|
-
|
|
256
|
+
Falcon leads the fresh-connection IO run. With keep-alive, Falcon and the larger threaded pools are roughly tied in throughput.
|
|
586
257
|
|
|
587
|
-
|
|
258
|
+
### CPU over HTTP/1.1
|
|
588
259
|
|
|
589
|
-
|
|
260
|
+
The CPU results are mixed. On fresh connections, fixed Raptor reaches 3.61k req/s against Puma's 3.29k. Scaling Raptor reaches 3.54k against 2.89k. Raptor leads throughput by about 10% and 23% respectively, but its p95 is higher in both modes.
|
|
590
261
|
|
|
591
|
-
|
|
262
|
+
With keep-alive, fixed Puma leads throughput at 3.61k against Raptor's 3.12k, and also has lower p95. Scaling Raptor leads at 3.09k against Puma's 2.88k, with about 20% lower p95. Puma uses less CPU per successful request in both keep-alive cases. On fresh connections, CPU per request is close.
|
|
592
263
|
|
|
593
|
-
|
|
264
|
+
Raptor's throughput spread is between ±1.2% and ±4.6% across these four cases.
|
|
594
265
|
|
|
595
|
-
###
|
|
266
|
+
### The 64 KiB response
|
|
596
267
|
|
|
597
|
-
|
|
268
|
+
This scaling keep-alive case is a clear measured strength for Raptor. It was rerun on Ruby 4.1-dev revision `33ac6afea7`; the other cases use `cda3527671`.
|
|
598
269
|
|
|
599
|
-
|
|
270
|
+
| Metric | Raptor | Puma |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| Successful req/s | 102.11k | 88.55k |
|
|
273
|
+
| p95 | 3.37 ms | 3.63 ms |
|
|
274
|
+
| CPU per successful request | 0.047 ms | 0.054 ms |
|
|
275
|
+
| Peak aggregate PSS | 513.5 MiB | 1371.1 MiB |
|
|
600
276
|
|
|
601
|
-
|
|
277
|
+
Raptor serves about 15% more requests per second, with 7% lower p95, 13% less CPU per request and 63% less peak PSS. Throughput spread is ±0.4% for Raptor and ±0.6% for Puma. The prebuilt body avoids JSON-generation work. This result does not predict memory use for other response sizes or applications.
|
|
602
278
|
|
|
603
|
-
###
|
|
279
|
+
### IO alongside SSE
|
|
604
280
|
|
|
605
|
-
|
|
281
|
+
The harness opens twenty streams across the cluster, not two guaranteed streams on each worker. It keeps them connected while measuring ordinary IO requests. A stream without complete response headers after two seconds is counted as stalled and remains connected. Throughput counts successful ordinary requests only, not events or timed-out requests. Failure counts sum all three samples.
|
|
606
282
|
|
|
607
|
-
|
|
283
|
+
With three fixed threads, native detached Raptor serves 3.46k req/s against Puma's 0.35k, nearly ten times the throughput. Puma records 54 timeouts and one stalled stream. Raptor's matched Enumerator control falls to 0.89k, with 21 timeouts. The control shows that the native result is not representative of running the same blocking streaming body on Raptor.
|
|
608
284
|
|
|
609
|
-
|
|
285
|
+
With scaling enabled, native Raptor serves 7.48k req/s against Puma's 7.14k, about 5% ahead. Its p95 is about 5% lower, but it uses more CPU per successful request. Detached streaming frees application threads, but the benefit depends on how much thread capacity the rest of the workload needs.
|
|
610
286
|
|
|
611
|
-
|
|
287
|
+
### HTTP/2 against Falcon
|
|
612
288
|
|
|
613
|
-
|
|
289
|
+
On the CPU workload, Raptor reaches 3.37k–3.70k req/s against Falcon's 2.79k, a median throughput lead of about 21–33%. Its p95 is lower, but Falcon's throughput spread is ±25.3%. Falcon's samples range from 2.37k to 3.78k req/s, so the median lead is not consistent across all samples. Raptor also uses more CPU per successful request and has higher peak PSS.
|
|
614
290
|
|
|
615
|
-
|
|
291
|
+
For IO, scaling Raptor and Falcon are roughly tied at 7.17k and 7.13k req/s. Raptor has about 11% higher p95, 28% more CPU per successful request and 85% higher peak PSS at 916.8 MiB against 496.1 MiB. Its throughput spread is ±11.3%. With three fixed threads, Raptor falls behind at 1.59k req/s.
|
|
616
292
|
|
|
617
|
-
|
|
293
|
+
These tests include TLS, multiplexing and flow control. Raptor's plain-TCP BPF path is not used for this TLS listener.
|
|
618
294
|
|
|
619
|
-
|
|
295
|
+
## Limits and choosing a server
|
|
620
296
|
|
|
621
|
-
|
|
297
|
+
Puma is the safer default when production history, broad Ruby compatibility and operational familiarity matter most. Its simpler request path also has fewer Ractor boundaries to trace when debugging.
|
|
622
298
|
|
|
623
|
-
|
|
299
|
+
Raptor requires Ruby 4 or newer and has more coordination machinery per worker. Pipeline requests pay for shareable state and cross-Ractor handoffs. Several core pieces depend on libraries developed alongside Raptor, including `atomic-ruby`, `ractor-pool`, `red-black-tree` and `mmap-ruby`. Their adoption and testing history are not equivalent to Puma's.
|
|
624
300
|
|
|
625
|
-
|
|
301
|
+
The published run uses development Ruby, not the minimum supported Ruby version. Ractor and runtime behavior can change between versions. Benchmark the version you intend to deploy rather than assuming these numbers apply to Ruby 4.0 or a later release.
|
|
626
302
|
|
|
627
|
-
|
|
303
|
+
Raptor's cluster-only model also carries process and coordination costs in a small container. Direct HTTP/2 and detached streaming are useful only when the deployment or application uses them. A synthetic fifteen-second run does not establish long-term stability, memory behavior, overload recovery or production readiness.
|
|
628
304
|
|
|
629
|
-
|
|
305
|
+
Request bodies are fully buffered before the app runs. Both protocols apply `connection.max_body_size`, which defaults to unlimited. HTTP/1 copies larger bodies to a tempfile after receipt, which does not bound peak receive memory. HTTP/2 keeps accepted bodies in memory. Public deployments should set a finite body-size limit and enforce concurrent-upload limits at a reverse proxy, with request buffering for slow uploads.
|
|
630
306
|
|
|
631
|
-
Try Raptor
|
|
307
|
+
Try Raptor with your thread-safe Rack application, especially if direct HTTP/2 or detached streaming would be useful. Measure throughput, latency, CPU, memory and failures alongside the server you use today.
|
|
632
308
|
|
|
633
|
-
|
|
309
|
+
Include Falcon when the application and its dependencies work well with fiber scheduling, especially for concurrent I/O or long-lived connections. Choose from measurements of your application and your operational constraints, not one benchmark leaderboard.
|