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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/Dockerfile +10 -2
  4. data/README.md +183 -61
  5. data/docs/assets/profile_picture.png +0 -0
  6. data/docs/assets/startup_output.svg +37 -0
  7. data/docs/raptor-vs-puma.md +104 -424
  8. data/docs/talk.md +1388 -0
  9. data/ext/raptor_bpf/reuseport_select.bpf.c +18 -9
  10. data/ext/raptor_http/extconf.rb +2 -0
  11. data/ext/raptor_http/raptor_http.c +165 -234
  12. data/ext/raptor_http2/raptor_http2.c +272 -223
  13. data/ext/raptor_native/raptor_native.c +38 -20
  14. data/lib/rackup/handler/raptor.rb +14 -12
  15. data/lib/raptor/binder.rb +12 -12
  16. data/lib/raptor/cli.rb +19 -18
  17. data/lib/raptor/cluster.rb +120 -74
  18. data/lib/raptor/control_server.rb +5 -2
  19. data/lib/raptor/detached_body.rb +6 -2
  20. data/lib/raptor/http.rb +7 -6
  21. data/lib/raptor/http1.rb +281 -234
  22. data/lib/raptor/http2.rb +356 -276
  23. data/lib/raptor/plugin.rb +98 -0
  24. data/lib/raptor/reactor.rb +138 -148
  25. data/lib/raptor/reuseport_bpf.rb +62 -38
  26. data/lib/raptor/server.rb +45 -34
  27. data/lib/raptor/stats.rb +15 -3
  28. data/lib/raptor/thread_locals.rb +4 -3
  29. data/lib/raptor/version.rb +1 -1
  30. data/sig/generated/rackup/handler/raptor.rbs +2 -1
  31. data/sig/generated/raptor/binder.rbs +9 -9
  32. data/sig/generated/raptor/cli.rbs +6 -6
  33. data/sig/generated/raptor/cluster.rbs +18 -5
  34. data/sig/generated/raptor/control_server.rbs +2 -1
  35. data/sig/generated/raptor/detached_body.rbs +5 -1
  36. data/sig/generated/raptor/http.rbs +3 -5
  37. data/sig/generated/raptor/http1.rbs +119 -102
  38. data/sig/generated/raptor/http2.rbs +204 -190
  39. data/sig/generated/raptor/plugin.rbs +52 -0
  40. data/sig/generated/raptor/reactor.rbs +107 -118
  41. data/sig/generated/raptor/reuseport_bpf.rbs +54 -17
  42. data/sig/generated/raptor/server.rbs +26 -26
  43. data/sig/generated/raptor/stats.rbs +3 -2
  44. data/sig/generated/raptor/thread_locals.rbs +4 -3
  45. metadata +6 -2
  46. 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?_