raptor 0.22.2 → 0.23.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 +35 -0
- data/Dockerfile +3 -1
- data/README.md +38 -31
- data/docs/raptor-vs-puma.md +44 -38
- data/docs/talk.md +1295 -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 +91 -119
- data/ext/raptor_http2/raptor_http2.c +272 -223
- data/ext/raptor_native/raptor_native.c +38 -20
- data/lib/rackup/handler/raptor.rb +12 -11
- data/lib/raptor/binder.rb +12 -12
- data/lib/raptor/cli.rb +18 -18
- data/lib/raptor/cluster.rb +44 -44
- data/lib/raptor/control_server.rb +3 -2
- data/lib/raptor/detached_body.rb +6 -2
- data/lib/raptor/http.rb +7 -5
- data/lib/raptor/http1.rb +233 -208
- data/lib/raptor/http2.rb +314 -267
- data/lib/raptor/reactor.rb +139 -149
- data/lib/raptor/reuseport_bpf.rb +62 -38
- data/lib/raptor/server.rb +53 -38
- 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 +9 -9
- 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 -3
- data/sig/generated/raptor/http1.rbs +100 -91
- data/sig/generated/raptor/http2.rbs +200 -189
- 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 +2 -2
- data/docs/brisrails-talk.md +0 -2398
data/docs/talk.md
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
# Raptor
|
|
2
|
+
|
|
3
|
+
## A Ruby 4 Web Server
|
|
4
|
+
|
|
5
|
+
<img src="https://github.com/joshuay03.png" width="200" alt="Joshua Young" />
|
|
6
|
+
|
|
7
|
+
_Joshua Young ([`@joshuay03`](https://github.com/joshuay03)) · Senior Software Engineer at [Buildkite](https://buildkite.com/)_
|
|
8
|
+
|
|
9
|
+
_On the [Rails](https://github.com/rails/rails), [Puma](https://github.com/puma/puma), and [Concurrent Ruby](https://github.com/ruby-concurrency/concurrent-ruby) maintainer teams_
|
|
10
|
+
|
|
11
|
+
_[BrisRails](https://luma.com/t3l24s8h) · August 2026_
|
|
12
|
+
|
|
13
|
+
_[Ruby Melbourne](https://www.meetup.com/ruby-melbourne/events/313408465/) · October 2026_
|
|
14
|
+
|
|
15
|
+
<br>
|
|
16
|
+
<br>
|
|
17
|
+
<br>
|
|
18
|
+
<br>
|
|
19
|
+
<br>
|
|
20
|
+
<br>
|
|
21
|
+
<br>
|
|
22
|
+
<br>
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<br>
|
|
27
|
+
<br>
|
|
28
|
+
<br>
|
|
29
|
+
<br>
|
|
30
|
+
<br>
|
|
31
|
+
<br>
|
|
32
|
+
<br>
|
|
33
|
+
<br>
|
|
34
|
+
|
|
35
|
+
## Hi
|
|
36
|
+
|
|
37
|
+
<big>I'm Josh. By day I work on Buildkite's Rails monolith, and I also spend a lot of time in the internals of Puma and Rails.</big>
|
|
38
|
+
|
|
39
|
+
<big>Over the past year that turned into my own Ruby web server, Raptor. It runs any Rack app, Rails included, and parses HTTP in parallel using Ractors.</big>
|
|
40
|
+
|
|
41
|
+
<big>It's still young though, so I wouldn't put your production app behind it just yet.</big>
|
|
42
|
+
|
|
43
|
+
Today is a tour of what's inside it, and the parts of Ruby and Linux I had to learn along the way:
|
|
44
|
+
|
|
45
|
+
- <big>Ractors, and what changed in Ruby 4</big>
|
|
46
|
+
- <big>Lock-free data structures built on compare-and-swap</big>
|
|
47
|
+
- <big>Red-black trees</big>
|
|
48
|
+
- <big>Shared memory between processes</big>
|
|
49
|
+
- <big>Tiny programs that run inside the Linux kernel</big>
|
|
50
|
+
|
|
51
|
+
<br>
|
|
52
|
+
<br>
|
|
53
|
+
<br>
|
|
54
|
+
<br>
|
|
55
|
+
<br>
|
|
56
|
+
<br>
|
|
57
|
+
<br>
|
|
58
|
+
<br>
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<br>
|
|
63
|
+
<br>
|
|
64
|
+
<br>
|
|
65
|
+
<br>
|
|
66
|
+
<br>
|
|
67
|
+
<br>
|
|
68
|
+
<br>
|
|
69
|
+
<br>
|
|
70
|
+
|
|
71
|
+
## What a web server actually does
|
|
72
|
+
|
|
73
|
+
Rails isn't a web server. [Rack](https://github.com/rack/rack) isn't one either. Rack is the contract between a web server and your app:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# A complete Rack app. Nothing more is needed.
|
|
77
|
+
run ->(env) { [200, { "content-type" => "text/plain" }, ["hello\n"]] }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- <big>Your app is anything that responds to `#call`</big>
|
|
81
|
+
- <big>It takes a `Hash` describing the request (the "Rack env")</big>
|
|
82
|
+
- <big>It returns `[status, headers, body]`</big>
|
|
83
|
+
|
|
84
|
+
Everything else is the web server's job:
|
|
85
|
+
|
|
86
|
+
```mermaid
|
|
87
|
+
sequenceDiagram
|
|
88
|
+
participant C as Client
|
|
89
|
+
participant K as Kernel
|
|
90
|
+
participant S as Web server
|
|
91
|
+
participant A as Your Rack app
|
|
92
|
+
|
|
93
|
+
C->>K: Open a TCP connection
|
|
94
|
+
K->>S: accept() hands over a connected socket
|
|
95
|
+
C->>S: GET /users/42 HTTP/1.1
|
|
96
|
+
S->>S: Parse the bytes into a Rack env
|
|
97
|
+
S->>A: app.call(env)
|
|
98
|
+
A->>S: [200, headers, body]
|
|
99
|
+
S->>C: HTTP/1.1 200 OK ...
|
|
100
|
+
Note over C,S: Close, or keep the connection open for the next request
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- <big>The kernel does the TCP handshake. The server just calls `accept` and gets a ready socket.</big>
|
|
104
|
+
- <big>The server reads bytes, parses them, calls your app, and writes the response back</big>
|
|
105
|
+
- <big>With keep-alive, the same connection carries the next request</big>
|
|
106
|
+
|
|
107
|
+
<br>
|
|
108
|
+
<br>
|
|
109
|
+
<br>
|
|
110
|
+
<br>
|
|
111
|
+
<br>
|
|
112
|
+
<br>
|
|
113
|
+
<br>
|
|
114
|
+
<br>
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
<br>
|
|
119
|
+
<br>
|
|
120
|
+
<br>
|
|
121
|
+
<br>
|
|
122
|
+
<br>
|
|
123
|
+
<br>
|
|
124
|
+
<br>
|
|
125
|
+
<br>
|
|
126
|
+
|
|
127
|
+
## The two servers you already know
|
|
128
|
+
|
|
129
|
+
**[Puma](https://github.com/puma/puma)**
|
|
130
|
+
|
|
131
|
+
<big>Worker processes, each with a pool of threads. It's HTTP/1.1 only, battle-tested, and the one I know best.</big>
|
|
132
|
+
|
|
133
|
+
**[Falcon](https://github.com/socketry/falcon)**
|
|
134
|
+
|
|
135
|
+
<big>Built by Samuel Williams on his [`async`](https://github.com/socketry/async) gem. It uses fibers instead of a thread pool, speaks HTTP/2, and is great at streaming.</big>
|
|
136
|
+
|
|
137
|
+
Both are great, but both were designed before Ractors were usable.
|
|
138
|
+
|
|
139
|
+
<br>
|
|
140
|
+
<br>
|
|
141
|
+
<br>
|
|
142
|
+
<br>
|
|
143
|
+
<br>
|
|
144
|
+
<br>
|
|
145
|
+
<br>
|
|
146
|
+
<br>
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
<br>
|
|
151
|
+
<br>
|
|
152
|
+
<br>
|
|
153
|
+
<br>
|
|
154
|
+
<br>
|
|
155
|
+
<br>
|
|
156
|
+
<br>
|
|
157
|
+
<br>
|
|
158
|
+
|
|
159
|
+
## The Ruby 4 opening
|
|
160
|
+
|
|
161
|
+
<big>Ractors have been around since Ruby 3.0, but the API kept changing, so most of us never touched them.</big>
|
|
162
|
+
|
|
163
|
+
<big>Ruby 4 reworked them around `Ractor::Port`, which many Ractors can send to and one can receive from. That's exactly the shape of "many workers, one collector".</big>
|
|
164
|
+
|
|
165
|
+
<big>They're still marked experimental, but Ruby 4.1 moves them closer to dropping that label. For one, each Ractor will collect its own garbage without stopping the others.</big>
|
|
166
|
+
|
|
167
|
+
That got me wondering:
|
|
168
|
+
|
|
169
|
+
> What would a Ruby web server look like if it was built around Ractors from the start?
|
|
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 has a Global VM Lock (GVL). Only one thread at a time can run Ruby code.
|
|
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
|
+
# ArgumentError: can not isolate a Proc because it accesses outer variables (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>Frozen values are shared as is</big>
|
|
220
|
+
- <big>Mutable values are deep-copied, or moved with `move: true` so the sender can't touch them again</big>
|
|
221
|
+
- <big>No shared mutable state means no data races</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 app parallel on their own, because almost no gem you use is Ractor-safe. Where they do help is work that is:
|
|
246
|
+
|
|
247
|
+
- <big>Small and CPU-heavy</big>
|
|
248
|
+
- <big>Free of global state</big>
|
|
249
|
+
- <big>Free of gem dependencies</big>
|
|
250
|
+
- <big>Easy to hand off: bytes in, result out</big>
|
|
251
|
+
|
|
252
|
+
That describes HTTP parsing exactly.
|
|
253
|
+
|
|
254
|
+
<br>
|
|
255
|
+
<br>
|
|
256
|
+
<br>
|
|
257
|
+
<br>
|
|
258
|
+
<br>
|
|
259
|
+
<br>
|
|
260
|
+
<br>
|
|
261
|
+
<br>
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
<br>
|
|
266
|
+
<br>
|
|
267
|
+
<br>
|
|
268
|
+
<br>
|
|
269
|
+
<br>
|
|
270
|
+
<br>
|
|
271
|
+
<br>
|
|
272
|
+
<br>
|
|
273
|
+
|
|
274
|
+
## The idea
|
|
275
|
+
|
|
276
|
+
Puma parses each request on the same thread that's about to run your app. Parsing and your app take turns on the same GVL.
|
|
277
|
+
|
|
278
|
+
If parsing runs in a Ractor instead, it gets its own GVL:
|
|
279
|
+
|
|
280
|
+
```mermaid
|
|
281
|
+
gantt
|
|
282
|
+
title One worker process, over time
|
|
283
|
+
dateFormat x
|
|
284
|
+
axisFormat %L ms
|
|
285
|
+
|
|
286
|
+
section Puma (one GVL)
|
|
287
|
+
Parse request A :0, 2
|
|
288
|
+
App handles A :2, 8
|
|
289
|
+
Parse request B :8, 10
|
|
290
|
+
App handles B :10, 16
|
|
291
|
+
|
|
292
|
+
section Raptor (app GVL)
|
|
293
|
+
App handles A :2, 8
|
|
294
|
+
App handles B :8, 14
|
|
295
|
+
|
|
296
|
+
section Raptor (parser Ractor GVL)
|
|
297
|
+
Parse request A :0, 2
|
|
298
|
+
Parse request B :2, 4
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
<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>
|
|
302
|
+
|
|
303
|
+
If a whole request arrives in one read, Raptor skips the Ractor and parses it straight away.
|
|
304
|
+
|
|
305
|
+
<br>
|
|
306
|
+
<br>
|
|
307
|
+
<br>
|
|
308
|
+
<br>
|
|
309
|
+
<br>
|
|
310
|
+
<br>
|
|
311
|
+
<br>
|
|
312
|
+
<br>
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
<br>
|
|
317
|
+
<br>
|
|
318
|
+
<br>
|
|
319
|
+
<br>
|
|
320
|
+
<br>
|
|
321
|
+
<br>
|
|
322
|
+
<br>
|
|
323
|
+
<br>
|
|
324
|
+
|
|
325
|
+
## Raptor at a glance
|
|
326
|
+
|
|
327
|
+
```mermaid
|
|
328
|
+
flowchart TB
|
|
329
|
+
M["Master process<br/>starts and watches workers"]
|
|
330
|
+
|
|
331
|
+
subgraph W["Worker process (one of many)"]
|
|
332
|
+
SRV["Server thread<br/>accepts connections"]
|
|
333
|
+
RCT["Reactor thread<br/>waits on idle and partial connections"]
|
|
334
|
+
RP["Ractor pools<br/>parse HTTP/1.1 and HTTP/2<br/>on their own GVLs"]
|
|
335
|
+
COL["Collector threads<br/>gather parsed requests"]
|
|
336
|
+
APP["App thread pool<br/>runs your Rack app<br/>and writes responses"]
|
|
337
|
+
|
|
338
|
+
SRV -->|"whole request already here"| APP
|
|
339
|
+
SRV -->|"needs more bytes"| RCT
|
|
340
|
+
RCT -->|"raw bytes"| RP
|
|
341
|
+
RP --> COL
|
|
342
|
+
COL -->|"complete request"| APP
|
|
343
|
+
COL -->|"still incomplete"| RCT
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
M -->|"fork"| W
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Let's walk through each piece.
|
|
350
|
+
|
|
351
|
+
<br>
|
|
352
|
+
<br>
|
|
353
|
+
<br>
|
|
354
|
+
<br>
|
|
355
|
+
<br>
|
|
356
|
+
<br>
|
|
357
|
+
<br>
|
|
358
|
+
<br>
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
<br>
|
|
363
|
+
<br>
|
|
364
|
+
<br>
|
|
365
|
+
<br>
|
|
366
|
+
<br>
|
|
367
|
+
<br>
|
|
368
|
+
<br>
|
|
369
|
+
<br>
|
|
370
|
+
|
|
371
|
+
## The Ractor pools
|
|
372
|
+
|
|
373
|
+
Each protocol gets its own pool of parser Ractors. A coordinator hands work to whichever parser is free.
|
|
374
|
+
|
|
375
|
+
```mermaid
|
|
376
|
+
sequenceDiagram
|
|
377
|
+
box Main Ractor
|
|
378
|
+
participant R as Reactor thread
|
|
379
|
+
participant L as Collector thread
|
|
380
|
+
end
|
|
381
|
+
box Coordinator Ractor
|
|
382
|
+
participant C as Coordinator thread
|
|
383
|
+
end
|
|
384
|
+
box Parser Ractor
|
|
385
|
+
participant P as Parser thread
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
P->>C: "I'm free" (sends itself)
|
|
389
|
+
R->>C: raw bytes for a connection
|
|
390
|
+
C->>P: forward to the free parser
|
|
391
|
+
P->>P: parse on its own GVL
|
|
392
|
+
P->>L: parsed request via Ractor::Port
|
|
393
|
+
P->>C: "I'm free" again
|
|
394
|
+
L->>L: hand it to the app thread pool
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
<big>Idle parsers send **themselves** back to the coordinator, so it always knows who's free without polling or busy-waiting.</big>
|
|
398
|
+
|
|
399
|
+
<big>The parsers are Ractor-safe C extensions, and common header names are created once and reused.</big>
|
|
400
|
+
|
|
401
|
+
I pulled this pattern out into its own gem, **[`ractor-pool`](https://github.com/joshuay03/ractor-pool)**.
|
|
402
|
+
|
|
403
|
+
<br>
|
|
404
|
+
<br>
|
|
405
|
+
<br>
|
|
406
|
+
<br>
|
|
407
|
+
<br>
|
|
408
|
+
<br>
|
|
409
|
+
<br>
|
|
410
|
+
<br>
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
<br>
|
|
415
|
+
<br>
|
|
416
|
+
<br>
|
|
417
|
+
<br>
|
|
418
|
+
<br>
|
|
419
|
+
<br>
|
|
420
|
+
<br>
|
|
421
|
+
<br>
|
|
422
|
+
|
|
423
|
+
## The app thread pool
|
|
424
|
+
|
|
425
|
+
This is where your Rack app runs.
|
|
426
|
+
|
|
427
|
+
Puma's pool is a queue guarded by a `Mutex`, so when things get busy, threads line up behind the lock.
|
|
428
|
+
|
|
429
|
+
Raptor's pool has **no locks** at all. To explain how, we need one tiny CPU instruction.
|
|
430
|
+
|
|
431
|
+
<br>
|
|
432
|
+
<br>
|
|
433
|
+
<br>
|
|
434
|
+
<br>
|
|
435
|
+
<br>
|
|
436
|
+
<br>
|
|
437
|
+
<br>
|
|
438
|
+
<br>
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
<br>
|
|
443
|
+
<br>
|
|
444
|
+
<br>
|
|
445
|
+
<br>
|
|
446
|
+
<br>
|
|
447
|
+
<br>
|
|
448
|
+
<br>
|
|
449
|
+
<br>
|
|
450
|
+
|
|
451
|
+
## Compare-and-swap (CAS)
|
|
452
|
+
|
|
453
|
+
"Set this value to **new**, but only if it's still **expected**." The CPU does it as one indivisible step.
|
|
454
|
+
|
|
455
|
+
```mermaid
|
|
456
|
+
sequenceDiagram
|
|
457
|
+
participant A as Thread A
|
|
458
|
+
participant M as Memory: count
|
|
459
|
+
participant B as Thread B
|
|
460
|
+
|
|
461
|
+
Note over M: count = 42
|
|
462
|
+
A->>M: CAS(expected 42, new 43)
|
|
463
|
+
M-->>A: ✅ swapped, count is now 43
|
|
464
|
+
B->>M: CAS(expected 42, new 43)
|
|
465
|
+
M-->>B: ❌ failed, count is 43 not 42
|
|
466
|
+
B->>M: retry: CAS(expected 43, new 44)
|
|
467
|
+
M-->>B: ✅ swapped, count is now 44
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
<big>Nobody waits on a lock. The loser just retries with the fresh value.</big>
|
|
471
|
+
|
|
472
|
+
<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>
|
|
473
|
+
|
|
474
|
+
A mutex is still simpler for bigger critical sections. CAS pays off on hot paths.
|
|
475
|
+
|
|
476
|
+
I wrote a native gem that brings these primitives to Ruby, **[`atomic-ruby`](https://github.com/joshuay03/atomic-ruby)**.
|
|
477
|
+
|
|
478
|
+
<br>
|
|
479
|
+
<br>
|
|
480
|
+
<br>
|
|
481
|
+
<br>
|
|
482
|
+
<br>
|
|
483
|
+
<br>
|
|
484
|
+
<br>
|
|
485
|
+
<br>
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
<br>
|
|
490
|
+
<br>
|
|
491
|
+
<br>
|
|
492
|
+
<br>
|
|
493
|
+
<br>
|
|
494
|
+
<br>
|
|
495
|
+
<br>
|
|
496
|
+
<br>
|
|
497
|
+
|
|
498
|
+
## A queue with no lock
|
|
499
|
+
|
|
500
|
+
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.
|
|
501
|
+
|
|
502
|
+
**Adding work C**
|
|
503
|
+
|
|
504
|
+
```mermaid
|
|
505
|
+
flowchart LR
|
|
506
|
+
H(["head"]) --> S["sentinel"] --> A["work A"] --> B["work B"]
|
|
507
|
+
T(["tail"]) --> B
|
|
508
|
+
B -. "① CAS: link C after B" .-> C["work C"]
|
|
509
|
+
T -. "② CAS: move tail to C" .-> C
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**Taking work**
|
|
513
|
+
|
|
514
|
+
```mermaid
|
|
515
|
+
flowchart LR
|
|
516
|
+
H(["head"]) --> S["sentinel"] --> A["work A"] --> B["work B"]
|
|
517
|
+
H -. "CAS: move head to A, return A's work<br/>(A becomes the new sentinel)" .-> A
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
<big>Adding and taking never block each other. If two threads race, one CAS wins and the other retries.</big>
|
|
521
|
+
|
|
522
|
+
<br>
|
|
523
|
+
<br>
|
|
524
|
+
<br>
|
|
525
|
+
<br>
|
|
526
|
+
<br>
|
|
527
|
+
<br>
|
|
528
|
+
<br>
|
|
529
|
+
<br>
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
<br>
|
|
534
|
+
<br>
|
|
535
|
+
<br>
|
|
536
|
+
<br>
|
|
537
|
+
<br>
|
|
538
|
+
<br>
|
|
539
|
+
<br>
|
|
540
|
+
<br>
|
|
541
|
+
|
|
542
|
+
## No mutexes at all
|
|
543
|
+
|
|
544
|
+
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.
|
|
545
|
+
|
|
546
|
+
That's not only about speed. Under the GVL, a thread can be switched out at almost any point, including while it's holding a lock:
|
|
547
|
+
|
|
548
|
+
```mermaid
|
|
549
|
+
sequenceDiagram
|
|
550
|
+
participant A as Thread A
|
|
551
|
+
participant M as Mutex
|
|
552
|
+
participant B as Thread B
|
|
553
|
+
|
|
554
|
+
A->>M: lock
|
|
555
|
+
Note over A: switched out while still holding it
|
|
556
|
+
B->>M: lock
|
|
557
|
+
Note over B: has to sleep, even though it's ready to run
|
|
558
|
+
Note over A: scheduled again, eventually
|
|
559
|
+
A->>M: unlock
|
|
560
|
+
M-->>B: wake up and take the lock
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
<big>Now every thread that needs the lock is waiting on one that isn't even running.</big>
|
|
564
|
+
|
|
565
|
+
<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>
|
|
566
|
+
|
|
567
|
+
It also means there's no lock ordering to get wrong, which is where deadlocks come from.
|
|
568
|
+
|
|
569
|
+
<br>
|
|
570
|
+
<br>
|
|
571
|
+
<br>
|
|
572
|
+
<br>
|
|
573
|
+
<br>
|
|
574
|
+
<br>
|
|
575
|
+
<br>
|
|
576
|
+
<br>
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
<br>
|
|
581
|
+
<br>
|
|
582
|
+
<br>
|
|
583
|
+
<br>
|
|
584
|
+
<br>
|
|
585
|
+
<br>
|
|
586
|
+
<br>
|
|
587
|
+
<br>
|
|
588
|
+
|
|
589
|
+
## How many threads?
|
|
590
|
+
|
|
591
|
+
Most requests spend their time waiting on the database or the network. More threads help with waiting, but not with running Ruby.
|
|
592
|
+
|
|
593
|
+
Raptor uses [`atomic-ruby`](https://github.com/joshuay03/atomic-ruby)'s `AtomicThreadPool`, which uses a CRuby hook to see what each thread is actually doing:
|
|
594
|
+
|
|
595
|
+
```mermaid
|
|
596
|
+
flowchart TB
|
|
597
|
+
Q{"Are requests waiting<br/>in the queue?"}
|
|
598
|
+
Q -->|"yes"| W{"What are the threads<br/>mostly doing?"}
|
|
599
|
+
Q -->|"no"| I{"Has the queue been<br/>empty for 5 seconds?"}
|
|
600
|
+
W -->|"waiting on I/O<br/>(database, network)"| Add["Add more threads"]
|
|
601
|
+
W -->|"running Ruby<br/>(CPU is the limit)"| Trim["Trim back towards<br/>the base size"]
|
|
602
|
+
I -->|"no"| Stay["Keep the current size"]
|
|
603
|
+
I -->|"yes"| Retire["Retire the extra threads"]
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
<big>It starts at `threads` (3 by default). Set `max_threads` to cap the growth, or match `threads` for a fixed pool.</big>
|
|
607
|
+
|
|
608
|
+
<br>
|
|
609
|
+
<br>
|
|
610
|
+
<br>
|
|
611
|
+
<br>
|
|
612
|
+
<br>
|
|
613
|
+
<br>
|
|
614
|
+
<br>
|
|
615
|
+
<br>
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
<br>
|
|
620
|
+
<br>
|
|
621
|
+
<br>
|
|
622
|
+
<br>
|
|
623
|
+
<br>
|
|
624
|
+
<br>
|
|
625
|
+
<br>
|
|
626
|
+
<br>
|
|
627
|
+
|
|
628
|
+
## The reactor
|
|
629
|
+
|
|
630
|
+
Some connections aren't ready to be read yet: slow clients, half-sent requests, idle keep-alive connections. The reactor watches all of them at once.
|
|
631
|
+
|
|
632
|
+
- <big>Built on [`nio4r`](https://github.com/socketry/nio4r) (`epoll` on Linux, `kqueue` on macOS), same as Puma</big>
|
|
633
|
+
- <big>Every connection also has a deadline:</big>
|
|
634
|
+
- 30s to send the first bytes
|
|
635
|
+
- 10s between chunks of a request
|
|
636
|
+
- 65s idle between keep-alive requests
|
|
637
|
+
|
|
638
|
+
Every time a connection sends bytes, its deadline moves. So the reactor needs a cheap way to keep **thousands of deadlines in order**.
|
|
639
|
+
|
|
640
|
+
<br>
|
|
641
|
+
<br>
|
|
642
|
+
<br>
|
|
643
|
+
<br>
|
|
644
|
+
<br>
|
|
645
|
+
<br>
|
|
646
|
+
<br>
|
|
647
|
+
<br>
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
<br>
|
|
652
|
+
<br>
|
|
653
|
+
<br>
|
|
654
|
+
<br>
|
|
655
|
+
<br>
|
|
656
|
+
<br>
|
|
657
|
+
<br>
|
|
658
|
+
<br>
|
|
659
|
+
|
|
660
|
+
## Keeping deadlines in order
|
|
661
|
+
|
|
662
|
+
**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.
|
|
663
|
+
|
|
664
|
+
**Raptor** keeps a **red-black tree**: a binary search tree that rebalances itself as it changes.
|
|
665
|
+
|
|
666
|
+
```mermaid
|
|
667
|
+
flowchart TB
|
|
668
|
+
R["deadline 100"] --> L["deadline 50"]
|
|
669
|
+
R --> RR["deadline 150"]
|
|
670
|
+
L --> LL["deadline 25<br/>(soonest, always the leftmost)"]
|
|
671
|
+
L --> LR["deadline 75"]
|
|
672
|
+
|
|
673
|
+
classDef black fill:#222,color:#fff,stroke:#000
|
|
674
|
+
classDef red fill:#c62828,color:#fff,stroke:#8e0000
|
|
675
|
+
class R,L,RR black
|
|
676
|
+
class LL,LR red
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
<big>Inserting, removing, and finding the soonest deadline are all O(log n). Expiring connections means walking from the leftmost node until one isn't due.</big>
|
|
680
|
+
|
|
681
|
+
With a hundred connections you wouldn't notice the difference, but it adds up once you have thousands.
|
|
682
|
+
|
|
683
|
+
That one became another gem, **[`red-black-tree`](https://github.com/joshuay03/red-black-tree)**.
|
|
684
|
+
|
|
685
|
+
<br>
|
|
686
|
+
<br>
|
|
687
|
+
<br>
|
|
688
|
+
<br>
|
|
689
|
+
<br>
|
|
690
|
+
<br>
|
|
691
|
+
<br>
|
|
692
|
+
<br>
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
<br>
|
|
697
|
+
<br>
|
|
698
|
+
<br>
|
|
699
|
+
<br>
|
|
700
|
+
<br>
|
|
701
|
+
<br>
|
|
702
|
+
<br>
|
|
703
|
+
<br>
|
|
704
|
+
|
|
705
|
+
## Writing the response
|
|
706
|
+
|
|
707
|
+
Once your app returns, the response still has to become bytes on the socket.
|
|
708
|
+
|
|
709
|
+
```mermaid
|
|
710
|
+
flowchart LR
|
|
711
|
+
subgraph P["Puma"]
|
|
712
|
+
direction TB
|
|
713
|
+
PH["status line + headers<br/>(built in Ruby)"] --> PB["one buffer"]
|
|
714
|
+
PBody["small body"] -->|"copied in"| PB
|
|
715
|
+
PB -->|"write"| PS(["socket"])
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
subgraph R["Raptor"]
|
|
719
|
+
direction TB
|
|
720
|
+
RH["status line + headers<br/>(built in C)"] --> RV["writev"]
|
|
721
|
+
RBody["body"] --> RV
|
|
722
|
+
RV -->|"one syscall, no copy"| RS(["socket"])
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
P ~~~ R
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
<big>Puma builds the response in Ruby, then either copies small bodies into its header buffer or corks the socket for larger ones.</big>
|
|
729
|
+
|
|
730
|
+
<big>Raptor builds it in C and sends the headers and body together with one `writev` call, without copying them into one string.</big>
|
|
731
|
+
<br>
|
|
732
|
+
<br>
|
|
733
|
+
<br>
|
|
734
|
+
<br>
|
|
735
|
+
<br>
|
|
736
|
+
<br>
|
|
737
|
+
<br>
|
|
738
|
+
<br>
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
<br>
|
|
743
|
+
<br>
|
|
744
|
+
<br>
|
|
745
|
+
<br>
|
|
746
|
+
<br>
|
|
747
|
+
<br>
|
|
748
|
+
<br>
|
|
749
|
+
<br>
|
|
750
|
+
|
|
751
|
+
## HTTP/2 in one slide
|
|
752
|
+
|
|
753
|
+
HTTP/1.1 sends one request at a time on a connection. HTTP/2 sends many at once, split into small binary frames:
|
|
754
|
+
|
|
755
|
+
```mermaid
|
|
756
|
+
sequenceDiagram
|
|
757
|
+
participant C as Client
|
|
758
|
+
participant S as Server
|
|
759
|
+
|
|
760
|
+
Note over C,S: All on one TCP connection
|
|
761
|
+
C->>S: HEADERS (stream 1): GET /users
|
|
762
|
+
C->>S: HEADERS (stream 3): GET /posts
|
|
763
|
+
C->>S: HEADERS + DATA (stream 5): POST /log
|
|
764
|
+
S->>C: HEADERS + DATA (stream 3): 200
|
|
765
|
+
S->>C: HEADERS + DATA (stream 1): 200
|
|
766
|
+
S->>C: HEADERS (stream 5): 201
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
- <big>**Multiplexing**: many requests (streams) share one connection</big>
|
|
770
|
+
- <big>**Header compression** (HPACK): headers you've already sent become tiny numbers</big>
|
|
771
|
+
- <big>**Flow control**: each side says how much data it's ready to receive</big>
|
|
772
|
+
|
|
773
|
+
Raptor speaks HTTP/2 natively, and so does Falcon. Puma [leaves it to a proxy](https://github.com/puma/puma/issues/2697) like nginx.
|
|
774
|
+
|
|
775
|
+
<br>
|
|
776
|
+
<br>
|
|
777
|
+
<br>
|
|
778
|
+
<br>
|
|
779
|
+
<br>
|
|
780
|
+
<br>
|
|
781
|
+
<br>
|
|
782
|
+
<br>
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
<br>
|
|
787
|
+
<br>
|
|
788
|
+
<br>
|
|
789
|
+
<br>
|
|
790
|
+
<br>
|
|
791
|
+
<br>
|
|
792
|
+
<br>
|
|
793
|
+
<br>
|
|
794
|
+
|
|
795
|
+
## HTTP/2 in Raptor
|
|
796
|
+
|
|
797
|
+
<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>
|
|
798
|
+
|
|
799
|
+
<big>Only the reactor ever writes to the socket. App threads hand it frames and carry on.</big>
|
|
800
|
+
|
|
801
|
+
```mermaid
|
|
802
|
+
flowchart LR
|
|
803
|
+
A1["App thread<br/>(stream 1)"] -->|"frames"| Q["Reactor's lock-free queue<br/>(AtomicQueue)"]
|
|
804
|
+
A2["App thread<br/>(stream 3)"] -->|"frames"| Q
|
|
805
|
+
A3["App thread<br/>(stream 5)"] -->|"frames"| Q
|
|
806
|
+
Q --> R["Reactor writes<br/>when the socket is ready"]
|
|
807
|
+
R --> S(["socket"])
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
<big>No thread ever waits for a slow client to read, and there's no lock around the socket because only one thread owns it.</big>
|
|
811
|
+
|
|
812
|
+
<br>
|
|
813
|
+
<br>
|
|
814
|
+
<br>
|
|
815
|
+
<br>
|
|
816
|
+
<br>
|
|
817
|
+
<br>
|
|
818
|
+
<br>
|
|
819
|
+
<br>
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
<br>
|
|
824
|
+
<br>
|
|
825
|
+
<br>
|
|
826
|
+
<br>
|
|
827
|
+
<br>
|
|
828
|
+
<br>
|
|
829
|
+
<br>
|
|
830
|
+
<br>
|
|
831
|
+
|
|
832
|
+
## Streams that outlive the request
|
|
833
|
+
|
|
834
|
+
Some responses stay open for minutes, like server-sent events or gRPC streams. Normally each one ties up an app thread the whole time.
|
|
835
|
+
|
|
836
|
+
Raptor's answer is `Raptor::DetachedBody`:
|
|
837
|
+
|
|
838
|
+
```ruby
|
|
839
|
+
run ->(env) {
|
|
840
|
+
body = Raptor::DetachedBody.new
|
|
841
|
+
body.on_open { Subscribers.add(body) }
|
|
842
|
+
body.on_close { Subscribers.remove(body) }
|
|
843
|
+
[200, { "content-type" => "text/event-stream" }, body]
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
# Later, from any thread
|
|
847
|
+
Subscribers.each { |body| body.try_write("data: update\n\n") }
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
```mermaid
|
|
851
|
+
sequenceDiagram
|
|
852
|
+
participant A as App thread
|
|
853
|
+
participant R as Reactor thread
|
|
854
|
+
participant P as Your publisher
|
|
855
|
+
participant C as Client
|
|
856
|
+
|
|
857
|
+
A->>R: hand over the headers and the body
|
|
858
|
+
A->>P: on_open adds the body
|
|
859
|
+
Note over A: Thread goes back to the pool
|
|
860
|
+
P->>R: try_write(message)
|
|
861
|
+
R->>C: write when the socket is ready
|
|
862
|
+
C--xR: client disconnects
|
|
863
|
+
R->>P: on_close removes the body
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
<big>The app thread returns straight away and the reactor owns the stream from then on. `try_write` never blocks.</big>
|
|
867
|
+
|
|
868
|
+
<big>When the client disconnects or the server shuts down, `on_close` runs immediately. It works over both HTTP/1.1 and HTTP/2.</big>
|
|
869
|
+
|
|
870
|
+
The first time I tried this at work, every new stream still held a thread for about 1.5ms. The handoff was polling, so I changed it to wake the thread instead.
|
|
871
|
+
|
|
872
|
+
<br>
|
|
873
|
+
<br>
|
|
874
|
+
<br>
|
|
875
|
+
<br>
|
|
876
|
+
<br>
|
|
877
|
+
<br>
|
|
878
|
+
<br>
|
|
879
|
+
<br>
|
|
880
|
+
|
|
881
|
+
---
|
|
882
|
+
|
|
883
|
+
<br>
|
|
884
|
+
<br>
|
|
885
|
+
<br>
|
|
886
|
+
<br>
|
|
887
|
+
<br>
|
|
888
|
+
<br>
|
|
889
|
+
<br>
|
|
890
|
+
<br>
|
|
891
|
+
|
|
892
|
+
## Enough HTTP/2 for gRPC
|
|
893
|
+
|
|
894
|
+
gRPC runs over HTTP/2 and relies on parts of it most apps never touch. Raptor supports them, so a `DetachedBody` can serve gRPC:
|
|
895
|
+
|
|
896
|
+
```ruby
|
|
897
|
+
body.try_write(encoded_message)
|
|
898
|
+
body.close(trailers: { "grpc-status" => "0" })
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
- <big>**Trailers**: headers sent after the body. gRPC puts its final status there, because it isn't known until the stream ends.</big>
|
|
902
|
+
- <big>**Cancellation**: when a client gives up on a stream, Raptor passes that on to your body straight away</big>
|
|
903
|
+
- <big>**Keepalive PINGs**: idle connections get pinged, so dead ones are noticed and closed</big>
|
|
904
|
+
- <big>**Graceful shutdown**: `GOAWAY` tells clients to stop opening new streams while the current ones finish</big>
|
|
905
|
+
|
|
906
|
+
<br>
|
|
907
|
+
<br>
|
|
908
|
+
<br>
|
|
909
|
+
<br>
|
|
910
|
+
<br>
|
|
911
|
+
<br>
|
|
912
|
+
<br>
|
|
913
|
+
<br>
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
<br>
|
|
918
|
+
<br>
|
|
919
|
+
<br>
|
|
920
|
+
<br>
|
|
921
|
+
<br>
|
|
922
|
+
<br>
|
|
923
|
+
<br>
|
|
924
|
+
<br>
|
|
925
|
+
|
|
926
|
+
## Zooming out: the processes
|
|
927
|
+
|
|
928
|
+
So far we've been inside one worker. Let's look at how the workers live together.
|
|
929
|
+
|
|
930
|
+
**Preforking**: the master loads your app once, then forks a copy for each worker.
|
|
931
|
+
|
|
932
|
+
```mermaid
|
|
933
|
+
flowchart TB
|
|
934
|
+
M["Master<br/>loads your app once"] -->|"fork"| W0["Worker 0"]
|
|
935
|
+
M -->|"fork"| W1["Worker 1"]
|
|
936
|
+
M -->|"fork"| W2["Worker 2"]
|
|
937
|
+
|
|
938
|
+
W0 -.-> SP[("Shared memory pages<br/>(your loaded app)")]
|
|
939
|
+
W1 -.-> SP
|
|
940
|
+
W2 -.-> SP
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
<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>
|
|
944
|
+
|
|
945
|
+
<br>
|
|
946
|
+
<br>
|
|
947
|
+
<br>
|
|
948
|
+
<br>
|
|
949
|
+
<br>
|
|
950
|
+
<br>
|
|
951
|
+
<br>
|
|
952
|
+
<br>
|
|
953
|
+
|
|
954
|
+
---
|
|
955
|
+
|
|
956
|
+
<br>
|
|
957
|
+
<br>
|
|
958
|
+
<br>
|
|
959
|
+
<br>
|
|
960
|
+
<br>
|
|
961
|
+
<br>
|
|
962
|
+
<br>
|
|
963
|
+
<br>
|
|
964
|
+
|
|
965
|
+
## Refork
|
|
966
|
+
|
|
967
|
+
Copy-on-write savings fade as workers warm caches, compile YJIT code, and load gems lazily. Eventually each worker has its own copy of most pages.
|
|
968
|
+
|
|
969
|
+
The fix: periodically fork new workers from a **warm** worker instead of the master.
|
|
970
|
+
|
|
971
|
+
```mermaid
|
|
972
|
+
sequenceDiagram
|
|
973
|
+
participant M as Master
|
|
974
|
+
participant W as Warm worker
|
|
975
|
+
participant N as New workers
|
|
976
|
+
|
|
977
|
+
Note over M: Every refork_after requests
|
|
978
|
+
M->>W: You're the seed now
|
|
979
|
+
W->>W: Finish in-flight requests
|
|
980
|
+
M->>W: Fork replacement workers
|
|
981
|
+
W->>N: fork()
|
|
982
|
+
Note over N: Start with the warm caches and YJIT code,<br/>shared copy-on-write
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
<big>The idea comes from Shopify's [Pitchfork](https://github.com/Shopify/pitchfork). Puma's version is the experimental `fork_worker`, which I've fixed a few bugs in.</big>
|
|
986
|
+
|
|
987
|
+
<big>It relies on `PR_SET_CHILD_SUBREAPER`, so the master can adopt workers it didn't fork. That makes it Linux only.</big>
|
|
988
|
+
|
|
989
|
+
<br>
|
|
990
|
+
<br>
|
|
991
|
+
<br>
|
|
992
|
+
<br>
|
|
993
|
+
<br>
|
|
994
|
+
<br>
|
|
995
|
+
<br>
|
|
996
|
+
<br>
|
|
997
|
+
|
|
998
|
+
---
|
|
999
|
+
|
|
1000
|
+
<br>
|
|
1001
|
+
<br>
|
|
1002
|
+
<br>
|
|
1003
|
+
<br>
|
|
1004
|
+
<br>
|
|
1005
|
+
<br>
|
|
1006
|
+
<br>
|
|
1007
|
+
<br>
|
|
1008
|
+
|
|
1009
|
+
## Shared memory for stats
|
|
1010
|
+
|
|
1011
|
+
The master needs to know how each worker is doing. Puma's workers report through pipes, while Raptor's write straight into shared memory.
|
|
1012
|
+
|
|
1013
|
+
```mermaid
|
|
1014
|
+
flowchart LR
|
|
1015
|
+
W0["Worker 0"] -->|"writes"| S0
|
|
1016
|
+
W1["Worker 1"] -->|"writes"| S1
|
|
1017
|
+
W2["Worker 2"] -->|"writes"| S2
|
|
1018
|
+
|
|
1019
|
+
subgraph SHM["Shared memory, created before forking"]
|
|
1020
|
+
S0["slot 0"]
|
|
1021
|
+
S1["slot 1"]
|
|
1022
|
+
S2["slot 2"]
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
S0 -.->|"reads"| M["Master"]
|
|
1026
|
+
S1 -.-> M
|
|
1027
|
+
S2 -.-> M
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
<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>
|
|
1031
|
+
|
|
1032
|
+
<big>Updating stats is just a write to memory, with no pipe, no parsing, and no syscall.</big>
|
|
1033
|
+
|
|
1034
|
+
That's where **[`mmap-ruby`](https://github.com/joshuay03/mmap-ruby)** came from.
|
|
1035
|
+
|
|
1036
|
+
<br>
|
|
1037
|
+
<br>
|
|
1038
|
+
<br>
|
|
1039
|
+
<br>
|
|
1040
|
+
<br>
|
|
1041
|
+
<br>
|
|
1042
|
+
<br>
|
|
1043
|
+
<br>
|
|
1044
|
+
|
|
1045
|
+
---
|
|
1046
|
+
|
|
1047
|
+
<br>
|
|
1048
|
+
<br>
|
|
1049
|
+
<br>
|
|
1050
|
+
<br>
|
|
1051
|
+
<br>
|
|
1052
|
+
<br>
|
|
1053
|
+
<br>
|
|
1054
|
+
<br>
|
|
1055
|
+
|
|
1056
|
+
## Which worker gets the next connection?
|
|
1057
|
+
|
|
1058
|
+
All workers listen on the same port, and by default Linux spreads connections by hashing the client's address. That's even **on average**, but blind to which workers are busy.
|
|
1059
|
+
|
|
1060
|
+
Puma nudges the odds. Busier workers pause briefly before accepting, so quieter ones tend to win.
|
|
1061
|
+
|
|
1062
|
+
Raptor lets the kernel decide using real load, with **BPF**.
|
|
1063
|
+
|
|
1064
|
+
<big>BPF lets you run tiny, kernel-verified programs inside Linux. It's the same technology behind `bpftrace` and Cilium.</big>
|
|
1065
|
+
|
|
1066
|
+
<br>
|
|
1067
|
+
<br>
|
|
1068
|
+
<br>
|
|
1069
|
+
<br>
|
|
1070
|
+
<br>
|
|
1071
|
+
<br>
|
|
1072
|
+
<br>
|
|
1073
|
+
<br>
|
|
1074
|
+
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
<br>
|
|
1078
|
+
<br>
|
|
1079
|
+
<br>
|
|
1080
|
+
<br>
|
|
1081
|
+
<br>
|
|
1082
|
+
<br>
|
|
1083
|
+
<br>
|
|
1084
|
+
<br>
|
|
1085
|
+
|
|
1086
|
+
## Raptor's BPF program
|
|
1087
|
+
|
|
1088
|
+
```mermaid
|
|
1089
|
+
flowchart TB
|
|
1090
|
+
New(["new connection"]) --> Hash["hash picks two workers"]
|
|
1091
|
+
Hash --> A["Worker 0<br/>5 waiting requests"]
|
|
1092
|
+
Hash --> B["Worker 3<br/>6 waiting requests"]
|
|
1093
|
+
A --> Pick{"pick the less busy one"}
|
|
1094
|
+
B --> Pick
|
|
1095
|
+
Pick -->|"reserve: 5 → 6"| W0["Worker 0 accepts it"]
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
<big>Each worker publishes its load, including connections still waiting to be accepted, to a map the kernel can read. The program compares two workers and picks the quieter one ("power of two choices").</big>
|
|
1099
|
+
|
|
1100
|
+
<big>It bumps that worker's count straight away, so a burst doesn't all land on one worker.</big>
|
|
1101
|
+
|
|
1102
|
+
<big>The whole program is about 70 lines of C. If BPF isn't available, Raptor quietly falls back to the default behaviour.</big>
|
|
1103
|
+
|
|
1104
|
+
To load it from Ruby, I wrote **[`libbpf-ruby`](https://github.com/joshuay03/libbpf-ruby)**.
|
|
1105
|
+
|
|
1106
|
+
<br>
|
|
1107
|
+
<br>
|
|
1108
|
+
<br>
|
|
1109
|
+
<br>
|
|
1110
|
+
<br>
|
|
1111
|
+
<br>
|
|
1112
|
+
<br>
|
|
1113
|
+
<br>
|
|
1114
|
+
|
|
1115
|
+
---
|
|
1116
|
+
|
|
1117
|
+
<br>
|
|
1118
|
+
<br>
|
|
1119
|
+
<br>
|
|
1120
|
+
<br>
|
|
1121
|
+
<br>
|
|
1122
|
+
<br>
|
|
1123
|
+
<br>
|
|
1124
|
+
<br>
|
|
1125
|
+
|
|
1126
|
+
## Pinning workers to cores
|
|
1127
|
+
|
|
1128
|
+
Linux moves processes between cores whenever it likes, and each move lands a worker on cold caches.
|
|
1129
|
+
|
|
1130
|
+
<big>With `cpu_affinity` on, each worker pins itself to its own core and keeps its caches warm. Puma doesn't offer this.</big>
|
|
1131
|
+
|
|
1132
|
+
<big>It's opt-in, because a pinned worker's threads and Ractors share one core. I've seen it help, but I don't have numbers yet.</big>
|
|
1133
|
+
|
|
1134
|
+
<br>
|
|
1135
|
+
<br>
|
|
1136
|
+
<br>
|
|
1137
|
+
<br>
|
|
1138
|
+
<br>
|
|
1139
|
+
<br>
|
|
1140
|
+
<br>
|
|
1141
|
+
<br>
|
|
1142
|
+
|
|
1143
|
+
---
|
|
1144
|
+
|
|
1145
|
+
<br>
|
|
1146
|
+
<br>
|
|
1147
|
+
<br>
|
|
1148
|
+
<br>
|
|
1149
|
+
<br>
|
|
1150
|
+
<br>
|
|
1151
|
+
<br>
|
|
1152
|
+
<br>
|
|
1153
|
+
|
|
1154
|
+
## The numbers
|
|
1155
|
+
|
|
1156
|
+
Full results and the setup are in the [README](../README.md#micro-benchmarks). **I/O** requests mostly wait on fake database calls, and **CPU** requests mostly run Ruby.
|
|
1157
|
+
|
|
1158
|
+
- <big>**I/O-heavy HTTP/1.1 with 3 threads per worker**: roughly 2.3 times Puma's throughput</big>
|
|
1159
|
+
- Raptor polls for accept capacity under saturation. That trades CPU time for throughput and latency; the same thread count doesn't mean the same CPU use.
|
|
1160
|
+
- Existing keep-alive traffic can also delay new connections at saturation; throughput and p95 don't capture every client's wait.
|
|
1161
|
+
- <big>**I/O-heavy HTTP/1.1 with scaling threads**: approximately tied with Puma</big>
|
|
1162
|
+
- Puma adds a thread whenever work is waiting. Raptor only adds one when the busy threads are blocked, to avoid adding threads CPU-heavy apps can't use.
|
|
1163
|
+
- <big>**HTTP/2**: direct multiplexing, compared with Falcon</big>
|
|
1164
|
+
- Puma doesn't implement HTTP/2. Raptor and Falcon do; their numbers move around a lot between runs, so check the spread as well as the medians.
|
|
1165
|
+
- <big>**I/O with server-sent events and 3 threads per worker**: 9.5 times Puma's throughput, with Puma also reporting timeouts</big>
|
|
1166
|
+
- A detached body hands the stream to the reactor, so the app thread is free again. On Puma, every open stream holds a thread until it closes.
|
|
1167
|
+
|
|
1168
|
+
Raptor wins on throughput in three of the four CPU-heavy HTTP/1.1 comparisons; Puma leads slightly with fixed threads and keep-alive. Puma still has lower p95 on fresh connections, while Raptor has lower p95 with keep-alive. These are micro benchmarks though, so benchmark your own application before choosing a server.
|
|
1169
|
+
|
|
1170
|
+
<br>
|
|
1171
|
+
<br>
|
|
1172
|
+
<br>
|
|
1173
|
+
<br>
|
|
1174
|
+
<br>
|
|
1175
|
+
<br>
|
|
1176
|
+
<br>
|
|
1177
|
+
<br>
|
|
1178
|
+
|
|
1179
|
+
---
|
|
1180
|
+
|
|
1181
|
+
<br>
|
|
1182
|
+
<br>
|
|
1183
|
+
<br>
|
|
1184
|
+
<br>
|
|
1185
|
+
<br>
|
|
1186
|
+
<br>
|
|
1187
|
+
<br>
|
|
1188
|
+
<br>
|
|
1189
|
+
|
|
1190
|
+
## What you give up
|
|
1191
|
+
|
|
1192
|
+
- <big>**Battle-testing**: Puma has run production Rails since 2011. Raptor is less than a year old.</big>
|
|
1193
|
+
- <big>**Older Rubies**: Raptor needs Ruby 4.0.</big>
|
|
1194
|
+
- <big>**Ractor overhead**: on Ruby 4.0, once a process starts a Ractor, the rest of its Ruby runs slower too. On the upcoming Ruby 4.1, it's gone in my measurements.</big>
|
|
1195
|
+
- <big>**Easy debugging**: a request can cross threads and Ractors, so tracing it means stitching stack traces together.</big>
|
|
1196
|
+
|
|
1197
|
+
<br>
|
|
1198
|
+
<br>
|
|
1199
|
+
<br>
|
|
1200
|
+
<br>
|
|
1201
|
+
<br>
|
|
1202
|
+
<br>
|
|
1203
|
+
<br>
|
|
1204
|
+
<br>
|
|
1205
|
+
|
|
1206
|
+
---
|
|
1207
|
+
|
|
1208
|
+
<br>
|
|
1209
|
+
<br>
|
|
1210
|
+
<br>
|
|
1211
|
+
<br>
|
|
1212
|
+
<br>
|
|
1213
|
+
<br>
|
|
1214
|
+
<br>
|
|
1215
|
+
<br>
|
|
1216
|
+
|
|
1217
|
+
## Why it's worth being curious
|
|
1218
|
+
|
|
1219
|
+
I'm not asking you to swap your production server. What I hope you take away is how much more is possible from Ruby now:
|
|
1220
|
+
|
|
1221
|
+
- <big>Ractors are finally practical</big>
|
|
1222
|
+
- <big>We can build lock-free data structures in Ruby</big>
|
|
1223
|
+
- <big>We can share memory between processes with `mmap`</big>
|
|
1224
|
+
- <big>We can load BPF programs into the kernel from Ruby</big>
|
|
1225
|
+
|
|
1226
|
+
None of these ideas are new on their own. What's new is that we can combine them in one Ruby server, and see how far that gets us.
|
|
1227
|
+
|
|
1228
|
+
<br>
|
|
1229
|
+
<br>
|
|
1230
|
+
<br>
|
|
1231
|
+
<br>
|
|
1232
|
+
<br>
|
|
1233
|
+
<br>
|
|
1234
|
+
<br>
|
|
1235
|
+
<br>
|
|
1236
|
+
|
|
1237
|
+
---
|
|
1238
|
+
|
|
1239
|
+
<br>
|
|
1240
|
+
<br>
|
|
1241
|
+
<br>
|
|
1242
|
+
<br>
|
|
1243
|
+
<br>
|
|
1244
|
+
<br>
|
|
1245
|
+
<br>
|
|
1246
|
+
<br>
|
|
1247
|
+
|
|
1248
|
+
## Try it
|
|
1249
|
+
|
|
1250
|
+
```
|
|
1251
|
+
gem install raptor # Ruby 4.0+
|
|
1252
|
+
bundle exec raptor # boots config.ru
|
|
1253
|
+
bundle exec rails server -u raptor
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
- <big>The CLI mirrors Puma's on purpose</big>
|
|
1257
|
+
- <big>Feedback and bug reports very welcome</big>
|
|
1258
|
+
|
|
1259
|
+
The long version of this talk, with much more detail, is [`docs/raptor-vs-puma.md`](raptor-vs-puma.md).
|
|
1260
|
+
|
|
1261
|
+
<br>
|
|
1262
|
+
<br>
|
|
1263
|
+
<br>
|
|
1264
|
+
<br>
|
|
1265
|
+
<br>
|
|
1266
|
+
<br>
|
|
1267
|
+
<br>
|
|
1268
|
+
<br>
|
|
1269
|
+
|
|
1270
|
+
---
|
|
1271
|
+
|
|
1272
|
+
<br>
|
|
1273
|
+
<br>
|
|
1274
|
+
<br>
|
|
1275
|
+
<br>
|
|
1276
|
+
<br>
|
|
1277
|
+
<br>
|
|
1278
|
+
<br>
|
|
1279
|
+
<br>
|
|
1280
|
+
|
|
1281
|
+
## Thanks
|
|
1282
|
+
|
|
1283
|
+
**Raptor**
|
|
1284
|
+
|
|
1285
|
+
[github.com/joshuay03/raptor](https://github.com/joshuay03/raptor)
|
|
1286
|
+
|
|
1287
|
+
**The gems that came out of it**
|
|
1288
|
+
|
|
1289
|
+
- <big>[`ractor-pool`](https://github.com/joshuay03/ractor-pool): a pool of Ractor workers</big>
|
|
1290
|
+
- <big>[`atomic-ruby`](https://github.com/joshuay03/atomic-ruby): lock-free primitives and the thread pool</big>
|
|
1291
|
+
- <big>[`red-black-tree`](https://github.com/joshuay03/red-black-tree): a self-balancing tree</big>
|
|
1292
|
+
- <big>[`mmap-ruby`](https://github.com/joshuay03/mmap-ruby): shared memory</big>
|
|
1293
|
+
- <big>[`libbpf-ruby`](https://github.com/joshuay03/libbpf-ruby): BPF from Ruby</big>
|
|
1294
|
+
|
|
1295
|
+
_Questions?_
|