raptor 0.22.3 → 0.24.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 +46 -0
- data/Dockerfile +10 -2
- data/README.md +183 -61
- data/docs/assets/profile_picture.png +0 -0
- data/docs/assets/startup_output.svg +37 -0
- data/docs/raptor-vs-puma.md +104 -424
- data/docs/talk.md +1388 -0
- data/ext/raptor_bpf/reuseport_select.bpf.c +18 -9
- data/ext/raptor_http/extconf.rb +2 -0
- data/ext/raptor_http/raptor_http.c +165 -234
- data/ext/raptor_http2/raptor_http2.c +272 -223
- data/ext/raptor_native/raptor_native.c +38 -20
- data/lib/rackup/handler/raptor.rb +14 -12
- data/lib/raptor/binder.rb +12 -12
- data/lib/raptor/cli.rb +19 -18
- data/lib/raptor/cluster.rb +120 -74
- data/lib/raptor/control_server.rb +5 -2
- data/lib/raptor/detached_body.rb +6 -2
- data/lib/raptor/http.rb +7 -6
- data/lib/raptor/http1.rb +281 -234
- data/lib/raptor/http2.rb +356 -276
- data/lib/raptor/plugin.rb +98 -0
- data/lib/raptor/reactor.rb +138 -148
- data/lib/raptor/reuseport_bpf.rb +62 -38
- data/lib/raptor/server.rb +45 -34
- data/lib/raptor/stats.rb +15 -3
- data/lib/raptor/thread_locals.rb +4 -3
- data/lib/raptor/version.rb +1 -1
- data/sig/generated/rackup/handler/raptor.rbs +2 -1
- data/sig/generated/raptor/binder.rbs +9 -9
- data/sig/generated/raptor/cli.rbs +6 -6
- data/sig/generated/raptor/cluster.rbs +18 -5
- data/sig/generated/raptor/control_server.rbs +2 -1
- data/sig/generated/raptor/detached_body.rbs +5 -1
- data/sig/generated/raptor/http.rbs +3 -5
- data/sig/generated/raptor/http1.rbs +119 -102
- data/sig/generated/raptor/http2.rbs +204 -190
- data/sig/generated/raptor/plugin.rbs +52 -0
- data/sig/generated/raptor/reactor.rbs +107 -118
- data/sig/generated/raptor/reuseport_bpf.rbs +54 -17
- data/sig/generated/raptor/server.rbs +26 -26
- data/sig/generated/raptor/stats.rbs +3 -2
- data/sig/generated/raptor/thread_locals.rbs +4 -3
- metadata +6 -2
- data/docs/brisrails-talk.md +0 -2398
data/docs/talk.md
ADDED
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
# Raptor
|
|
2
|
+
|
|
3
|
+
## A Ruby 4 Web Server
|
|
4
|
+
|
|
5
|
+
<img src="./assets/startup_output.svg" width="60%" align="right" alt="The README's complete Raptor startup output, including log prefixes, the process tree, and all ten worker boot messages." />
|
|
6
|
+
|
|
7
|
+
<img src="./assets/profile_picture.png" width="200" alt="Joshua Young" />
|
|
8
|
+
|
|
9
|
+
_Joshua Young ([`@joshuay03`](https://github.com/joshuay03))_
|
|
10
|
+
|
|
11
|
+
_Senior Software Engineer at [Buildkite](https://buildkite.com/)_
|
|
12
|
+
|
|
13
|
+
_[Rails](https://github.com/rails/rails) Issues team member_
|
|
14
|
+
|
|
15
|
+
_[Puma](https://github.com/puma/puma), [Concurrent Ruby](https://github.com/ruby-concurrency/concurrent-ruby), and [Vernier](https://github.com/jhawthorn/vernier) maintainer_
|
|
16
|
+
|
|
17
|
+
<hr width="35%" align="left">
|
|
18
|
+
|
|
19
|
+
_[BrisRails](https://luma.com/t3l24s8h) · August 2026_
|
|
20
|
+
|
|
21
|
+
_[Ruby Melbourne](https://www.meetup.com/ruby-melbourne/events/313408465/) · October 2026_
|
|
22
|
+
|
|
23
|
+
<br clear="all" />
|
|
24
|
+
|
|
25
|
+
<br>
|
|
26
|
+
<br>
|
|
27
|
+
<br>
|
|
28
|
+
<br>
|
|
29
|
+
<br>
|
|
30
|
+
<br>
|
|
31
|
+
<br>
|
|
32
|
+
<br>
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<br>
|
|
37
|
+
<br>
|
|
38
|
+
<br>
|
|
39
|
+
<br>
|
|
40
|
+
<br>
|
|
41
|
+
<br>
|
|
42
|
+
<br>
|
|
43
|
+
<br>
|
|
44
|
+
|
|
45
|
+
## How Raptor started
|
|
46
|
+
|
|
47
|
+
<big>Working on Puma made me curious about what a web server built around Ractors could look like.</big>
|
|
48
|
+
|
|
49
|
+
<big>That became Raptor. It runs Rack apps, Rails included, and parses HTTP in parallel using Ractors.</big>
|
|
50
|
+
|
|
51
|
+
<big>**Your app doesn't need to be Ractor-safe.** It runs on ordinary threads,
|
|
52
|
+
with the same thread-safety requirements as multithreaded Puma.</big>
|
|
53
|
+
|
|
54
|
+
<big>It's still young, and I'd love more people to try it on their apps and tell me what works and what doesn't.</big>
|
|
55
|
+
|
|
56
|
+
Today we'll look inside Raptor at its Ractors, lock-free queues, red-black trees,
|
|
57
|
+
shared memory, and a tiny program running inside Linux.
|
|
58
|
+
|
|
59
|
+
<br>
|
|
60
|
+
<br>
|
|
61
|
+
<br>
|
|
62
|
+
<br>
|
|
63
|
+
<br>
|
|
64
|
+
<br>
|
|
65
|
+
<br>
|
|
66
|
+
<br>
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<br>
|
|
71
|
+
<br>
|
|
72
|
+
<br>
|
|
73
|
+
<br>
|
|
74
|
+
<br>
|
|
75
|
+
<br>
|
|
76
|
+
<br>
|
|
77
|
+
<br>
|
|
78
|
+
|
|
79
|
+
## What a web server actually does
|
|
80
|
+
|
|
81
|
+
[Rack](https://github.com/rack/rack) gives us a small boundary between the server and your app.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# A complete Rack app. Nothing more is needed.
|
|
85
|
+
run ->(env) { [200, { "content-type" => "text/plain" }, ["hello\n"]] }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
<big>Your app responds to `call`. It takes a request hash, the "Rack env", and returns `[status, headers, body]`.</big>
|
|
89
|
+
|
|
90
|
+
```mermaid
|
|
91
|
+
sequenceDiagram
|
|
92
|
+
participant C as Client
|
|
93
|
+
participant K as Kernel
|
|
94
|
+
participant S as Web server
|
|
95
|
+
participant A as Your Rack app
|
|
96
|
+
|
|
97
|
+
C->>K: Open a TCP connection
|
|
98
|
+
K->>S: accept() hands over a connected socket
|
|
99
|
+
C->>S: GET /users/42 HTTP/1.1
|
|
100
|
+
S->>S: Parse the bytes into a Rack env
|
|
101
|
+
S->>A: app.call(env)
|
|
102
|
+
A->>S: [200, headers, body]
|
|
103
|
+
S->>C: HTTP/1.1 200 OK ...
|
|
104
|
+
Note over C,S: Close, or keep the connection open for the next request
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
<big>The kernel handles TCP. The server handles HTTP.</big>
|
|
108
|
+
|
|
109
|
+
<br>
|
|
110
|
+
<br>
|
|
111
|
+
<br>
|
|
112
|
+
<br>
|
|
113
|
+
<br>
|
|
114
|
+
<br>
|
|
115
|
+
<br>
|
|
116
|
+
<br>
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
<br>
|
|
121
|
+
<br>
|
|
122
|
+
<br>
|
|
123
|
+
<br>
|
|
124
|
+
<br>
|
|
125
|
+
<br>
|
|
126
|
+
<br>
|
|
127
|
+
<br>
|
|
128
|
+
|
|
129
|
+
## The two servers you already know
|
|
130
|
+
|
|
131
|
+
**[Puma](https://github.com/puma/puma)**
|
|
132
|
+
|
|
133
|
+
<big>Worker processes, each with a pool of threads. It's HTTP/1.1 only, battle-tested, and the one I know best.</big>
|
|
134
|
+
|
|
135
|
+
**[Falcon](https://github.com/socketry/falcon)**
|
|
136
|
+
|
|
137
|
+
<big>Built on [`async`](https://github.com/socketry/async). It uses fibers instead of a thread pool, speaks HTTP/2, and is great at streaming.</big>
|
|
138
|
+
|
|
139
|
+
Fibers are another concurrency model. Raptor keeps your thread-safe Puma app on ordinary threads,
|
|
140
|
+
without requiring app changes.
|
|
141
|
+
|
|
142
|
+
<br>
|
|
143
|
+
<br>
|
|
144
|
+
<br>
|
|
145
|
+
<br>
|
|
146
|
+
<br>
|
|
147
|
+
<br>
|
|
148
|
+
<br>
|
|
149
|
+
<br>
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
<br>
|
|
154
|
+
<br>
|
|
155
|
+
<br>
|
|
156
|
+
<br>
|
|
157
|
+
<br>
|
|
158
|
+
<br>
|
|
159
|
+
<br>
|
|
160
|
+
<br>
|
|
161
|
+
|
|
162
|
+
## The Ruby 4 opening
|
|
163
|
+
|
|
164
|
+
<big>Ractors have been around since Ruby 3.0, but the API kept changing, so most of us never touched them.</big>
|
|
165
|
+
|
|
166
|
+
<big>[Ruby 4.0](https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/#ractor-improvements) introduced `Ractor::Port`.
|
|
167
|
+
Many Ractors can send to one receiver. A good fit for "many workers, one collector".</big>
|
|
168
|
+
|
|
169
|
+
<big>They're still marked experimental, but [Ruby 4.1 adds Ractor-local garbage collection](https://www.ruby-lang.org/en/news/2026/10/12/ruby-4-1-0-preview1-released/#ractor-improvements), so most collections no longer stop all the Ractors.</big>
|
|
170
|
+
|
|
171
|
+
<br>
|
|
172
|
+
<br>
|
|
173
|
+
<br>
|
|
174
|
+
<br>
|
|
175
|
+
<br>
|
|
176
|
+
<br>
|
|
177
|
+
<br>
|
|
178
|
+
<br>
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
<br>
|
|
183
|
+
<br>
|
|
184
|
+
<br>
|
|
185
|
+
<br>
|
|
186
|
+
<br>
|
|
187
|
+
<br>
|
|
188
|
+
<br>
|
|
189
|
+
<br>
|
|
190
|
+
|
|
191
|
+
## Ractors in one slide
|
|
192
|
+
|
|
193
|
+
Ruby threads normally share a Global VM Lock (GVL), so only one of them can run Ruby code at a time.
|
|
194
|
+
|
|
195
|
+
A Ractor is an isolated area of Ruby with **its own GVL**, so two Ractors can run Ruby on two cores at the same time.
|
|
196
|
+
|
|
197
|
+
The catch is isolation. A Ractor can't reach into its surroundings.
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
data = { name: "hello" }
|
|
201
|
+
|
|
202
|
+
Ractor.new { puts data }
|
|
203
|
+
# Error: the block accesses an outer variable (data).
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Instead, Ractors pass messages.
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
port = Ractor::Port.new
|
|
210
|
+
|
|
211
|
+
worker = Ractor.new(port) do |port|
|
|
212
|
+
loop { port.send("processed: #{Ractor.receive}") }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
worker.send("hello")
|
|
216
|
+
port.receive # => "processed: hello"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
<big>Shareable values, like deeply frozen arrays and hashes, are shared as is. Mutable values are copied, or moved with `move: true` so the sender can no longer use them.</big>
|
|
220
|
+
|
|
221
|
+
<big>Isolation keeps ordinary mutable objects from being changed by two Ractors at once.</big>
|
|
222
|
+
|
|
223
|
+
<br>
|
|
224
|
+
<br>
|
|
225
|
+
<br>
|
|
226
|
+
<br>
|
|
227
|
+
<br>
|
|
228
|
+
<br>
|
|
229
|
+
<br>
|
|
230
|
+
<br>
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
<br>
|
|
235
|
+
<br>
|
|
236
|
+
<br>
|
|
237
|
+
<br>
|
|
238
|
+
<br>
|
|
239
|
+
<br>
|
|
240
|
+
<br>
|
|
241
|
+
<br>
|
|
242
|
+
|
|
243
|
+
## What Ractors are good for
|
|
244
|
+
|
|
245
|
+
They won't make your Rails app parallel on their own. Rails and many of its gems aren't Ractor-safe.
|
|
246
|
+
|
|
247
|
+
<big>They suit small, CPU-heavy jobs that are easy to hand off, without relying on global state or Ractor-unsafe gems.</big>
|
|
248
|
+
|
|
249
|
+
That describes HTTP parsing exactly.
|
|
250
|
+
|
|
251
|
+
<br>
|
|
252
|
+
<br>
|
|
253
|
+
<br>
|
|
254
|
+
<br>
|
|
255
|
+
<br>
|
|
256
|
+
<br>
|
|
257
|
+
<br>
|
|
258
|
+
<br>
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
<br>
|
|
263
|
+
<br>
|
|
264
|
+
<br>
|
|
265
|
+
<br>
|
|
266
|
+
<br>
|
|
267
|
+
<br>
|
|
268
|
+
<br>
|
|
269
|
+
<br>
|
|
270
|
+
|
|
271
|
+
## The idea
|
|
272
|
+
|
|
273
|
+
In Puma, parsing and your app share the worker's GVL, even when they run on different threads.
|
|
274
|
+
|
|
275
|
+
If parsing runs in a Ractor instead, it gets its own GVL.
|
|
276
|
+
|
|
277
|
+
```mermaid
|
|
278
|
+
---
|
|
279
|
+
config:
|
|
280
|
+
gantt:
|
|
281
|
+
leftPadding: 220
|
|
282
|
+
---
|
|
283
|
+
gantt
|
|
284
|
+
title One worker process, over time (illustrative)
|
|
285
|
+
dateFormat x
|
|
286
|
+
axisFormat %L ms
|
|
287
|
+
|
|
288
|
+
section Puma (one GVL)
|
|
289
|
+
Parse request A :0, 2
|
|
290
|
+
App handles A :2, 8
|
|
291
|
+
Parse request B :8, 10
|
|
292
|
+
App handles B :10, 16
|
|
293
|
+
|
|
294
|
+
section Raptor (app GVL)
|
|
295
|
+
App handles A :2, 8
|
|
296
|
+
App handles B :8, 14
|
|
297
|
+
|
|
298
|
+
section Raptor (parser Ractor GVL)
|
|
299
|
+
Parse request A :0, 2
|
|
300
|
+
Parse request B :2, 4
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
<big>While your app handles request A, a Ractor is already parsing request B. Your app stays on normal threads, so it doesn't need to be Ractor-safe.</big>
|
|
304
|
+
|
|
305
|
+
If a whole request arrives in one read, Raptor skips the Ractor and parses it straight away.
|
|
306
|
+
|
|
307
|
+
<br>
|
|
308
|
+
<br>
|
|
309
|
+
<br>
|
|
310
|
+
<br>
|
|
311
|
+
<br>
|
|
312
|
+
<br>
|
|
313
|
+
<br>
|
|
314
|
+
<br>
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
<br>
|
|
319
|
+
<br>
|
|
320
|
+
<br>
|
|
321
|
+
<br>
|
|
322
|
+
<br>
|
|
323
|
+
<br>
|
|
324
|
+
<br>
|
|
325
|
+
<br>
|
|
326
|
+
|
|
327
|
+
## Raptor at a glance
|
|
328
|
+
|
|
329
|
+
```mermaid
|
|
330
|
+
flowchart TB
|
|
331
|
+
M["Master<br/>starts and watches workers"]
|
|
332
|
+
|
|
333
|
+
subgraph W["Worker process (one of many)"]
|
|
334
|
+
SRV["Server thread<br/>accepts sockets"]
|
|
335
|
+
RCT["Reactor thread<br/>waits for bytes"]
|
|
336
|
+
RP["Parser Ractors<br/>HTTP/1.1 and HTTP/2"]
|
|
337
|
+
COL["Collector threads"]
|
|
338
|
+
APP["App threads<br/>Rack + response"]
|
|
339
|
+
|
|
340
|
+
SRV -->|"complete request"| APP
|
|
341
|
+
SRV -->|"incomplete"| RCT
|
|
342
|
+
RCT -->|"bytes"| RP
|
|
343
|
+
RP --> COL
|
|
344
|
+
COL -->|"ready"| APP
|
|
345
|
+
COL -->|"incomplete"| RCT
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
M -->|"fork"| W
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Let's walk through each piece.
|
|
352
|
+
|
|
353
|
+
<br>
|
|
354
|
+
<br>
|
|
355
|
+
<br>
|
|
356
|
+
<br>
|
|
357
|
+
<br>
|
|
358
|
+
<br>
|
|
359
|
+
<br>
|
|
360
|
+
<br>
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
<br>
|
|
365
|
+
<br>
|
|
366
|
+
<br>
|
|
367
|
+
<br>
|
|
368
|
+
<br>
|
|
369
|
+
<br>
|
|
370
|
+
<br>
|
|
371
|
+
<br>
|
|
372
|
+
|
|
373
|
+
## The Ractor pools
|
|
374
|
+
|
|
375
|
+
Each protocol gets its own pool of parser Ractors.
|
|
376
|
+
With multiple parsers, a coordinator hands work to whichever is free.
|
|
377
|
+
|
|
378
|
+
```mermaid
|
|
379
|
+
sequenceDiagram
|
|
380
|
+
box Main Ractor
|
|
381
|
+
participant R as Reactor thread
|
|
382
|
+
participant L as Collector thread
|
|
383
|
+
end
|
|
384
|
+
box Coordinator Ractor
|
|
385
|
+
participant C as Coordinator thread
|
|
386
|
+
end
|
|
387
|
+
box One parser Ractor
|
|
388
|
+
participant P as Parser thread
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
P->>C: "I'm free" (sends itself)
|
|
392
|
+
R->>C: raw bytes for a connection
|
|
393
|
+
C->>P: forward to the free parser
|
|
394
|
+
P->>P: parse on its own GVL
|
|
395
|
+
P->>L: parsed request via Ractor::Port
|
|
396
|
+
P->>C: "I'm free" again
|
|
397
|
+
L->>L: hand it to the app thread pool
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
<big>Idle parsers send **themselves** back to the coordinator, so it always knows who's free without polling or busy-waiting.</big>
|
|
401
|
+
|
|
402
|
+
<big>The parsers are Ractor-safe C extensions, and common header names are created once and reused.</big>
|
|
403
|
+
|
|
404
|
+
I pulled this pattern out into its own gem, **[`ractor-pool`](https://github.com/joshuay03/ractor-pool)**.
|
|
405
|
+
|
|
406
|
+
<br>
|
|
407
|
+
<br>
|
|
408
|
+
<br>
|
|
409
|
+
<br>
|
|
410
|
+
<br>
|
|
411
|
+
<br>
|
|
412
|
+
<br>
|
|
413
|
+
<br>
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
<br>
|
|
418
|
+
<br>
|
|
419
|
+
<br>
|
|
420
|
+
<br>
|
|
421
|
+
<br>
|
|
422
|
+
<br>
|
|
423
|
+
<br>
|
|
424
|
+
<br>
|
|
425
|
+
|
|
426
|
+
## The app thread pool
|
|
427
|
+
|
|
428
|
+
This is where your Rack app runs.
|
|
429
|
+
|
|
430
|
+
Puma's work queue is guarded by a `Mutex`. Adding and taking work both acquire it.
|
|
431
|
+
|
|
432
|
+
Raptor's work queue has **no mutex**. To explain how, we need one tiny CPU instruction.
|
|
433
|
+
|
|
434
|
+
<br>
|
|
435
|
+
<br>
|
|
436
|
+
<br>
|
|
437
|
+
<br>
|
|
438
|
+
<br>
|
|
439
|
+
<br>
|
|
440
|
+
<br>
|
|
441
|
+
<br>
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
<br>
|
|
446
|
+
<br>
|
|
447
|
+
<br>
|
|
448
|
+
<br>
|
|
449
|
+
<br>
|
|
450
|
+
<br>
|
|
451
|
+
<br>
|
|
452
|
+
<br>
|
|
453
|
+
|
|
454
|
+
## Compare-and-swap (CAS)
|
|
455
|
+
|
|
456
|
+
"Set this value to **new**, but only if it's still **expected**." The CPU does it as one indivisible step.
|
|
457
|
+
|
|
458
|
+
```mermaid
|
|
459
|
+
sequenceDiagram
|
|
460
|
+
participant A as Thread A
|
|
461
|
+
participant M as Memory holding count
|
|
462
|
+
participant B as Thread B
|
|
463
|
+
|
|
464
|
+
Note over M: count = 42
|
|
465
|
+
A->>M: CAS(expected 42, new 43)
|
|
466
|
+
M-->>A: ✅ swapped, count is now 43
|
|
467
|
+
B->>M: CAS(expected 42, new 43)
|
|
468
|
+
M-->>B: ❌ failed, count is 43 not 42
|
|
469
|
+
B->>M: retry CAS(expected 43, new 44)
|
|
470
|
+
M-->>B: ✅ swapped, count is now 44
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
<big>Nobody waits on a lock. The loser just retries with the fresh value.</big>
|
|
474
|
+
|
|
475
|
+
<big>The catch is that it works on one value at a time, like a counter, a flag, or the head of a queue.</big>
|
|
476
|
+
|
|
477
|
+
A mutex is still simpler for bigger critical sections. CAS pays off on hot paths.
|
|
478
|
+
|
|
479
|
+
I wrote a native gem that brings these primitives to Ruby, **[`atomic-ruby`](https://github.com/joshuay03/atomic-ruby)**.
|
|
480
|
+
|
|
481
|
+
<br>
|
|
482
|
+
<br>
|
|
483
|
+
<br>
|
|
484
|
+
<br>
|
|
485
|
+
<br>
|
|
486
|
+
<br>
|
|
487
|
+
<br>
|
|
488
|
+
<br>
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
<br>
|
|
493
|
+
<br>
|
|
494
|
+
<br>
|
|
495
|
+
<br>
|
|
496
|
+
<br>
|
|
497
|
+
<br>
|
|
498
|
+
<br>
|
|
499
|
+
<br>
|
|
500
|
+
|
|
501
|
+
## A queue with no lock
|
|
502
|
+
|
|
503
|
+
Raptor's work queue is [`atomic-ruby`](https://github.com/joshuay03/atomic-ruby)'s `AtomicQueue`, a lock-free linked list where each step is a single CAS.
|
|
504
|
+
|
|
505
|
+
**Adding work C**
|
|
506
|
+
|
|
507
|
+
```mermaid
|
|
508
|
+
flowchart LR
|
|
509
|
+
H(["head"]) --> S["sentinel"] --> A["work A"] --> B["work B"]
|
|
510
|
+
T(["tail"]) --> B
|
|
511
|
+
B -. "① CAS links C after B" .-> C["work C"]
|
|
512
|
+
T -. "② CAS moves tail" .-> C
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Taking work**
|
|
516
|
+
|
|
517
|
+
```mermaid
|
|
518
|
+
flowchart LR
|
|
519
|
+
H(["head"]) --> S["sentinel"] --> A["work A"] --> B["work B"]
|
|
520
|
+
H -. "CAS moves head to A, returns A's work<br/>(A becomes the new sentinel)" .-> A
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
<big>Adding and taking never block each other. If two threads race, one CAS wins and the other retries.</big>
|
|
524
|
+
|
|
525
|
+
<br>
|
|
526
|
+
<br>
|
|
527
|
+
<br>
|
|
528
|
+
<br>
|
|
529
|
+
<br>
|
|
530
|
+
<br>
|
|
531
|
+
<br>
|
|
532
|
+
<br>
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
<br>
|
|
537
|
+
<br>
|
|
538
|
+
<br>
|
|
539
|
+
<br>
|
|
540
|
+
<br>
|
|
541
|
+
<br>
|
|
542
|
+
<br>
|
|
543
|
+
<br>
|
|
544
|
+
|
|
545
|
+
## No mutexes in Raptor's own code
|
|
546
|
+
|
|
547
|
+
There isn't a single `Mutex` in Raptor's own code. Shared state is either owned by one thread, passed along as a message, or updated with CAS.
|
|
548
|
+
|
|
549
|
+
That's not only about speed. Under the GVL, a thread can be switched out at almost any point,
|
|
550
|
+
including while it's holding a lock.
|
|
551
|
+
|
|
552
|
+
```mermaid
|
|
553
|
+
sequenceDiagram
|
|
554
|
+
participant A as Thread A
|
|
555
|
+
participant M as Mutex
|
|
556
|
+
participant B as Thread B
|
|
557
|
+
|
|
558
|
+
A->>M: lock
|
|
559
|
+
Note over A: switched out while still holding it
|
|
560
|
+
B->>M: lock
|
|
561
|
+
Note over B: has to sleep, even though it's ready to run
|
|
562
|
+
Note over A: scheduled again, eventually
|
|
563
|
+
A->>M: unlock
|
|
564
|
+
M-->>B: wake up and take the lock
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
<big>Now every thread that needs the lock is waiting on one that isn't even running.</big>
|
|
568
|
+
|
|
569
|
+
<big>CAS has nothing to hold. A thread switched out mid-update can't block anyone, and if it lost the race, it just retries.</big>
|
|
570
|
+
|
|
571
|
+
It also means there's no lock ordering to get wrong, removing one source of deadlocks.
|
|
572
|
+
|
|
573
|
+
<br>
|
|
574
|
+
<br>
|
|
575
|
+
<br>
|
|
576
|
+
<br>
|
|
577
|
+
<br>
|
|
578
|
+
<br>
|
|
579
|
+
<br>
|
|
580
|
+
<br>
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
<br>
|
|
585
|
+
<br>
|
|
586
|
+
<br>
|
|
587
|
+
<br>
|
|
588
|
+
<br>
|
|
589
|
+
<br>
|
|
590
|
+
<br>
|
|
591
|
+
<br>
|
|
592
|
+
|
|
593
|
+
## How many threads?
|
|
594
|
+
|
|
595
|
+
Thread counts are hard to tune. Too few leave requests queued. Too many can compete for the GVL.
|
|
596
|
+
The right count changes with the workload.
|
|
597
|
+
|
|
598
|
+
[`atomic-ruby`](https://github.com/joshuay03/atomic-ruby)'s pool uses CRuby's thread-event API
|
|
599
|
+
to measure time holding the GVL, waiting for it, and blocked outside it, such as during I/O.
|
|
600
|
+
|
|
601
|
+
It combines those timings with CPU time to decide whether more threads would help.
|
|
602
|
+
|
|
603
|
+
```mermaid
|
|
604
|
+
flowchart LR
|
|
605
|
+
Q{"Work queued?"}
|
|
606
|
+
Q -->|"yes"| W{"Where is time<br/>being spent?"}
|
|
607
|
+
Q -->|"no"| I{"Queue empty<br/>for 5 seconds?"}
|
|
608
|
+
W -->|"waiting on I/O<br/>(database, network)"| Add["Add more threads"]
|
|
609
|
+
W -->|"Ruby CPU saturated"| Trim["Trim back towards<br/>the base size"]
|
|
610
|
+
I -->|"no"| Stay["Keep the current size"]
|
|
611
|
+
I -->|"yes"| Retire["Retire extra threads<br/>after their current work"]
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
<big>It starts at `threads` (3 per worker by default) and adapts to the workload,
|
|
615
|
+
with no hard maximum. You don't have to choose one fixed thread count.</big>
|
|
616
|
+
|
|
617
|
+
<big>Set `max_threads` to fit your database pool or other resource limits, or match `threads` for a fixed pool.</big>
|
|
618
|
+
|
|
619
|
+
<br>
|
|
620
|
+
<br>
|
|
621
|
+
<br>
|
|
622
|
+
<br>
|
|
623
|
+
<br>
|
|
624
|
+
<br>
|
|
625
|
+
<br>
|
|
626
|
+
<br>
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
<br>
|
|
631
|
+
<br>
|
|
632
|
+
<br>
|
|
633
|
+
<br>
|
|
634
|
+
<br>
|
|
635
|
+
<br>
|
|
636
|
+
<br>
|
|
637
|
+
<br>
|
|
638
|
+
|
|
639
|
+
## The reactor
|
|
640
|
+
|
|
641
|
+
Slow clients, half-sent requests, and idle keep-alive connections aren't ready to be read yet. The reactor watches all of them at once.
|
|
642
|
+
|
|
643
|
+
<big>Like Puma, it uses [`nio4r`](https://github.com/socketry/nio4r), backed by `epoll` on Linux
|
|
644
|
+
and `kqueue` on macOS.</big>
|
|
645
|
+
|
|
646
|
+
<big>The HTTP/1.1 defaults are 30 seconds for the first bytes,
|
|
647
|
+
10 seconds between request chunks, and 65 seconds idle on keep-alive.</big>
|
|
648
|
+
|
|
649
|
+
As bytes arrive, deadlines move. The reactor needs to keep **thousands of them in order**.
|
|
650
|
+
|
|
651
|
+
<br>
|
|
652
|
+
<br>
|
|
653
|
+
<br>
|
|
654
|
+
<br>
|
|
655
|
+
<br>
|
|
656
|
+
<br>
|
|
657
|
+
<br>
|
|
658
|
+
<br>
|
|
659
|
+
|
|
660
|
+
---
|
|
661
|
+
|
|
662
|
+
<br>
|
|
663
|
+
<br>
|
|
664
|
+
<br>
|
|
665
|
+
<br>
|
|
666
|
+
<br>
|
|
667
|
+
<br>
|
|
668
|
+
<br>
|
|
669
|
+
<br>
|
|
670
|
+
|
|
671
|
+
## Keeping deadlines in order
|
|
672
|
+
|
|
673
|
+
**Puma** keeps an `Array` and sorts it after changes. Sorting is O(n log n), and removing a specific connection means scanning the whole list.
|
|
674
|
+
|
|
675
|
+
**Raptor** keeps a **red-black tree**, a binary search tree that rebalances itself as it changes.
|
|
676
|
+
|
|
677
|
+
```mermaid
|
|
678
|
+
flowchart TB
|
|
679
|
+
R["deadline 100"] --> L["deadline 50"]
|
|
680
|
+
R --> RR["deadline 150"]
|
|
681
|
+
L --> LL["deadline 25<br/>(soonest, always the leftmost)"]
|
|
682
|
+
L --> LR["deadline 75"]
|
|
683
|
+
|
|
684
|
+
classDef black fill:#222,color:#fff,stroke:#000
|
|
685
|
+
classDef red fill:#c62828,color:#fff,stroke:#8e0000
|
|
686
|
+
class R,L,RR black
|
|
687
|
+
class LL,LR red
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
<big>Inserting and removing are O(log n). Finding the soonest deadline is O(1), because it's cached.</big>
|
|
691
|
+
|
|
692
|
+
The reactor removes expired deadlines from the front. Keeping this cheap matters with thousands of connections.
|
|
693
|
+
|
|
694
|
+
That one became another gem, **[`red-black-tree`](https://github.com/joshuay03/red-black-tree)**.
|
|
695
|
+
|
|
696
|
+
<br>
|
|
697
|
+
<br>
|
|
698
|
+
<br>
|
|
699
|
+
<br>
|
|
700
|
+
<br>
|
|
701
|
+
<br>
|
|
702
|
+
<br>
|
|
703
|
+
<br>
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
<br>
|
|
708
|
+
<br>
|
|
709
|
+
<br>
|
|
710
|
+
<br>
|
|
711
|
+
<br>
|
|
712
|
+
<br>
|
|
713
|
+
<br>
|
|
714
|
+
<br>
|
|
715
|
+
|
|
716
|
+
## Writing the response
|
|
717
|
+
|
|
718
|
+
Once your app returns, the response still has to become bytes on the socket.
|
|
719
|
+
Here's the common HTTP/1.1 path with an array body.
|
|
720
|
+
|
|
721
|
+
```mermaid
|
|
722
|
+
flowchart LR
|
|
723
|
+
subgraph P["Puma"]
|
|
724
|
+
direction TB
|
|
725
|
+
PH["status line + headers<br/>(built in Ruby)"] --> PB["one buffer"]
|
|
726
|
+
PBody["small body"] -->|"copied in"| PB
|
|
727
|
+
PB -->|"write"| PS(["socket"])
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
subgraph R["Raptor"]
|
|
731
|
+
direction TB
|
|
732
|
+
RH["cached status line<br/>+ headers formatted in C"] --> RV["writev"]
|
|
733
|
+
RBody["body"] --> RV
|
|
734
|
+
RV -->|"headers + body together"| RS(["socket"])
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
P ~~~ R
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
<big>Puma formats headers in Ruby and copies small bodies into the same buffer. It asks the OS to batch separate writes.</big>
|
|
741
|
+
|
|
742
|
+
<big>Raptor formats headers in C. `writev` sends headers and body without joining them into one string, in one syscall when possible.</big>
|
|
743
|
+
<br>
|
|
744
|
+
<br>
|
|
745
|
+
<br>
|
|
746
|
+
<br>
|
|
747
|
+
<br>
|
|
748
|
+
<br>
|
|
749
|
+
<br>
|
|
750
|
+
<br>
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
<br>
|
|
755
|
+
<br>
|
|
756
|
+
<br>
|
|
757
|
+
<br>
|
|
758
|
+
<br>
|
|
759
|
+
<br>
|
|
760
|
+
<br>
|
|
761
|
+
<br>
|
|
762
|
+
|
|
763
|
+
## HTTP/2 in one slide
|
|
764
|
+
|
|
765
|
+
HTTP/1.1 sends one request at a time on a connection. HTTP/2 sends many at once,
|
|
766
|
+
split into small binary frames.
|
|
767
|
+
|
|
768
|
+
```mermaid
|
|
769
|
+
sequenceDiagram
|
|
770
|
+
participant C as Client
|
|
771
|
+
participant S as Server
|
|
772
|
+
|
|
773
|
+
Note over C,S: All on one TCP connection
|
|
774
|
+
C->>S: HEADERS (stream 1): GET /users
|
|
775
|
+
C->>S: HEADERS (stream 3): GET /posts
|
|
776
|
+
C->>S: HEADERS + DATA (stream 5): POST /log
|
|
777
|
+
S->>C: HEADERS + DATA (stream 3): 200
|
|
778
|
+
S->>C: HEADERS + DATA (stream 1): 200
|
|
779
|
+
S->>C: HEADERS (stream 5): 201
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
<big>**Multiplexing** lets many requests share one connection. **Header compression** reuses earlier headers. **Flow control** limits how much data each side can send.</big>
|
|
783
|
+
|
|
784
|
+
Raptor speaks HTTP/2 natively, and so does Falcon. Puma [leaves it to a proxy](https://github.com/puma/puma/issues/2697) like nginx.
|
|
785
|
+
|
|
786
|
+
<br>
|
|
787
|
+
<br>
|
|
788
|
+
<br>
|
|
789
|
+
<br>
|
|
790
|
+
<br>
|
|
791
|
+
<br>
|
|
792
|
+
<br>
|
|
793
|
+
<br>
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
<br>
|
|
798
|
+
<br>
|
|
799
|
+
<br>
|
|
800
|
+
<br>
|
|
801
|
+
<br>
|
|
802
|
+
<br>
|
|
803
|
+
<br>
|
|
804
|
+
<br>
|
|
805
|
+
|
|
806
|
+
## HTTP/2 in Raptor
|
|
807
|
+
|
|
808
|
+
<big>HTTP/2 gets its own C parser and Ractor pool. Each stream is its own job, so one connection can use several app threads.</big>
|
|
809
|
+
|
|
810
|
+
<big>For established connections, the reactor owns socket writes. App threads queue frames rather than competing to write to the same socket.</big>
|
|
811
|
+
|
|
812
|
+
```mermaid
|
|
813
|
+
flowchart LR
|
|
814
|
+
A1["App thread<br/>(stream 1)"] -->|"frames"| Q["Reactor's lock-free queue<br/>(AtomicQueue)"]
|
|
815
|
+
A2["App thread<br/>(stream 3)"] -->|"frames"| Q
|
|
816
|
+
A3["App thread<br/>(stream 5)"] -->|"frames"| Q
|
|
817
|
+
Q --> R["Reactor writes<br/>when the socket is ready"]
|
|
818
|
+
R --> S(["socket"])
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
<big>An ordinary response can still wait for HTTP/2 flow control. Long-lived streams need a way to release the app thread too.</big>
|
|
822
|
+
|
|
823
|
+
<br>
|
|
824
|
+
<br>
|
|
825
|
+
<br>
|
|
826
|
+
<br>
|
|
827
|
+
<br>
|
|
828
|
+
<br>
|
|
829
|
+
<br>
|
|
830
|
+
<br>
|
|
831
|
+
|
|
832
|
+
---
|
|
833
|
+
|
|
834
|
+
<br>
|
|
835
|
+
<br>
|
|
836
|
+
<br>
|
|
837
|
+
<br>
|
|
838
|
+
<br>
|
|
839
|
+
<br>
|
|
840
|
+
<br>
|
|
841
|
+
<br>
|
|
842
|
+
|
|
843
|
+
## Streams that outlive the request
|
|
844
|
+
|
|
845
|
+
SSE and gRPC responses can stay open for minutes. `Raptor::DetachedBody` lets them release the app thread.
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
run ->(env) {
|
|
849
|
+
body = Raptor::DetachedBody.new
|
|
850
|
+
body.on_open { Subscribers.add(body) }
|
|
851
|
+
body.on_close { Subscribers.remove(body) }
|
|
852
|
+
[200, { "content-type" => "text/event-stream" }, body]
|
|
853
|
+
}
|
|
854
|
+
# Later, from any thread
|
|
855
|
+
Subscribers.each { |body| body.try_write("data: update\n\n") }
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
```mermaid
|
|
859
|
+
sequenceDiagram
|
|
860
|
+
participant A as App thread
|
|
861
|
+
participant R as Reactor thread
|
|
862
|
+
participant P as Your publisher
|
|
863
|
+
participant C as Client
|
|
864
|
+
|
|
865
|
+
A->>R: hand over the headers and the body
|
|
866
|
+
P->>R: try_write(message)
|
|
867
|
+
R->>C: write when the socket is ready
|
|
868
|
+
C--xR: client disconnects
|
|
869
|
+
R->>A: schedule on_close on the app pool
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
<big>`try_write` never waits for the client. It returns `:accepted`, `:full` (no buffer space), or `:closed`. Your publisher decides whether to retry or drop an update.</big>
|
|
873
|
+
|
|
874
|
+
Cleanup runs on the app pool via `on_close`. This works over HTTP/1.1 and HTTP/2.
|
|
875
|
+
|
|
876
|
+
<br>
|
|
877
|
+
<br>
|
|
878
|
+
<br>
|
|
879
|
+
<br>
|
|
880
|
+
<br>
|
|
881
|
+
<br>
|
|
882
|
+
<br>
|
|
883
|
+
<br>
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
<br>
|
|
888
|
+
<br>
|
|
889
|
+
<br>
|
|
890
|
+
<br>
|
|
891
|
+
<br>
|
|
892
|
+
<br>
|
|
893
|
+
<br>
|
|
894
|
+
<br>
|
|
895
|
+
|
|
896
|
+
## Enough HTTP/2 for gRPC
|
|
897
|
+
|
|
898
|
+
Raptor provides the HTTP/2 pieces for unary and server-streaming gRPC.
|
|
899
|
+
`DetachedBody` streams the response after the request body arrives in full.
|
|
900
|
+
|
|
901
|
+
```ruby
|
|
902
|
+
if body.try_write(encoded_message) == :accepted
|
|
903
|
+
body.close(trailers: { "grpc-status" => "0" })
|
|
904
|
+
end # On :full, retry later rather than losing the message.
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
<big>**Trailers** carry gRPC's final status after the body. **Cancellation** closes abandoned streams, and **PINGs** help detect dead connections.</big>
|
|
908
|
+
|
|
909
|
+
<big>On shutdown, `GOAWAY` tells clients to stop opening streams while existing ones finish.</big>
|
|
910
|
+
|
|
911
|
+
<br>
|
|
912
|
+
<br>
|
|
913
|
+
<br>
|
|
914
|
+
<br>
|
|
915
|
+
<br>
|
|
916
|
+
<br>
|
|
917
|
+
<br>
|
|
918
|
+
<br>
|
|
919
|
+
|
|
920
|
+
---
|
|
921
|
+
|
|
922
|
+
<br>
|
|
923
|
+
<br>
|
|
924
|
+
<br>
|
|
925
|
+
<br>
|
|
926
|
+
<br>
|
|
927
|
+
<br>
|
|
928
|
+
<br>
|
|
929
|
+
<br>
|
|
930
|
+
|
|
931
|
+
## Zooming out to the processes
|
|
932
|
+
|
|
933
|
+
So far we've been inside one worker. Let's look at how the workers live together.
|
|
934
|
+
|
|
935
|
+
With **preforking**, the master loads your app once, then forks a copy for each worker.
|
|
936
|
+
|
|
937
|
+
```mermaid
|
|
938
|
+
flowchart TB
|
|
939
|
+
M["Master<br/>loads your app once"] -->|"fork"| W0["Worker 0"]
|
|
940
|
+
M -->|"fork"| W1["Worker 1"]
|
|
941
|
+
M -->|"fork"| W2["Worker 2"]
|
|
942
|
+
|
|
943
|
+
W0 -.-> SP[("Shared memory pages<br/>(your loaded app)")]
|
|
944
|
+
W1 -.-> SP
|
|
945
|
+
W2 -.-> SP
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
<big>Every worker starts out sharing the same memory, and a page is only copied when someone changes it. That's copy-on-write.</big>
|
|
949
|
+
|
|
950
|
+
<br>
|
|
951
|
+
<br>
|
|
952
|
+
<br>
|
|
953
|
+
<br>
|
|
954
|
+
<br>
|
|
955
|
+
<br>
|
|
956
|
+
<br>
|
|
957
|
+
<br>
|
|
958
|
+
|
|
959
|
+
---
|
|
960
|
+
|
|
961
|
+
<br>
|
|
962
|
+
<br>
|
|
963
|
+
<br>
|
|
964
|
+
<br>
|
|
965
|
+
<br>
|
|
966
|
+
<br>
|
|
967
|
+
<br>
|
|
968
|
+
<br>
|
|
969
|
+
|
|
970
|
+
## Refork
|
|
971
|
+
|
|
972
|
+
Copy-on-write savings fade as workers warm caches, compile YJIT code, and load gems lazily. Workers can lose much of that sharing as they warm up independently.
|
|
973
|
+
|
|
974
|
+
The fix is to fork replacement workers from a **warm** worker instead of the master.
|
|
975
|
+
|
|
976
|
+
```mermaid
|
|
977
|
+
sequenceDiagram
|
|
978
|
+
participant M as Master
|
|
979
|
+
participant W as Warm worker
|
|
980
|
+
participant N as New workers
|
|
981
|
+
|
|
982
|
+
Note over M: Warm worker reaches the configured request threshold
|
|
983
|
+
M->>W: You're the seed now
|
|
984
|
+
W->>W: Finish in-flight requests
|
|
985
|
+
M->>W: Fork replacement workers
|
|
986
|
+
W->>N: fork()
|
|
987
|
+
Note over N: Start with the warm caches and YJIT code,<br/>shared copy-on-write
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
<big>The idea comes from Shopify's [Pitchfork](https://github.com/Shopify/pitchfork). Puma has experimental `fork_worker` support too.</big>
|
|
991
|
+
|
|
992
|
+
<big>I recently added [`mold_worker`](https://github.com/puma/puma/pull/4038) on Puma's main branch. Like Raptor's seed, the mold stops serving requests and only forks replacements.</big>
|
|
993
|
+
|
|
994
|
+
Raptor uses Linux's `PR_SET_CHILD_SUBREAPER` so the master can adopt workers it didn't fork.
|
|
995
|
+
|
|
996
|
+
<br>
|
|
997
|
+
<br>
|
|
998
|
+
<br>
|
|
999
|
+
<br>
|
|
1000
|
+
<br>
|
|
1001
|
+
<br>
|
|
1002
|
+
<br>
|
|
1003
|
+
<br>
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
1007
|
+
<br>
|
|
1008
|
+
<br>
|
|
1009
|
+
<br>
|
|
1010
|
+
<br>
|
|
1011
|
+
<br>
|
|
1012
|
+
<br>
|
|
1013
|
+
<br>
|
|
1014
|
+
<br>
|
|
1015
|
+
|
|
1016
|
+
## Shared memory for stats
|
|
1017
|
+
|
|
1018
|
+
The master needs to know how each worker is doing. Puma's workers report through pipes, while Raptor's write straight into shared memory.
|
|
1019
|
+
|
|
1020
|
+
```mermaid
|
|
1021
|
+
flowchart LR
|
|
1022
|
+
W0["Worker 0"] -->|"writes"| S0
|
|
1023
|
+
W1["Worker 1"] -->|"writes"| S1
|
|
1024
|
+
W2["Worker 2"] -->|"writes"| S2
|
|
1025
|
+
|
|
1026
|
+
subgraph SHM["Shared memory, created before forking"]
|
|
1027
|
+
S0["slot 0"]
|
|
1028
|
+
S1["slot 1"]
|
|
1029
|
+
S2["slot 2"]
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
S0 -.->|"reads"| M["Master"]
|
|
1033
|
+
S1 -.-> M
|
|
1034
|
+
S2 -.-> M
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
<big>The master creates the region with `mmap` before forking, so every worker inherits it. Each worker owns one 49-byte slot, so writes never clash.</big>
|
|
1038
|
+
|
|
1039
|
+
<big>Updating stats is a write to shared memory, with no pipe or syscall to send the update.</big>
|
|
1040
|
+
|
|
1041
|
+
That's where **[`mmap-ruby`](https://github.com/joshuay03/mmap-ruby)** came from.
|
|
1042
|
+
|
|
1043
|
+
<br>
|
|
1044
|
+
<br>
|
|
1045
|
+
<br>
|
|
1046
|
+
<br>
|
|
1047
|
+
<br>
|
|
1048
|
+
<br>
|
|
1049
|
+
<br>
|
|
1050
|
+
<br>
|
|
1051
|
+
|
|
1052
|
+
---
|
|
1053
|
+
|
|
1054
|
+
<br>
|
|
1055
|
+
<br>
|
|
1056
|
+
<br>
|
|
1057
|
+
<br>
|
|
1058
|
+
<br>
|
|
1059
|
+
<br>
|
|
1060
|
+
<br>
|
|
1061
|
+
<br>
|
|
1062
|
+
|
|
1063
|
+
## Which worker gets the next connection?
|
|
1064
|
+
|
|
1065
|
+
With Linux's `SO_REUSEPORT`, workers get separate listeners on the same port. The kernel hashes connection details to pick one, without knowing which workers are busy.
|
|
1066
|
+
|
|
1067
|
+
In Puma, busier workers pause briefly before accepting new connections. This gives less busy workers more chance to accept them.
|
|
1068
|
+
|
|
1069
|
+
On Linux, Raptor can use **BPF** to route plain TCP connections using real load.
|
|
1070
|
+
|
|
1071
|
+
<big>BPF lets you run tiny, kernel-verified programs inside Linux. It's the same technology behind `bpftrace` and Cilium.</big>
|
|
1072
|
+
|
|
1073
|
+
<br>
|
|
1074
|
+
<br>
|
|
1075
|
+
<br>
|
|
1076
|
+
<br>
|
|
1077
|
+
<br>
|
|
1078
|
+
<br>
|
|
1079
|
+
<br>
|
|
1080
|
+
<br>
|
|
1081
|
+
|
|
1082
|
+
---
|
|
1083
|
+
|
|
1084
|
+
<br>
|
|
1085
|
+
<br>
|
|
1086
|
+
<br>
|
|
1087
|
+
<br>
|
|
1088
|
+
<br>
|
|
1089
|
+
<br>
|
|
1090
|
+
<br>
|
|
1091
|
+
<br>
|
|
1092
|
+
|
|
1093
|
+
## Raptor's BPF program
|
|
1094
|
+
|
|
1095
|
+
```mermaid
|
|
1096
|
+
flowchart LR
|
|
1097
|
+
New(["new connection"]) --> Hash["hash picks two workers"]
|
|
1098
|
+
Hash --> A["Worker 0<br/>load 5"]
|
|
1099
|
+
Hash --> B["Worker 3<br/>load 6"]
|
|
1100
|
+
A --> Pick{"pick the less busy one"}
|
|
1101
|
+
B --> Pick
|
|
1102
|
+
Pick -->|"reserve 5 → 6"| W0["Worker 0 accepts it"]
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
<big>A kernel-readable map tracks each worker's load, including pending connections.
|
|
1106
|
+
The program compares two workers and picks the quieter one.</big>
|
|
1107
|
+
|
|
1108
|
+
<big>Checking every worker adds work to every connection. The "power of two choices"
|
|
1109
|
+
helps avoid busy workers without scanning the whole cluster.</big>
|
|
1110
|
+
|
|
1111
|
+
It reserves the connection straight away, so the next arrival sees the updated load.
|
|
1112
|
+
|
|
1113
|
+
The program is about 70 lines of C, loaded through **[`libbpf-ruby`](https://github.com/joshuay03/libbpf-ruby)**.
|
|
1114
|
+
Without the optional dependencies, Raptor uses normal listeners.
|
|
1115
|
+
|
|
1116
|
+
<br>
|
|
1117
|
+
<br>
|
|
1118
|
+
<br>
|
|
1119
|
+
<br>
|
|
1120
|
+
<br>
|
|
1121
|
+
<br>
|
|
1122
|
+
<br>
|
|
1123
|
+
<br>
|
|
1124
|
+
|
|
1125
|
+
---
|
|
1126
|
+
|
|
1127
|
+
<br>
|
|
1128
|
+
<br>
|
|
1129
|
+
<br>
|
|
1130
|
+
<br>
|
|
1131
|
+
<br>
|
|
1132
|
+
<br>
|
|
1133
|
+
<br>
|
|
1134
|
+
<br>
|
|
1135
|
+
|
|
1136
|
+
## Pinning workers to cores
|
|
1137
|
+
|
|
1138
|
+
Moving workers between cores can lose warm CPU caches.
|
|
1139
|
+
|
|
1140
|
+
<big>On Linux, `cpu_affinity` pins workers to cores when enough are available.
|
|
1141
|
+
Puma doesn't have a built-in setting for this.</big>
|
|
1142
|
+
|
|
1143
|
+
<big>It's opt-in, because a pinned worker's threads and Ractors share one core.</big>
|
|
1144
|
+
|
|
1145
|
+
<br>
|
|
1146
|
+
<br>
|
|
1147
|
+
<br>
|
|
1148
|
+
<br>
|
|
1149
|
+
<br>
|
|
1150
|
+
<br>
|
|
1151
|
+
<br>
|
|
1152
|
+
<br>
|
|
1153
|
+
|
|
1154
|
+
---
|
|
1155
|
+
|
|
1156
|
+
<br>
|
|
1157
|
+
<br>
|
|
1158
|
+
<br>
|
|
1159
|
+
<br>
|
|
1160
|
+
<br>
|
|
1161
|
+
<br>
|
|
1162
|
+
<br>
|
|
1163
|
+
<br>
|
|
1164
|
+
|
|
1165
|
+
## The numbers over HTTP/1.1 without keep-alive
|
|
1166
|
+
|
|
1167
|
+
Scaling uses 3–16 threads per worker. Fixed uses 3. Falcon uses unbounded fibers.
|
|
1168
|
+
|
|
1169
|
+
**I/O-bound requests**
|
|
1170
|
+
|
|
1171
|
+
<big>With scaling threads, Raptor matches Puma on throughput and p95.
|
|
1172
|
+
Falcon has 38% higher throughput than Raptor, with lower p95.
|
|
1173
|
+
With 3 fixed threads, Raptor has over twice Puma's throughput.</big>
|
|
1174
|
+
|
|
1175
|
+
**CPU-bound requests**
|
|
1176
|
+
|
|
1177
|
+
<big>With scaling threads, Raptor gets about 23% more throughput than Puma, with 10% higher p95.
|
|
1178
|
+
With 3 fixed threads, the throughput lead is 10%. Falcon leads on both metrics.</big>
|
|
1179
|
+
|
|
1180
|
+
<br>
|
|
1181
|
+
<br>
|
|
1182
|
+
<br>
|
|
1183
|
+
<br>
|
|
1184
|
+
<br>
|
|
1185
|
+
<br>
|
|
1186
|
+
<br>
|
|
1187
|
+
<br>
|
|
1188
|
+
|
|
1189
|
+
---
|
|
1190
|
+
|
|
1191
|
+
<br>
|
|
1192
|
+
<br>
|
|
1193
|
+
<br>
|
|
1194
|
+
<br>
|
|
1195
|
+
<br>
|
|
1196
|
+
<br>
|
|
1197
|
+
<br>
|
|
1198
|
+
<br>
|
|
1199
|
+
|
|
1200
|
+
## The numbers over HTTP/1.1 keep-alive
|
|
1201
|
+
|
|
1202
|
+
Scaling uses 3–16 threads per worker. Fixed uses 3. Falcon uses unbounded fibers.
|
|
1203
|
+
|
|
1204
|
+
**I/O-bound requests**
|
|
1205
|
+
|
|
1206
|
+
<big>With scaling threads, Raptor matches Puma and Falcon on throughput and p95.
|
|
1207
|
+
With 3 fixed threads, Raptor has over twice Puma's throughput.</big>
|
|
1208
|
+
|
|
1209
|
+
**CPU-bound requests**
|
|
1210
|
+
|
|
1211
|
+
<big>With scaling threads, Raptor gets 7% more throughput and 20% lower p95 than scaling Puma.
|
|
1212
|
+
With 3 fixed threads, Puma leads on both metrics.
|
|
1213
|
+
Falcon has higher throughput, but Raptor's p95 is lower.</big>
|
|
1214
|
+
|
|
1215
|
+
<br>
|
|
1216
|
+
<br>
|
|
1217
|
+
<br>
|
|
1218
|
+
<br>
|
|
1219
|
+
<br>
|
|
1220
|
+
<br>
|
|
1221
|
+
<br>
|
|
1222
|
+
<br>
|
|
1223
|
+
|
|
1224
|
+
---
|
|
1225
|
+
|
|
1226
|
+
<br>
|
|
1227
|
+
<br>
|
|
1228
|
+
<br>
|
|
1229
|
+
<br>
|
|
1230
|
+
<br>
|
|
1231
|
+
<br>
|
|
1232
|
+
<br>
|
|
1233
|
+
<br>
|
|
1234
|
+
|
|
1235
|
+
## The numbers over HTTP/2
|
|
1236
|
+
|
|
1237
|
+
Raptor vs Falcon. Puma has no native HTTP/2.
|
|
1238
|
+
|
|
1239
|
+
**I/O-bound requests**
|
|
1240
|
+
|
|
1241
|
+
<big>With scaling threads, Raptor matches Falcon's throughput, with p95 around 30 ms versus 27 ms.
|
|
1242
|
+
With 3 fixed threads, throughput is 78% lower than Falcon.</big>
|
|
1243
|
+
|
|
1244
|
+
**CPU-bound requests**
|
|
1245
|
+
|
|
1246
|
+
<big>With scaling threads, Raptor gets 21% more throughput than Falcon at about half the p95.
|
|
1247
|
+
With 3 fixed threads, the throughput lead is 33%.</big>
|
|
1248
|
+
|
|
1249
|
+
Full results and the setup are in the [README](../README.md#micro-benchmarks).
|
|
1250
|
+
|
|
1251
|
+
<br>
|
|
1252
|
+
<br>
|
|
1253
|
+
<br>
|
|
1254
|
+
<br>
|
|
1255
|
+
<br>
|
|
1256
|
+
<br>
|
|
1257
|
+
<br>
|
|
1258
|
+
<br>
|
|
1259
|
+
|
|
1260
|
+
---
|
|
1261
|
+
|
|
1262
|
+
<br>
|
|
1263
|
+
<br>
|
|
1264
|
+
<br>
|
|
1265
|
+
<br>
|
|
1266
|
+
<br>
|
|
1267
|
+
<br>
|
|
1268
|
+
<br>
|
|
1269
|
+
<br>
|
|
1270
|
+
|
|
1271
|
+
## What you give up
|
|
1272
|
+
|
|
1273
|
+
<big>Raptor has less **battle-testing**. Puma has years of production Rails use. Raptor is less than a year old.</big>
|
|
1274
|
+
|
|
1275
|
+
<big>It needs **Ruby 4.0 or newer**. Ractors add overhead on 4.0 that I don't see in my 4.1 measurements.</big>
|
|
1276
|
+
|
|
1277
|
+
<big>**Debugging server internals** can involve tracing work across threads and Ractors.
|
|
1278
|
+
Most app developers won't need to do that. Your app stays on ordinary threads.</big>
|
|
1279
|
+
|
|
1280
|
+
<br>
|
|
1281
|
+
<br>
|
|
1282
|
+
<br>
|
|
1283
|
+
<br>
|
|
1284
|
+
<br>
|
|
1285
|
+
<br>
|
|
1286
|
+
<br>
|
|
1287
|
+
<br>
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
<br>
|
|
1292
|
+
<br>
|
|
1293
|
+
<br>
|
|
1294
|
+
<br>
|
|
1295
|
+
<br>
|
|
1296
|
+
<br>
|
|
1297
|
+
<br>
|
|
1298
|
+
<br>
|
|
1299
|
+
|
|
1300
|
+
## Why it's worth being curious
|
|
1301
|
+
|
|
1302
|
+
I'd love you to try Raptor, but I also hope you leave curious about what's possible from Ruby now.
|
|
1303
|
+
|
|
1304
|
+
<big>Raptor combines Ractors, lock-free queues, shared memory, and BPF in one Rack server.</big>
|
|
1305
|
+
|
|
1306
|
+
<big>Let the pool adapt instead of choosing one fixed thread count.
|
|
1307
|
+
Your app still runs on ordinary Rack threads.</big>
|
|
1308
|
+
|
|
1309
|
+
In these tests, scaling threads match Falcon's I/O throughput over keep-alive HTTP/1.1 and HTTP/2.
|
|
1310
|
+
|
|
1311
|
+
<big>Native HTTP/2 and detached streaming are there when you need them.</big>
|
|
1312
|
+
|
|
1313
|
+
Try your own workload to see how these pieces work together.
|
|
1314
|
+
|
|
1315
|
+
<br>
|
|
1316
|
+
<br>
|
|
1317
|
+
<br>
|
|
1318
|
+
<br>
|
|
1319
|
+
<br>
|
|
1320
|
+
<br>
|
|
1321
|
+
<br>
|
|
1322
|
+
<br>
|
|
1323
|
+
|
|
1324
|
+
---
|
|
1325
|
+
|
|
1326
|
+
<br>
|
|
1327
|
+
<br>
|
|
1328
|
+
<br>
|
|
1329
|
+
<br>
|
|
1330
|
+
<br>
|
|
1331
|
+
<br>
|
|
1332
|
+
<br>
|
|
1333
|
+
<br>
|
|
1334
|
+
|
|
1335
|
+
## Try it
|
|
1336
|
+
|
|
1337
|
+
```
|
|
1338
|
+
bundle add raptor # Ruby 4.0+
|
|
1339
|
+
bundle exec raptor # boots config.ru
|
|
1340
|
+
bundle exec rails server -u raptor
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
<big>The CLI mirrors Puma's on purpose. Try it on your app.
|
|
1344
|
+
Feedback and bug reports are very welcome.</big>
|
|
1345
|
+
|
|
1346
|
+
Test your real traffic, then try a small production canary with an easy way back to Puma.
|
|
1347
|
+
|
|
1348
|
+
For a detailed comparison of Raptor and Puma's designs, see [`docs/raptor-vs-puma.md`](raptor-vs-puma.md).
|
|
1349
|
+
|
|
1350
|
+
<br>
|
|
1351
|
+
<br>
|
|
1352
|
+
<br>
|
|
1353
|
+
<br>
|
|
1354
|
+
<br>
|
|
1355
|
+
<br>
|
|
1356
|
+
<br>
|
|
1357
|
+
<br>
|
|
1358
|
+
|
|
1359
|
+
---
|
|
1360
|
+
|
|
1361
|
+
<br>
|
|
1362
|
+
<br>
|
|
1363
|
+
<br>
|
|
1364
|
+
<br>
|
|
1365
|
+
<br>
|
|
1366
|
+
<br>
|
|
1367
|
+
<br>
|
|
1368
|
+
<br>
|
|
1369
|
+
|
|
1370
|
+
## Thanks
|
|
1371
|
+
|
|
1372
|
+
**Raptor**
|
|
1373
|
+
|
|
1374
|
+
[github.com/joshuay03/raptor](https://github.com/joshuay03/raptor)
|
|
1375
|
+
|
|
1376
|
+
**The gems that came out of it**
|
|
1377
|
+
|
|
1378
|
+
[`ractor-pool`](https://github.com/joshuay03/ractor-pool) provides Ractor workers.
|
|
1379
|
+
|
|
1380
|
+
[`atomic-ruby`](https://github.com/joshuay03/atomic-ruby) provides lock-free primitives and the thread pool.
|
|
1381
|
+
|
|
1382
|
+
[`red-black-tree`](https://github.com/joshuay03/red-black-tree) keeps deadlines in order.
|
|
1383
|
+
|
|
1384
|
+
[`mmap-ruby`](https://github.com/joshuay03/mmap-ruby) provides shared memory.
|
|
1385
|
+
|
|
1386
|
+
[`libbpf-ruby`](https://github.com/joshuay03/libbpf-ruby) loads BPF programs from Ruby.
|
|
1387
|
+
|
|
1388
|
+
_Questions?_
|