raptor 0.17.0 → 0.19.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 +9 -0
- data/README.md +56 -40
- data/docs/brisrails-talk.md +119 -88
- data/docs/raptor-vs-puma.md +76 -64
- data/lib/rackup/handler/raptor.rb +10 -6
- data/lib/raptor/binder.rb +9 -0
- data/lib/raptor/cli.rb +31 -0
- data/lib/raptor/cluster.rb +29 -15
- data/lib/raptor/reactor.rb +1 -1
- data/lib/raptor/stats.rb +1 -1
- data/lib/raptor/version.rb +1 -1
- data/sig/generated/raptor/binder.rbs +7 -0
- data/sig/generated/raptor/cli.rbs +13 -2
- data/sig/generated/raptor/cluster.rbs +3 -0
- data/sig/generated/raptor/reactor.rbs +1 -1
- data/sig/generated/raptor/stats.rbs +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6620d52a503c84e57e9aea223f0980e2215aeb58da7154e077c31c0d1858bf12
|
|
4
|
+
data.tar.gz: 36c546ae9bb960f6049a82e123af665375785872533b4657f0c336abd8ab7a9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 729594070744ec522ec48852af6cce2c5aeef4df2050359473bdbf3f11aeaa167dbf7124e9a2fa21e622f77b6351d508fa3f4cf7438839e1b4e8a037784a7c89
|
|
7
|
+
data.tar.gz: 6812627b196892d9ffbc7608d34fc54c790d2d96ecdd4fa0823946389cc147a6e1640028cd057b958e91e9a2fc6307397e693bd5e6e7998b1c41b9fc9d13195b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.19.0] - 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Skip HTTP/2 Ractor pools without SSL bindings
|
|
6
|
+
- Default to adaptive application thread scaling
|
|
7
|
+
|
|
8
|
+
## [0.18.0] - 2026-08-29
|
|
9
|
+
|
|
10
|
+
- Add adaptive application thread scaling with `max_threads`
|
|
11
|
+
|
|
3
12
|
## [0.17.0] - 2026-08-29
|
|
4
13
|
|
|
5
14
|
- Reserve BPF-routed connections before accept
|
data/README.md
CHANGED
|
@@ -36,30 +36,29 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
|
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
> bundle exec raptor -w 10 -t 3 hello_world.ru
|
|
39
|
-
[Raptor
|
|
40
|
-
[Raptor
|
|
41
|
-
[Raptor
|
|
42
|
-
[Raptor
|
|
43
|
-
[Raptor
|
|
44
|
-
[Raptor
|
|
45
|
-
[Raptor
|
|
46
|
-
[Raptor
|
|
47
|
-
[Raptor
|
|
48
|
-
[Raptor
|
|
49
|
-
[Raptor
|
|
50
|
-
[Raptor
|
|
51
|
-
[Raptor
|
|
52
|
-
[Raptor
|
|
53
|
-
[Raptor
|
|
54
|
-
[Raptor
|
|
55
|
-
[Raptor
|
|
56
|
-
[Raptor
|
|
57
|
-
[Raptor
|
|
58
|
-
[Raptor
|
|
59
|
-
[Raptor
|
|
60
|
-
[Raptor
|
|
61
|
-
[Raptor
|
|
62
|
-
[Raptor 83665|Main|Main] Worker 9 booted
|
|
39
|
+
[Raptor 46475|Main|Main] Cluster initializing:
|
|
40
|
+
[Raptor 46475|Main|Main] ├─ Version: 0.19.0
|
|
41
|
+
[Raptor 46475|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
|
|
42
|
+
[Raptor 46475|Main|Main] ├─ Environment: development
|
|
43
|
+
[Raptor 46475|Main|Main] ├─ Master PID: 46475
|
|
44
|
+
[Raptor 46475|Main|Main] │ └─ 10 worker processes
|
|
45
|
+
[Raptor 46475|Main|Main] │ ├─ 1 server thread
|
|
46
|
+
[Raptor 46475|Main|Main] │ ├─ 1 reactor thread
|
|
47
|
+
[Raptor 46475|Main|Main] │ ├─ 1 HTTP/1.1 pipeline ractor
|
|
48
|
+
[Raptor 46475|Main|Main] │ ├─ 1 pipeline collector thread
|
|
49
|
+
[Raptor 46475|Main|Main] │ ├─ 3 worker threads (scaling, no limit)
|
|
50
|
+
[Raptor 46475|Main|Main] │ └─ 1 stats thread
|
|
51
|
+
[Raptor 46475|Main|Main] └─ Listening on 0.0.0.0:9292
|
|
52
|
+
[Raptor 46480|Main|Main] Worker 0 booted
|
|
53
|
+
[Raptor 46484|Main|Main] Worker 4 booted
|
|
54
|
+
[Raptor 46482|Main|Main] Worker 2 booted
|
|
55
|
+
[Raptor 46481|Main|Main] Worker 1 booted
|
|
56
|
+
[Raptor 46483|Main|Main] Worker 3 booted
|
|
57
|
+
[Raptor 46485|Main|Main] Worker 5 booted
|
|
58
|
+
[Raptor 46486|Main|Main] Worker 6 booted
|
|
59
|
+
[Raptor 46487|Main|Main] Worker 7 booted
|
|
60
|
+
[Raptor 46488|Main|Main] Worker 8 booted
|
|
61
|
+
[Raptor 46489|Main|Main] Worker 9 booted
|
|
63
62
|
```
|
|
64
63
|
|
|
65
64
|
```
|
|
@@ -93,6 +92,7 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
|
|
|
93
92
|
drain_accept_queue: false,
|
|
94
93
|
workers: 4, # `Etc.nprocessors`
|
|
95
94
|
threads: 3,
|
|
95
|
+
max_threads: Float::INFINITY, # set to `threads` for a fixed pool
|
|
96
96
|
chdir: nil,
|
|
97
97
|
environment: nil, # falls back to `RAILS_ENV`, then `RACK_ENV`, then `"development"`
|
|
98
98
|
connection: {
|
|
@@ -128,6 +128,11 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
+
`threads` sets the number of application threads each worker keeps running. By default, Raptor adds temporary threads
|
|
132
|
+
without a fixed limit when queued work is held up by blocking operations. It does not add threads when waiting for the
|
|
133
|
+
GVL is the bottleneck, and temporary threads leave after the queue drains. Set `max_threads` to cap growth, or set it
|
|
134
|
+
to the same value as `threads` for a fixed pool.
|
|
135
|
+
|
|
131
136
|
## Bindings
|
|
132
137
|
|
|
133
138
|
Raptor accepts multiple `binds:` URIs across three schemes.
|
|
@@ -188,8 +193,8 @@ KillMode=mixed
|
|
|
188
193
|
|
|
189
194
|
## Stats
|
|
190
195
|
|
|
191
|
-
Each worker writes per-worker stats (request count, busy threads, backlog, last check-in) to shared
|
|
192
|
-
JSON file (default `tmp/raptor.json`; set via `stats_file`).
|
|
196
|
+
Each worker writes per-worker stats (request count, busy and available threads, backlog, last check-in) to shared
|
|
197
|
+
memory and to a JSON file (default `tmp/raptor.json`; set via `stats_file`).
|
|
193
198
|
|
|
194
199
|
```
|
|
195
200
|
> bundle exec raptor stats
|
|
@@ -201,29 +206,40 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
|
|
|
201
206
|
|
|
202
207
|
## (Micro) Benchmarks
|
|
203
208
|
|
|
204
|
-
Raptor 0.
|
|
209
|
+
Raptor 0.19.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
|
|
205
210
|
interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
|
|
206
211
|
cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
|
|
207
212
|
building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
|
|
208
213
|
most of its work in Ruby with a few near-zero-cost cache hits.
|
|
209
214
|
|
|
210
|
-
|
|
215
|
+
Raptor is run in two modes: **Fixed** keeps 3 application threads per worker, matching Puma, while **Scaling** starts
|
|
216
|
+
with 3 and may add threads without a fixed limit when queued work is blocked outside the GVL. Both modes are compared
|
|
217
|
+
with both Puma and Falcon in the table below.
|
|
218
|
+
|
|
219
|
+
Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
|
|
211
220
|
may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
|
|
212
221
|
state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
|
|
213
|
-
((max - min) / 2 / median) between runs of a single cell was ±
|
|
214
|
-
|
|
215
|
-
| Protocol | Workload | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
|
|
216
|
-
| --------------------- | -------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
|
|
217
|
-
| HTTP/1.1 | IO | 3.
|
|
218
|
-
| HTTP/1.1 |
|
|
219
|
-
| HTTP/1.1
|
|
220
|
-
| HTTP/1.1
|
|
221
|
-
| HTTP/
|
|
222
|
-
| HTTP/
|
|
222
|
+
((max - min) / 2 / median) between runs of a single cell was ±26.6% for throughput and ±45.2% for p95.
|
|
223
|
+
|
|
224
|
+
| Protocol | Workload | Raptor mode | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
|
|
225
|
+
| --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
|
|
226
|
+
| HTTP/1.1 | IO | Fixed | 3.11k req/s | 60.50 ms | 1.51k req/s | 123.80 ms | 106.1% higher | 51.1% lower | 12.20k req/s | 14.20 ms | 74.5% lower | 326.1% higher |
|
|
227
|
+
| HTTP/1.1 | IO | Scaling | 8.63k req/s | 21.60 ms | 1.51k req/s | 123.80 ms | 471.4% higher | 82.6% lower | 12.20k req/s | 14.20 ms | 29.3% lower | 52.1% higher |
|
|
228
|
+
| HTTP/1.1 | CPU | Fixed | 8.22k req/s | 25.40 ms | 8.65k req/s | 20.90 ms | 5.0% lower | 21.5% higher | 6.52k req/s | 27.40 ms | 26.0% higher | 7.3% lower |
|
|
229
|
+
| HTTP/1.1 | CPU | Scaling | 8.22k req/s | 25.10 ms | 8.65k req/s | 20.90 ms | 4.9% lower | 20.1% higher | 6.52k req/s | 27.40 ms | 26.1% higher | 8.4% lower |
|
|
230
|
+
| HTTP/1.1 (keep-alive) | IO | Fixed | 3.21k req/s | 45.90 ms | 1.46k req/s | 105.50 ms | 119.5% higher | 56.5% lower | 6.23k req/s | 28.20 ms | 48.6% lower | 62.8% higher |
|
|
231
|
+
| HTTP/1.1 (keep-alive) | IO | Scaling | 9.43k req/s | 21.60 ms | 1.46k req/s | 105.50 ms | 545.8% higher | 79.5% lower | 6.23k req/s | 28.20 ms | 51.3% higher | 23.4% lower |
|
|
232
|
+
| HTTP/1.1 (keep-alive) | CPU | Fixed | 8.49k req/s | 21.80 ms | 8.54k req/s | 22.20 ms | 0.7% lower | 1.8% lower | 6.86k req/s | 32.70 ms | 23.8% higher | 33.3% lower |
|
|
233
|
+
| HTTP/1.1 (keep-alive) | CPU | Scaling | 8.49k req/s | 22.20 ms | 8.54k req/s | 22.20 ms | 0.6% lower | 0.0% higher | 6.86k req/s | 32.70 ms | 23.8% higher | 32.1% lower |
|
|
234
|
+
| HTTP/2 | IO | Fixed | 1.14k req/s | 150.03 ms | N/A | N/A | - | - | 6.40k req/s | 28.07 ms | 82.1% lower | 434.4% higher |
|
|
235
|
+
| HTTP/2 | IO | Scaling | 4.46k req/s | 39.65 ms | N/A | N/A | - | - | 6.40k req/s | 28.07 ms | 30.3% lower | 41.2% higher |
|
|
236
|
+
| HTTP/2 | CPU | Fixed | 5.35k req/s | 35.99 ms | N/A | N/A | - | - | 7.32k req/s | 41.05 ms | 26.9% lower | 12.3% lower |
|
|
237
|
+
| HTTP/2 | CPU | Scaling | 6.28k req/s | 32.32 ms | N/A | N/A | - | - | 7.32k req/s | 41.05 ms | 14.2% lower | 21.3% lower |
|
|
223
238
|
|
|
224
239
|
> ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [aarch64-linux]
|
|
225
|
-
> 10 worker processes; Raptor and Puma run 3 threads per worker
|
|
226
|
-
> 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client
|
|
240
|
+
> 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
|
|
241
|
+
> Falcon runs unbounded fibers per worker; 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client
|
|
242
|
+
> connections × 3 streams each
|
|
227
243
|
|
|
228
244
|
See [bin/benchmark](bin/benchmark) for more details.
|
|
229
245
|
|
data/docs/brisrails-talk.md
CHANGED
|
@@ -72,7 +72,7 @@ _[luma.com/t3l24s8h](https://luma.com/t3l24s8h)_
|
|
|
72
72
|
- Red-black trees
|
|
73
73
|
- Anonymous shared memory via mmap
|
|
74
74
|
- eBPF programs running inside the Linux kernel
|
|
75
|
-
- <big>
|
|
75
|
+
- <big>Ruby APIs, native extensions, and one tiny BPF program, all working together in one server.</big>
|
|
76
76
|
|
|
77
77
|
<br>
|
|
78
78
|
<br>
|
|
@@ -239,7 +239,7 @@ One thing to note: the whole `SYN` → `SYN-ACK` → `ACK` handshake at the top
|
|
|
239
239
|
|
|
240
240
|
- <big>Kernel does the handshake and puts the completed connection on the listener's accept queue</big>
|
|
241
241
|
- <big>Server calls `accept_nonblock` to pull the next connection off the queue</big>
|
|
242
|
-
- <big>
|
|
242
|
+
- <big>For Raptor's plain-TCP listener, the BPF program we'll see later can choose which worker socket receives each new connection</big>
|
|
243
243
|
|
|
244
244
|
<br>
|
|
245
245
|
<br>
|
|
@@ -273,8 +273,8 @@ One thing to note: the whole `SYN` → `SYN-ACK` → `ACK` handshake at the top
|
|
|
273
273
|
|
|
274
274
|
**[Falcon](https://github.com/socketry/falcon)**
|
|
275
275
|
|
|
276
|
-
- <big>
|
|
277
|
-
-
|
|
276
|
+
- <big>Lightweight async tasks backed by fibers instead of a fixed app thread pool</big>
|
|
277
|
+
- Fibers are cooperatively scheduled coroutines and much cheaper to create than OS threads
|
|
278
278
|
- <big>Built on the [`async`](https://github.com/socketry/async) gem, mostly written by Samuel Williams ([`@ioquatix`](https://github.com/ioquatix))</big>
|
|
279
279
|
- <big>Speaks HTTP/2 natively</big>
|
|
280
280
|
- <big>Excellent at long-lived connections and streaming</big>
|
|
@@ -535,9 +535,9 @@ If parsing runs in a Ractor:
|
|
|
535
535
|
|
|
536
536
|
- <big>It uses a **different GVL** than the app</big>
|
|
537
537
|
- <big>On a two-core machine, the parser and the app can run at the exact same instant</big>
|
|
538
|
-
- <big>Actual parallelism inside one
|
|
538
|
+
- <big>Actual protocol and app parallelism inside one worker process</big>
|
|
539
539
|
|
|
540
|
-
That's the whole idea.
|
|
540
|
+
That's the whole idea. Raptor uses that pipeline when a connection needs the reactor; complete requests on its eager paths parse inline instead of paying for a Ractor handoff.
|
|
541
541
|
|
|
542
542
|
<br>
|
|
543
543
|
<br>
|
|
@@ -591,6 +591,7 @@ flowchart TB
|
|
|
591
591
|
|
|
592
592
|
STA["Stats thread<br/>writes 1 Hz"]
|
|
593
593
|
|
|
594
|
+
SRV -->|"complete first read"| ATP
|
|
594
595
|
SRV --> RCT
|
|
595
596
|
RCT --> RP1
|
|
596
597
|
RCT --> RP2
|
|
@@ -759,7 +760,7 @@ flowchart LR
|
|
|
759
760
|
|
|
760
761
|
- <big>App threads share one GVL among themselves. Only one runs Ruby at a time.</big>
|
|
761
762
|
- <big>The pipeline Ractor has its own GVL. It runs Ruby independently.</big>
|
|
762
|
-
- <big>
|
|
763
|
+
- <big>A pipeline Ractor and an app thread can run Ruby code at the same time, on two CPU cores</big>
|
|
763
764
|
- <big>That is not something you get out of the box in Ruby</big>
|
|
764
765
|
|
|
765
766
|
<br>
|
|
@@ -786,7 +787,7 @@ flowchart LR
|
|
|
786
787
|
|
|
787
788
|
Where your Rack app runs, and where the response gets written back to the socket.
|
|
788
789
|
|
|
789
|
-
- <big>Puma's pool
|
|
790
|
+
- <big>Puma's pool coordinates a Ruby `Queue` with `Mutex + ConditionVariable`</big>
|
|
790
791
|
- <big>Every enqueue and every dequeue takes the mutex</big>
|
|
791
792
|
- <big>Under low concurrency, it's fine</big>
|
|
792
793
|
- <big>Under contention, the mutex becomes a serialisation point</big>
|
|
@@ -794,7 +795,7 @@ Where your Rack app runs, and where the response gets written back to the socket
|
|
|
794
795
|
|
|
795
796
|
Raptor's pool is **lock-free on the hot path**.
|
|
796
797
|
|
|
797
|
-
To explain that, one minute on one
|
|
798
|
+
To explain that, one minute on one atomic primitive.
|
|
798
799
|
|
|
799
800
|
<br>
|
|
800
801
|
<br>
|
|
@@ -831,13 +832,13 @@ flowchart LR
|
|
|
831
832
|
T2 -->|"CAS(42, 77)<br/>fails because current is now 99"| Memory
|
|
832
833
|
```
|
|
833
834
|
|
|
834
|
-
- <big>A
|
|
835
|
+
- <big>A hardware-supported atomic operation</big>
|
|
835
836
|
- <big>Reads a memory word</big>
|
|
836
837
|
- <big>Compares to what you expected</big>
|
|
837
838
|
- <big>If they match, replaces with a new value</big>
|
|
838
839
|
- <big>All atomically. No lock.</big>
|
|
839
840
|
|
|
840
|
-
On x86 it
|
|
841
|
+
On x86 it is commonly `LOCK CMPXCHG`. On ARM it is commonly an exclusive load/store pair such as `LDXR` / `STXR`.
|
|
841
842
|
|
|
842
843
|
<br>
|
|
843
844
|
<br>
|
|
@@ -867,15 +868,15 @@ On x86 it's `LOCK CMPXCHG`. On ARM it's `LDXR` / `STXR`.
|
|
|
867
868
|
cas(@slot, expected, new_value)
|
|
868
869
|
```
|
|
869
870
|
|
|
870
|
-
- <big>If
|
|
871
|
+
- <big>If a state transition can be published with CAS, threads do not need a lock for that transition</big>
|
|
871
872
|
- <big>If the CAS fails, you retry (someone else got there first)</big>
|
|
872
|
-
- <big>
|
|
873
|
+
- <big>Many high-performance concurrent data structures are built on this</big>
|
|
873
874
|
- Java's `ConcurrentHashMap`
|
|
874
875
|
- Rust's `AtomicUsize`
|
|
875
876
|
- Go's `sync/atomic`
|
|
876
877
|
- <big>[`concurrent-ruby`](https://github.com/ruby-concurrency/concurrent-ruby) has had CAS primitives (`AtomicReference`, `AtomicBoolean`, `AtomicFixnum`) since 2013</big>
|
|
877
|
-
-
|
|
878
|
-
-
|
|
878
|
+
- I could have built around those primitives, but Raptor also needed a lock-free FIFO and lock-free waiter parking
|
|
879
|
+
- I built that smaller, native set of pieces as `atomic-ruby`, with its C extension explicitly marked Ractor-safe
|
|
879
880
|
|
|
880
881
|
<br>
|
|
881
882
|
<br>
|
|
@@ -950,10 +951,10 @@ Where it pays off:
|
|
|
950
951
|
|
|
951
952
|
Why we picked CAS for Raptor's thread pool:
|
|
952
953
|
|
|
953
|
-
- <big>
|
|
954
|
+
- <big>The queue can publish head, tail, and link changes through small atomic steps</big>
|
|
954
955
|
- <big>The server thread reads pool metrics on every accept iteration, thousands of times per second</big>
|
|
955
|
-
- <big>Under a mutex,
|
|
956
|
-
- <big>
|
|
956
|
+
- <big>Under a queue-wide mutex, readers and writers serialise around the same lock</big>
|
|
957
|
+
- <big>Atomic counters avoid that lock. They still cost a synchronised memory access, but they do not park another thread.</big>
|
|
957
958
|
|
|
958
959
|
<br>
|
|
959
960
|
<br>
|
|
@@ -980,11 +981,12 @@ Why we picked CAS for Raptor's thread pool:
|
|
|
980
981
|
Another gem I wrote. Native C extension. Exposes CAS-based primitives to Ruby:
|
|
981
982
|
|
|
982
983
|
- <big>`Atom` – a CAS-protected reference cell holding one Ruby value</big>
|
|
983
|
-
- <big>`AtomicBoolean
|
|
984
|
+
- <big>`AtomicBoolean` – a type-specialised boolean</big>
|
|
985
|
+
- <big>`AtomicQueue` – a multi-producer, multi-consumer FIFO</big>
|
|
984
986
|
- <big>`AtomicThreadPool` – the thread pool Raptor uses</big>
|
|
985
987
|
- <big>`AtomicConditionVariable` – lock-free waiter parking</big>
|
|
986
988
|
|
|
987
|
-
Ruby's `VALUE` type on 64-bit is a tagged pointer.
|
|
989
|
+
Ruby's `VALUE` type on 64-bit is a tagged pointer. It fits in one machine word, so the native extension can compare and replace the reference atomically.
|
|
988
990
|
|
|
989
991
|
```c
|
|
990
992
|
old = ATOMIC_VALUE_CAS(&atom->value, expected, new_value);
|
|
@@ -1010,27 +1012,19 @@ old = ATOMIC_VALUE_CAS(&atom->value, expected, new_value);
|
|
|
1010
1012
|
<br>
|
|
1011
1013
|
<br>
|
|
1012
1014
|
|
|
1013
|
-
## The
|
|
1015
|
+
## The Michael-Scott queue
|
|
1014
1016
|
|
|
1015
|
-
Raptor's thread pool
|
|
1016
|
-
|
|
1017
|
-
```ruby
|
|
1018
|
-
{ in: [...], out: [...], count:, shutdown: }
|
|
1019
|
-
```
|
|
1017
|
+
Raptor's thread pool uses a lock-free linked FIFO from `atomic-ruby`:
|
|
1020
1018
|
|
|
1021
1019
|
```mermaid
|
|
1022
1020
|
flowchart LR
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
subgraph Q3["When out is empty, flip"]
|
|
1031
|
-
In3["in: []"]
|
|
1032
|
-
Out3["out: [A, B, C, D]"]
|
|
1033
|
-
end
|
|
1021
|
+
H["atomic head"] --> S["dummy sentinel"]
|
|
1022
|
+
S --> A["work A"]
|
|
1023
|
+
A --> B["work B"]
|
|
1024
|
+
B --> N["nil"]
|
|
1025
|
+
T["atomic tail"] --> B
|
|
1026
|
+
P["producer: work C"] -->|"CAS B.next from nil to C<br/>then advance tail"| C["work C"]
|
|
1027
|
+
Q["consumer"] -->|"read A<br/>CAS head from S to A"| H
|
|
1034
1028
|
```
|
|
1035
1029
|
|
|
1036
1030
|
<br>
|
|
@@ -1055,19 +1049,54 @@ flowchart LR
|
|
|
1055
1049
|
|
|
1056
1050
|
## Why this pattern works
|
|
1057
1051
|
|
|
1058
|
-
- <big>
|
|
1059
|
-
- <big>
|
|
1060
|
-
- <big>
|
|
1061
|
-
- <big>
|
|
1052
|
+
- <big>A dummy node separates the queue's head position from its first value</big>
|
|
1053
|
+
- <big>Producers append at the tail; consumers advance the head</big>
|
|
1054
|
+
- <big>Multiple producers and consumers can make progress without one queue-wide lock</big>
|
|
1055
|
+
- <big>Push and pop are O(1), with CAS retries when another thread wins a race</big>
|
|
1062
1056
|
- <big>Lock-free</big>
|
|
1063
|
-
- <big>It's
|
|
1057
|
+
- <big>It's the Michael-Scott queue, a classic concurrent FIFO design</big>
|
|
1064
1058
|
|
|
1065
1059
|
The bit that matters most in practice:
|
|
1066
1060
|
|
|
1067
|
-
- <big>
|
|
1061
|
+
- <big>Queue length and active count are tracked in separate atoms</big>
|
|
1068
1062
|
- <big>The server thread reads them every iteration of the accept loop</big>
|
|
1069
|
-
- <big>
|
|
1070
|
-
- <big>
|
|
1063
|
+
- <big>Those reads do not acquire the queue's mutation lock, because there isn't one</big>
|
|
1064
|
+
- <big>The values are point-in-time snapshots; they can change immediately after being read</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
|
+
## Scaling app threads without making CPU contention worse
|
|
1087
|
+
|
|
1088
|
+
The pool starts at `threads` and scales automatically when more threads would help.
|
|
1089
|
+
|
|
1090
|
+
- <big>A native CRuby thread hook measures time running, blocked outside the GVL, and waiting for the GVL</big>
|
|
1091
|
+
- <big>The queue has to stay non-empty, and every current worker has to be active</big>
|
|
1092
|
+
- <big>Blocked time has to exceed half of worker time</big>
|
|
1093
|
+
- <big>GVL wait has to stay below two percent</big>
|
|
1094
|
+
- <big>Only then does the pool add a temporary thread</big>
|
|
1095
|
+
- <big>When the queue drains, temporary threads leave and the pool returns to `threads`</big>
|
|
1096
|
+
|
|
1097
|
+
The distinction matters. More threads help when requests are asleep in database or network calls. They make CPU-bound Ruby slower when the GVL is already the bottleneck.
|
|
1098
|
+
|
|
1099
|
+
Growth has no fixed limit by default. Set `max_threads` to cap it, or set it to `threads` to keep the pool fixed. OS threads still are not as cheap as fibers.
|
|
1071
1100
|
|
|
1072
1101
|
<br>
|
|
1073
1102
|
<br>
|
|
@@ -1281,7 +1310,7 @@ flowchart TB
|
|
|
1281
1310
|
Col["Collector threads<br/>drain Ractor::Ports"]
|
|
1282
1311
|
ATP["App thread pool<br/>calls Rack app<br/>writes response"]
|
|
1283
1312
|
|
|
1284
|
-
Client -->|"
|
|
1313
|
+
Client -->|"connected socket + request bytes"| Srv
|
|
1285
1314
|
Srv -->|"fast path: complete on first read"| ATP
|
|
1286
1315
|
Srv -->|"slow path: bytes not ready"| Rct
|
|
1287
1316
|
Rct -->|"got bytes; hand raw buffer to pool"| RP
|
|
@@ -1369,8 +1398,8 @@ end
|
|
|
1369
1398
|
|
|
1370
1399
|
- <big>Wait 1ms for the next request on the same connection</big>
|
|
1371
1400
|
- <big>If bytes arrive in that window: parse and dispatch inline, on the same thread</big>
|
|
1372
|
-
- <big>
|
|
1373
|
-
- <big>
|
|
1401
|
+
- <big>Return to the reactor when no bytes arrive inside the 1ms window, or when a request is incomplete</big>
|
|
1402
|
+
- <big>The trade: occupy an app thread for up to 1ms to widen the no-reactor fast path</big>
|
|
1374
1403
|
|
|
1375
1404
|
<br>
|
|
1376
1405
|
<br>
|
|
@@ -1493,7 +1522,7 @@ Notable: a single HTTP/2 client connection in Raptor can have many streams in fl
|
|
|
1493
1522
|
- <big>Each stream is a separate work item in the queue</big>
|
|
1494
1523
|
- <big>Different streams from the same connection can end up on different app threads</big>
|
|
1495
1524
|
- <big>Those threads still share the main GVL, so they overlap productively when the app is in I/O (the common Rails case), the same way Puma's keep-alive requests do</big>
|
|
1496
|
-
- <big>
|
|
1525
|
+
- <big>On the reactor path, frame batches parse in the HTTP/2 Ractor pool on its own GVL, in parallel with app work</big>
|
|
1497
1526
|
|
|
1498
1527
|
<br>
|
|
1499
1528
|
<br>
|
|
@@ -1560,7 +1589,7 @@ The master needs to know what the workers are doing.
|
|
|
1560
1589
|
- <big>How busy?</big>
|
|
1561
1590
|
- <big>Have they crashed?</big>
|
|
1562
1591
|
|
|
1563
|
-
**Puma**: pipes. Workers write
|
|
1592
|
+
**Puma**: pipes. Workers write status messages every check interval. Master reads.
|
|
1564
1593
|
|
|
1565
1594
|
**Raptor**: anonymous shared memory via `mmap`.
|
|
1566
1595
|
|
|
@@ -1675,7 +1704,7 @@ flowchart TB
|
|
|
1675
1704
|
|
|
1676
1705
|
- <big>Master calls `mmap` for the region **before** forking</big>
|
|
1677
1706
|
- <big>Every worker inherits the mapping</big>
|
|
1678
|
-
- <big>Each worker writes a 49-byte slot every second: pid, phase, requests, backlog, busy threads, boot time, checkin time, booted flag</big>
|
|
1707
|
+
- <big>Each worker writes a 49-byte slot every second: pid, phase, requests, backlog, busy and available threads, boot time, checkin time, booted flag</big>
|
|
1679
1708
|
- <big>Master reads the whole region directly. No JSON. No pipe drain. No signal.</big>
|
|
1680
1709
|
- <big>`bundle exec raptor stats` prints the region as JSON, essentially instantly</big>
|
|
1681
1710
|
|
|
@@ -1760,9 +1789,10 @@ The problem:
|
|
|
1760
1789
|
- <big>Default is a deterministic hash of the connection's four-tuple (client IP, client port, server IP, server port)</big>
|
|
1761
1790
|
- Same four-tuple always lands on the same worker. Not random. Not round-robin.
|
|
1762
1791
|
- Good spread on average across many clients, but no awareness of which worker is actually busy
|
|
1763
|
-
- One client hammering from the same source port lands on the same worker every SYN
|
|
1764
1792
|
|
|
1765
|
-
The Linux answer since kernel 4.5: **attach a BPF program to the reuseport group** via the `SO_ATTACH_REUSEPORT_EBPF` socket option.
|
|
1793
|
+
The Linux answer since kernel 4.5: **attach a BPF program to the reuseport group** via the `SO_ATTACH_REUSEPORT_EBPF` socket option. When the kernel selects a socket from that group for a new connection, your program can make the choice.
|
|
1794
|
+
|
|
1795
|
+
Raptor uses this for plain `tcp://` listeners. TLS and Unix listeners continue to use sockets inherited from the master.
|
|
1766
1796
|
|
|
1767
1797
|
<br>
|
|
1768
1798
|
<br>
|
|
@@ -1791,9 +1821,9 @@ Puma has [`ClusterAcceptLoopDelay`](https://github.com/puma/puma/blob/master/lib
|
|
|
1791
1821
|
- <big>Before each accept, a worker sleeps for a fraction of `max_delay` (default 5ms)</big>
|
|
1792
1822
|
- <big>The sleep is proportional to the worker's own load: 0 when idle, `max_delay` when very busy</big>
|
|
1793
1823
|
- <big>Every worker still accepts. Nobody refuses. Nobody gets skipped.</big>
|
|
1794
|
-
- <big>Busier workers wake later. Less-busy workers wake first and win the accept race on the
|
|
1824
|
+
- <big>Busier workers wake later. Less-busy workers wake first and win the accept race on the listener inherited from Puma's master.</big>
|
|
1795
1825
|
|
|
1796
|
-
It's clever,
|
|
1826
|
+
It's clever, portable, and deliberately imprecise. Each worker only needs its own load. Raptor's BPF path instead publishes cluster-wide load to a kernel map and chooses before a worker calls `accept`.
|
|
1797
1827
|
|
|
1798
1828
|
<br>
|
|
1799
1829
|
<br>
|
|
@@ -1826,7 +1856,7 @@ It's clever, but the signal is a proxy for load, not the load itself. And it doe
|
|
|
1826
1856
|
- No out-of-bounds memory access
|
|
1827
1857
|
- Provably terminates
|
|
1828
1858
|
- <big>If any of that can't be proved, the program is rejected at load time</big>
|
|
1829
|
-
- <big>Once loaded, runs
|
|
1859
|
+
- <big>Once loaded, runs inside the kernel without a userspace round-trip for each decision</big>
|
|
1830
1860
|
|
|
1831
1861
|
Modern Linux observability (`bcc`, `bpftrace`, `cilium`, `perf`) is all built on this. It's genuinely one of the most exciting things that has happened to Linux in the last decade.
|
|
1832
1862
|
|
|
@@ -1854,25 +1884,26 @@ Modern Linux observability (`bcc`, `bpftrace`, `cilium`, `perf`) is all built on
|
|
|
1854
1884
|
|
|
1855
1885
|
```mermaid
|
|
1856
1886
|
flowchart TB
|
|
1857
|
-
Client(["
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1887
|
+
Client(["new connection"])
|
|
1888
|
+
Hash["connection hash"]
|
|
1889
|
+
A["candidate A<br/>Worker 0, load 5"]
|
|
1890
|
+
B["candidate B<br/>Worker 3, load 6"]
|
|
1891
|
+
Pick["choose Worker 0<br/>reserve load 5 → 6"]
|
|
1892
|
+
Queue["Worker 0 accept queue"]
|
|
1893
|
+
|
|
1894
|
+
Client --> Hash
|
|
1895
|
+
Hash -->|"sample 1"| A
|
|
1896
|
+
Hash -->|"sample 2"| B
|
|
1897
|
+
A --> Pick
|
|
1898
|
+
B --> Pick
|
|
1899
|
+
Pick -->|"atomic increment, then route"| Queue
|
|
1869
1900
|
```
|
|
1870
1901
|
|
|
1871
1902
|
- <big>Each worker has a **load reporter thread**</big>
|
|
1872
1903
|
- <big>Publishes its current backlog into a BPF map every millisecond</big>
|
|
1873
|
-
- <big>
|
|
1874
|
-
- <big>
|
|
1875
|
-
- <big>
|
|
1904
|
+
- <big>The connection hash picks two distinct workers</big>
|
|
1905
|
+
- <big>The BPF program compares those two load slots and chooses the lower one</big>
|
|
1906
|
+
- <big>That is **power of two choices**: near-global balance without scanning every worker</big>
|
|
1876
1907
|
|
|
1877
1908
|
<br>
|
|
1878
1909
|
<br>
|
|
@@ -1894,18 +1925,17 @@ flowchart TB
|
|
|
1894
1925
|
<br>
|
|
1895
1926
|
<br>
|
|
1896
1927
|
|
|
1897
|
-
## Why the
|
|
1928
|
+
## Why the reservation matters
|
|
1898
1929
|
|
|
1899
|
-
|
|
1900
|
-
- <big>Every new SYN gets routed to it</big>
|
|
1901
|
-
- <big>By the time the next load-map update happens (1ms later), that worker is now the most loaded</big>
|
|
1902
|
-
- <big>You end up herding bursts onto whichever worker most recently reported the lowest load</big>
|
|
1930
|
+
The reporter only publishes once per millisecond. A burst can contain many connections.
|
|
1903
1931
|
|
|
1904
|
-
|
|
1932
|
+
- <big>Without a reservation, every connection in that burst can observe the same stale low value</big>
|
|
1933
|
+
- <big>They can all choose the same worker before Ruby reports the new backlog</big>
|
|
1934
|
+
- <big>Raptor atomically increments the chosen BPF slot **before** routing</big>
|
|
1935
|
+
- <big>The next connection sees that reservation immediately</big>
|
|
1936
|
+
- <big>When Ruby accepts a socket, it also publishes `backlog + 1` rather than waiting for the reporter</big>
|
|
1905
1937
|
|
|
1906
|
-
-
|
|
1907
|
-
- <big>Load spreads across all near-idle workers via the four-tuple hash</big>
|
|
1908
|
-
- <big>Under bursts, the effect is measurable in the p95 latency numbers</big>
|
|
1938
|
+
The load number is partly measurement and partly admission ledger. That closes the stale-reporting window that caused herding in the earlier design.
|
|
1909
1939
|
|
|
1910
1940
|
<br>
|
|
1911
1941
|
<br>
|
|
@@ -1929,9 +1959,9 @@ With the tie band:
|
|
|
1929
1959
|
|
|
1930
1960
|
## BPF in Raptor, by the numbers
|
|
1931
1961
|
|
|
1932
|
-
- <big>The whole BPF program is
|
|
1962
|
+
- <big>The whole BPF program is under 70 lines of C</big>
|
|
1933
1963
|
- <big>Compiles down to a few hundred bytes of BPF bytecode</big>
|
|
1934
|
-
- <big>Runs in the kernel
|
|
1964
|
+
- <big>Runs in the kernel whenever the reuseport group selects a socket for a new connection</big>
|
|
1935
1965
|
- <big>To load it from Ruby, I wrote **`libbpf-ruby`**</big>
|
|
1936
1966
|
- `libbpf` is the standard C library that user-space programs use to load, verify and attach BPF programs
|
|
1937
1967
|
- `libbpf-ruby` is my binding around it
|
|
@@ -1939,7 +1969,7 @@ With the tie band:
|
|
|
1939
1969
|
|
|
1940
1970
|
Graceful fallback:
|
|
1941
1971
|
|
|
1942
|
-
- <big>If `libbpf-ruby` isn't installed, or the BPF object hasn't been compiled, Raptor silently falls back to the
|
|
1972
|
+
- <big>If `libbpf-ruby` isn't installed, or the BPF object hasn't been compiled, Raptor silently falls back to the listener inherited from the master</big>
|
|
1943
1973
|
- <big>Everything still works. You just don't get load-aware routing.</big>
|
|
1944
1974
|
- <big>If the kernel refuses the program (verifier error, missing features), startup raises. Loud failure, not silent misbehaviour.</big>
|
|
1945
1975
|
|
|
@@ -2180,9 +2210,9 @@ The libraries that came out of building Raptor:
|
|
|
2180
2210
|
|
|
2181
2211
|
Each of them is small, focused, tested, and useful outside of Raptor.
|
|
2182
2212
|
|
|
2183
|
-
- <big>If you want lock-free primitives in Ruby, `atomic-ruby`
|
|
2213
|
+
- <big>If you want lock-free primitives in Ruby, `atomic-ruby` gives you a small, direct API</big>
|
|
2184
2214
|
- <big>If you want to try Ractor-based parallelism without writing the coordination yourself, `ractor-pool` is that</big>
|
|
2185
|
-
- <big>If you want to load a BPF program from Ruby, `libbpf-ruby`
|
|
2215
|
+
- <big>If you want to load a BPF program from Ruby, `libbpf-ruby` gives you a direct binding to libbpf</big>
|
|
2186
2216
|
|
|
2187
2217
|
I didn't set out to build a small library ecosystem. It's what happens when you refuse to fold every helper back into the main gem.
|
|
2188
2218
|
|
|
@@ -2210,10 +2240,11 @@ I didn't set out to build a small library ecosystem. It's what happens when you
|
|
|
2210
2240
|
|
|
2211
2241
|
Real numbers are in the [README benchmarks section](../README.md#micro-benchmarks). The shape:
|
|
2212
2242
|
|
|
2213
|
-
- <big>**IO-bound
|
|
2214
|
-
- <big>**CPU-bound HTTP/1.1**: Raptor
|
|
2243
|
+
- <big>**IO-bound HTTP/1.1**: Scaling lifts Raptor from 3.11k to 8.63k req/s without keep-alive and from 3.21k to 9.43k with it. Falcon wins the first; Raptor wins the second.</big>
|
|
2244
|
+
- <big>**CPU-bound HTTP/1.1**: Fixed and scaling Raptor are effectively identical. Puma leads by 5% without keep-alive and less than 1% with it; Raptor leads Falcon.</big>
|
|
2215
2245
|
- Tail latency ("p95") is the response time that 5% of requests exceed. It's what your slowest users see. Lower is better.
|
|
2216
|
-
- <big>**HTTP/2**: Raptor and Falcon
|
|
2246
|
+
- <big>**HTTP/2**: Scaling lifts Raptor from 1.14k to 4.46k req/s on IO and narrows Falcon's CPU-throughput lead from 27% to 14%. Falcon still leads throughput; Raptor has the lower CPU p95.</big>
|
|
2247
|
+
- <big>**Variance**: HTTP/1.1 is stable. HTTP/2 is noisy enough that I treat it as direction, not a precise ranking.</big>
|
|
2217
2248
|
|
|
2218
2249
|
Different workloads, different winners. That's fine.
|
|
2219
2250
|
|