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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Dockerfile +3 -1
  4. data/README.md +38 -31
  5. data/docs/raptor-vs-puma.md +44 -38
  6. data/docs/talk.md +1295 -0
  7. data/ext/raptor_bpf/reuseport_select.bpf.c +18 -9
  8. data/ext/raptor_http/extconf.rb +2 -0
  9. data/ext/raptor_http/raptor_http.c +91 -119
  10. data/ext/raptor_http2/raptor_http2.c +272 -223
  11. data/ext/raptor_native/raptor_native.c +38 -20
  12. data/lib/rackup/handler/raptor.rb +12 -11
  13. data/lib/raptor/binder.rb +12 -12
  14. data/lib/raptor/cli.rb +18 -18
  15. data/lib/raptor/cluster.rb +44 -44
  16. data/lib/raptor/control_server.rb +3 -2
  17. data/lib/raptor/detached_body.rb +6 -2
  18. data/lib/raptor/http.rb +7 -5
  19. data/lib/raptor/http1.rb +233 -208
  20. data/lib/raptor/http2.rb +314 -267
  21. data/lib/raptor/reactor.rb +139 -149
  22. data/lib/raptor/reuseport_bpf.rb +62 -38
  23. data/lib/raptor/server.rb +53 -38
  24. data/lib/raptor/stats.rb +15 -3
  25. data/lib/raptor/thread_locals.rb +4 -3
  26. data/lib/raptor/version.rb +1 -1
  27. data/sig/generated/rackup/handler/raptor.rbs +2 -1
  28. data/sig/generated/raptor/binder.rbs +9 -9
  29. data/sig/generated/raptor/cli.rbs +6 -6
  30. data/sig/generated/raptor/cluster.rbs +9 -9
  31. data/sig/generated/raptor/control_server.rbs +2 -1
  32. data/sig/generated/raptor/detached_body.rbs +5 -1
  33. data/sig/generated/raptor/http.rbs +3 -3
  34. data/sig/generated/raptor/http1.rbs +100 -91
  35. data/sig/generated/raptor/http2.rbs +200 -189
  36. data/sig/generated/raptor/reactor.rbs +107 -118
  37. data/sig/generated/raptor/reuseport_bpf.rbs +54 -17
  38. data/sig/generated/raptor/server.rbs +26 -26
  39. data/sig/generated/raptor/stats.rbs +3 -2
  40. data/sig/generated/raptor/thread_locals.rbs +4 -3
  41. metadata +2 -2
  42. 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?_