kino 0.4.0-aarch64-linux → 0.6.0-aarch64-linux
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 +56 -0
- data/README.md +113 -41
- data/doc/architecture.md +61 -7
- data/doc/benchmarks.md +120 -26
- data/lib/kino/configuration.rb +16 -0
- data/lib/kino/kino.so +0 -0
- data/lib/kino/server.rb +10 -0
- data/lib/kino/templates/kino.rb.tt +13 -2
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/sig/kino.rbs +3 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47797b5264a1cdd29fcccc4e370cbf8e92fc71b46daaf3831e7bca69c51affea
|
|
4
|
+
data.tar.gz: 98b264a8a72224f37136e4ec14154e35c1b378844bbe73f35c6110dee59ffde9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9035020f4e0f2a47666391c05c8a7992176258f186ccc0613fb97de3609bdd98866ab015bd1263246245dc9be0622dfbd93880516bca2d818f5d5bf2ea65739c
|
|
7
|
+
data.tar.gz: fd323d0e09a40124a91b19f1142f335c41f2e97fc8d33369fc437e5a1cb50fcf91bd82aeb74f85d3c6f577533e1345f1cf323b2004784a6ab21df9d7b5839648
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
## [0.6.0] - 2026-09-01
|
|
2
|
+
|
|
3
|
+
- HTTP/2 support. Kino now speaks HTTP/2 natively, on by default:
|
|
4
|
+
browsers negotiate it over TLS (ALPN), h2-preferring balancers and
|
|
5
|
+
tools get prior-knowledge h2c on plaintext, and everyone else keeps
|
|
6
|
+
getting HTTP/1.1 on the same port. `http2 false` restores an
|
|
7
|
+
HTTP/1-only server. What ships with it:
|
|
8
|
+
- A spec-complete Rack env on h2: `SERVER_PROTOCOL` is `"HTTP/2"`,
|
|
9
|
+
`HTTP_HOST`/`SERVER_NAME`/`SERVER_PORT` come from the `:authority`
|
|
10
|
+
pseudo-header, split cookie headers are rejoined, and uploads
|
|
11
|
+
stream with the same backpressure as HTTP/1.
|
|
12
|
+
- Uploads at full speed: body reads now drain every arrived chunk in
|
|
13
|
+
one native call, so h2's 16 KB data frames don't pay a GVL
|
|
14
|
+
round-trip each—h2 uploads run at HTTP/1 parity, and chunked
|
|
15
|
+
HTTP/1 uploads got faster too.
|
|
16
|
+
- `MAX_CONCURRENT_STREAMS` advertised from worker-slot capacity, so
|
|
17
|
+
an h2-aware balancer sees the server's real admission and a single
|
|
18
|
+
connection cannot multiply into hundreds of queued requests.
|
|
19
|
+
- Graceful connection drains: on shutdown, every connection finishes
|
|
20
|
+
its in-flight request and then closes (`Connection: close` on
|
|
21
|
+
HTTP/1, GOAWAY on h2) instead of being cut mid-stream at the
|
|
22
|
+
deadline.
|
|
23
|
+
- Interned header values: low-cardinality headers (user-agent,
|
|
24
|
+
accept-*, sec-ch-*) reuse one frozen string instead of allocating
|
|
25
|
+
per request—the env-side analogue of HPACK's wire dedup, worth a
|
|
26
|
+
few percent on header-heavy traffic and 6-8 fewer allocations per
|
|
27
|
+
request, on HTTP/1 too. Cookies and authorization are deliberately
|
|
28
|
+
never cached.
|
|
29
|
+
|
|
30
|
+
Measured on the reference box: native h2 is +79% over HTTP/1.1
|
|
31
|
+
cleartext and 2× over TLS on the same server, +50% over
|
|
32
|
+
nginx-terminated h2 in front of Kino, ~3× falcon on fast handlers.
|
|
33
|
+
- Leaner request hot path: up to 2-4% more throughput on fast handlers
|
|
34
|
+
with `lanes` (measured on Linux), no change elsewhere.
|
|
35
|
+
- All benchmarks re-measured from scratch on a fresh reference box
|
|
36
|
+
(c7a.2xlarge, Ruby 4.0.6, Puma 8.0.2): the 2026-06 numbers
|
|
37
|
+
reproduced within 1-3% at matched configurations, memory to the
|
|
38
|
+
megabyte. The published tables carry the new run, with two additions
|
|
39
|
+
and one honest downgrade: new studies for sharded I/O (+1-3% on 8
|
|
40
|
+
cores, best at `io_threads 8`) and HTTP/2 (above), and a narrower
|
|
41
|
+
ractor /cpu lead over the cluster (+25%, was +34%) because Puma
|
|
42
|
+
8.0.2 itself got faster. The bench harness gained the sharded-I/O
|
|
43
|
+
lanes and the Rails memory comparison, and the methodology notes now
|
|
44
|
+
record two measurement traps: memory is only comparable after the
|
|
45
|
+
full endpoint battery, and /io only at equal slot counts.
|
|
46
|
+
|
|
47
|
+
## [0.5.0] - 2026-08-29
|
|
48
|
+
|
|
49
|
+
- Add opt-in `io_shards true`: accepted HTTP connections can run on
|
|
50
|
+
current-thread Tokio I/O shards instead of Tokio's shared multi-thread
|
|
51
|
+
runtime, reducing scheduler contention on very fast handlers. `io_threads`
|
|
52
|
+
sets the shard count; otherwise Kino uses half the available CPUs
|
|
53
|
+
([Patrik Wenger](https://github.com/paddor)).
|
|
54
|
+
- Update Rust dependencies: hyper 1.11.1, rb-sys 0.9.130,
|
|
55
|
+
rustls-webpki 0.103.15, among others.
|
|
56
|
+
|
|
1
57
|
## [0.4.0] - 2026-08-22
|
|
2
58
|
|
|
3
59
|
- Rack handler: `rails server -u kino` and `rackup -s kino` boot Kino
|
data/README.md
CHANGED
|
@@ -11,11 +11,14 @@ on every core in **one small process**. A **Rust** (tokio + hyper)
|
|
|
11
11
|
front-end owns the network, parallel **Ractors** run your Rack 3 app,
|
|
12
12
|
and a threaded fallback mode runs everything else, Rails included.
|
|
13
13
|
|
|
14
|
-
* **Fast.** On a real 8-core server, every Kino mode is **1.5-
|
|
15
|
-
ahead of a Puma fork cluster on I/O-light endpoints
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
* **Fast.** On a real 8-core server, every Kino mode is **1.5-1.7×**
|
|
15
|
+
ahead of a Puma fork cluster on I/O-light endpoints—with native
|
|
16
|
+
**HTTP/2** adding another **+79%** over HTTP/1.1 on the same server.
|
|
17
|
+
Ractor mode also wins on pure CPU, **+25%**.
|
|
18
|
+
[Benchmarks](#benchmarks) below.
|
|
19
|
+
* **A fraction of the memory.** About **~8×** on the simplistic bench
|
|
20
|
+
Ractor app, and about **4× less memory** than a Puma cluster serving
|
|
21
|
+
Rails in fallback threaded mode.
|
|
19
22
|
* **Parallel without forking.** Ractor mode runs CPU work **more than
|
|
20
23
|
5× faster** than Kino's own GVL-bound threaded mode, in the same
|
|
21
24
|
small process.
|
|
@@ -73,32 +76,32 @@ rather than an app. It is Ractor-shareable, so Kino runs it in `:ractor`
|
|
|
73
76
|
mode (and `:threaded` for comparison). **A real Rails app is a different
|
|
74
77
|
story:** it is *not* Ractor-shareable, so it runs only in Kino's
|
|
75
78
|
`:threaded` fallback, with its own numbers—see [Rails](#rails) below.
|
|
76
|
-
Ruby 4.0.
|
|
77
|
-
3 threads, Kino stays in one process
|
|
78
|
-
modes, 3 in threaded). Numbers are
|
|
79
|
-
connections, same host).
|
|
80
|
-
[doc/benchmarks.md](doc/benchmarks.md).
|
|
79
|
+
Ruby 4.0.6 with YJIT (re-measured 2026-09), every server at its
|
|
80
|
+
defaults: Puma forks 8 workers × 3 threads, Kino stays in one process
|
|
81
|
+
(8 workers; 1 thread each in ractor modes, 3 in threaded). Numbers are
|
|
82
|
+
req/s by wrk (8-second windows, 64 connections, same host).
|
|
83
|
+
Methodology: [doc/benchmarks.md](doc/benchmarks.md).
|
|
81
84
|
|
|
82
85
|
| endpoint | Kino :ractor | + lanes | :ractor, `workers 32`² | Kino :threaded | Puma (cluster) |
|
|
83
86
|
|-------------|-------------:|--------:|-----------------------:|---------------:|---------------:|
|
|
84
|
-
| /plaintext |
|
|
85
|
-
| /10k |
|
|
86
|
-
| /cpu (fib) | **
|
|
87
|
-
| /io (5 ms) | 1,
|
|
88
|
-
| /io_native | 1,570 | 1,
|
|
87
|
+
| /plaintext | 222,980 | **244,652** | 115,553 | 213,104 | 142,094 |
|
|
88
|
+
| /10k | 175,182 | **188,365** | 103,147 | 132,009 | 125,116 |
|
|
89
|
+
| /cpu (fib) | **76,695**¹| 71,830 | 54,062 | 13,463 | 61,329 |
|
|
90
|
+
| /io (5 ms) | 1,548 | 1,556 | **5,388** | 4,722 | 4,699 |
|
|
91
|
+
| /io_native | 1,570 | 1,573 | **6,207** | 4,695 | 4,693 |
|
|
89
92
|
|
|
90
93
|
Memory tells two different stories depending on the app, both by **PSS**
|
|
91
94
|
(proportional set size; see note) after sustained load.
|
|
92
95
|
|
|
93
96
|
**The tiny benchmark app** (Ractor-shareable, so Kino runs it in `:ractor`
|
|
94
|
-
or `:threaded`). Kino is **~
|
|
95
|
-
than the Puma cluster
|
|
97
|
+
or `:threaded`). Kino is **~8× lighter in :ractor mode, ~10× in :threaded**
|
|
98
|
+
than the Puma cluster—the gap stays large because a trivial app is almost
|
|
96
99
|
all private per-worker heap, which copy-on-write can't share:
|
|
97
100
|
|
|
98
101
|
| tiny app, Kino | Kino (one process) | Puma cluster (8 workers) | ratio |
|
|
99
102
|
|-----------------|-------------------:|-------------------------:|------:|
|
|
100
|
-
| :ractor (8×1) | **
|
|
101
|
-
| :threaded (8×3) | **107 MB**³| 1,
|
|
103
|
+
| :ractor (8×1) | **135 MB** | 1,072 MB | ~8× |
|
|
104
|
+
| :threaded (8×3) | **107 MB**³| 1,072 MB | ~10× |
|
|
102
105
|
|
|
103
106
|
**A real Rails app** (not Ractor-shareable—Kino's `:threaded` fallback
|
|
104
107
|
only, [below](#rails)). The gap is **~4×**, smaller because Rails' large
|
|
@@ -106,24 +109,25 @@ framework *is* shared copy-on-write across Puma's forks:
|
|
|
106
109
|
|
|
107
110
|
| Rails hello-world | Kino :threaded | Puma cluster (8 workers) | ratio |
|
|
108
111
|
|-------------------|---------------:|-------------------------:|------:|
|
|
109
|
-
| **PSS** | **
|
|
112
|
+
| **PSS** | **95 MB** | **405 MB** | ~4× |
|
|
110
113
|
|
|
111
114
|
"+ lanes" is the experimental per-worker-queue dispatcher (`lanes true`).
|
|
112
115
|
It posts the fastest plaintext/10k of any configuration here. Details:
|
|
113
116
|
[doc/benchmarks.md](doc/benchmarks.md#lane-dispatch-experimental-lanes-true).
|
|
114
117
|
|
|
115
118
|
¹ Stock settings, no tuning. Ractor mode beats the fork cluster on pure
|
|
116
|
-
CPU by +
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
`
|
|
119
|
+
CPU by +25% (+17% with lanes; Puma 8.0.2 narrowed this from the +34%
|
|
120
|
+
measured against 7.x). Threaded mode shows the GVL ceiling that every
|
|
121
|
+
single-process Ruby server hits. The old CPU-tuning recipe stays
|
|
122
|
+
retired: its `threads 1` half **is** the default, and its
|
|
123
|
+
`tokio_threads 1` half still costs on real hardware; see
|
|
120
124
|
[doc/benchmarks.md](doc/benchmarks.md#cpu-bound-tuning).
|
|
121
125
|
|
|
122
126
|
² Wait-bound throughput is slots ÷ wait, and the default columns bring
|
|
123
127
|
8 single-thread workers against the cluster's 24 threads. Kino slots
|
|
124
128
|
are threads, not processes—when your app waits a lot, raise `workers`.
|
|
125
|
-
The `workers 32` column is that tuning: **+
|
|
126
|
-
(+
|
|
129
|
+
The `workers 32` column is that tuning: **+15% over the cluster on /io
|
|
130
|
+
(+32% via `Kino.sleep`)** while still ahead of it on pure CPU, all in
|
|
127
131
|
one small process. The cost is the CPU-light rows (32 ractors
|
|
128
132
|
oversubscribe 8 cores); pick the topology your app's wait profile
|
|
129
133
|
needs. See
|
|
@@ -131,7 +135,7 @@ needs. See
|
|
|
131
135
|
|
|
132
136
|
³ With `MALLOC_ARENA_MAX=2` (the standard Ruby deployment setting;
|
|
133
137
|
Heroku's default). Without it, 24 threads churning 10 KB responses
|
|
134
|
-
through one glibc heap balloon to ~
|
|
138
|
+
through one glibc heap balloon to ~647 MB—an arena-fragmentation
|
|
135
139
|
footgun, not a leak, and ractor mode sidesteps it. See
|
|
136
140
|
[doc/benchmarks.md](doc/benchmarks.md#memory-under-load-and-the-glibc-arena-footgun).
|
|
137
141
|
|
|
@@ -141,36 +145,64 @@ doc):
|
|
|
141
145
|
|
|
142
146
|
| endpoint | Kino :ractor (8×3) | Puma + ractor wrapper | Falcon + ractor wrapper |
|
|
143
147
|
|------------|-------------------:|----------------------:|------------------------:|
|
|
144
|
-
| /plaintext | **
|
|
145
|
-
| /cpu (fib) | **68,
|
|
146
|
-
| /io (5 ms) | **4,
|
|
148
|
+
| /plaintext | **190,206** | 22,055 | 107,203 |
|
|
149
|
+
| /cpu (fib) | **68,230** | 16,528 | 50,101 |
|
|
150
|
+
| /io (5 ms) | **4,477** | 1,482 | 1,545 |
|
|
151
|
+
|
|
152
|
+
(The Kino column here runs 8×3—the same 24 slots the wrappers get—so
|
|
153
|
+
the /io row is comparable; the main table's ractor column runs the 8×1
|
|
154
|
+
default.)
|
|
155
|
+
|
|
156
|
+
### HTTP/2 head-to-head
|
|
157
|
+
|
|
158
|
+
Kino speaks HTTP/2 natively, so it skips the usual nginx-termination
|
|
159
|
+
hop. Same box as the tables above; all over TLS, h2load, 64 in-flight,
|
|
160
|
+
same app:
|
|
161
|
+
|
|
162
|
+
| /plaintext | req/s | /upload (64 KB) |
|
|
163
|
+
|-------------------------------|------------:|----------------:|
|
|
164
|
+
| Kino native h2 | **164,044** | **19,226** |
|
|
165
|
+
| Kino HTTP/1.1 (same boot) | 80,789 | 17,210 |
|
|
166
|
+
| nginx h2 → Kino HTTP/1.1 | 109,219 | 1,217¹ |
|
|
167
|
+
| Falcon (native h2) | 55,637 | 18,838 |
|
|
168
|
+
|
|
169
|
+
Native h2 **doubles** HTTP/1.1 throughput on the same TLS server
|
|
170
|
+
(+79% on cleartext h2c; fewer, larger socket operations, and HPACK
|
|
171
|
+
spares re-sending cookies) and is **+50% over fronting the same Kino
|
|
172
|
+
with nginx**—the proxy hop is pure cost. Uploads, h2's classic weak
|
|
173
|
+
spot, run at HTTP/1 parity. Full matrix, cleartext h2c lanes, and
|
|
174
|
+
methodology: [doc/benchmarks.md](doc/benchmarks.md#http2); reproduce
|
|
175
|
+
with `bench/h2.sh`.
|
|
176
|
+
|
|
177
|
+
¹ nginx default-config h2 request-body flow control; see the doc.
|
|
147
178
|
|
|
148
179
|
### Rails
|
|
149
180
|
|
|
150
181
|
Rails is not Ractor-shareable today, so Kino serves it in `:threaded`
|
|
151
|
-
fallback
|
|
182
|
+
fallback—one GVL-bound process. On the same box (`examples/rails-hello`,
|
|
152
183
|
edge Rails, production, 8×5):
|
|
153
184
|
|
|
154
185
|
| Rails hello-world | req/s | memory (PSS) |
|
|
155
186
|
|------------------------------|-------:|-------------:|
|
|
156
|
-
| Kino :threaded (one process) | 2,
|
|
157
|
-
| Puma cluster (8 workers) |
|
|
187
|
+
| Kino :threaded (one process) | 2,731 | **95 MB** |
|
|
188
|
+
| Puma cluster (8 workers) | 15,422 | 405 MB |
|
|
158
189
|
|
|
159
190
|
The honest trade-off: Puma's fork cluster uses all 8 cores, so it serves
|
|
160
|
-
~
|
|
191
|
+
~5.6× the throughput—at ~4× the memory. Ractor-mode Rails would close
|
|
161
192
|
the throughput gap at one-process memory cost; the upstream blockers are
|
|
162
193
|
tracked in [doc/rails-on-ractors.md](doc/rails-on-ractors.md).
|
|
163
194
|
|
|
164
|
-
In short: on the tiny synthetic app, ractor mode beats fork-level CPU parallelism (**5.
|
|
165
|
-
own GVL-bound threaded mode, +
|
|
166
|
-
about 1/
|
|
167
|
-
Every Kino mode is 1.5-
|
|
195
|
+
In short: on the tiny synthetic app, ractor mode beats fork-level CPU parallelism (**5.7×** Kino's
|
|
196
|
+
own GVL-bound threaded mode, +25% over the cluster) in one process, at
|
|
197
|
+
about 1/8th of the cluster's memory by PSS (~4× on a real Rails app).
|
|
198
|
+
Every Kino mode is 1.5-1.7× ahead of the cluster on I/O-light endpoints,
|
|
199
|
+
and native HTTP/2 adds +79% over HTTP/1.1 on top. The macOS numbers
|
|
168
200
|
(secondary; everything there hits the loopback ceiling) and the
|
|
169
201
|
YJIT × Ractors gotcha are in [doc/benchmarks.md](doc/benchmarks.md).
|
|
170
202
|
|
|
171
203
|
Reproduce: `bench/run.sh [seconds] [concurrency]` for the main table,
|
|
172
204
|
`bench/studies.sh` for the follow-ups (CPU recipe, topology, scaling,
|
|
173
|
-
logging, memory).
|
|
205
|
+
sharded I/O, logging, memory), `bench/h2.sh` for the HTTP/2 matrix.
|
|
174
206
|
|
|
175
207
|
## Install
|
|
176
208
|
|
|
@@ -239,6 +271,7 @@ server = Kino::Server.new(app,
|
|
|
239
271
|
control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port
|
|
240
272
|
control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics
|
|
241
273
|
tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
|
|
274
|
+
http2: true, # ALPN h2 on TLS + plaintext h2c; false = HTTP/1 only
|
|
242
275
|
)
|
|
243
276
|
server.start
|
|
244
277
|
server.shutdown # graceful: drain → deadline → abort stragglers
|
|
@@ -259,6 +292,45 @@ server.shutdown # graceful: drain → deadline → abort straggler
|
|
|
259
292
|
always counts as "shareable" (classes are), even if calling it touches
|
|
260
293
|
unshareable state. Force `:threaded` for those.
|
|
261
294
|
|
|
295
|
+
### Sharded I/O
|
|
296
|
+
|
|
297
|
+
`io_shards true` (off by default) moves HTTP I/O from Tokio's shared
|
|
298
|
+
multi-thread runtime onto current-thread shards: one thread accepts and
|
|
299
|
+
hands each connection to the least-loaded shard, which then owns it for
|
|
300
|
+
its lifetime—no work-stealing, no cross-thread wakeups on the hot path.
|
|
301
|
+
On the 8-core reference box fast handlers gain +1-3% (best with
|
|
302
|
+
`io_threads 8`); the win grows with scheduler contention, so measure on
|
|
303
|
+
your own core count. Ruby-bound endpoints are unchanged. Orthogonal to
|
|
304
|
+
`mode`: it reshapes the Rust side only.
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
# kino.rb
|
|
308
|
+
io_shards true
|
|
309
|
+
io_threads 8 # optional; default: half the available CPUs
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### HTTP/2
|
|
313
|
+
|
|
314
|
+
On by default, on both transports, with nothing to configure:
|
|
315
|
+
|
|
316
|
+
- **TLS binds** advertise `h2` via ALPN, so browsers and h2-capable
|
|
317
|
+
clients negotiate HTTP/2 and everything else stays on HTTP/1.1.
|
|
318
|
+
- **Plaintext binds** serve prior-knowledge h2c: a client that opens
|
|
319
|
+
with the HTTP/2 preface (an h2-preferring load balancer, a gRPC-style
|
|
320
|
+
backend hop, `curl --http2-prior-knowledge`) gets HTTP/2; ordinary
|
|
321
|
+
clients are HTTP/1.1 exactly as before. Browsers never do h2 on
|
|
322
|
+
plaintext, so a certificate-less kino behaves identically for them.
|
|
323
|
+
|
|
324
|
+
The Rack side is spec-complete on h2: `SERVER_PROTOCOL` is `"HTTP/2"`,
|
|
325
|
+
`HTTP_HOST`/`SERVER_NAME`/`SERVER_PORT` come from the `:authority`
|
|
326
|
+
pseudo-header (h2 requests carry no Host header), split cookie headers
|
|
327
|
+
are rejoined with `"; "`, and streamed uploads flow through the same
|
|
328
|
+
backpressured body channel as HTTP/1. Streams multiplex into the same
|
|
329
|
+
worker slots as keep-alive requests—`workers × threads` bounds
|
|
330
|
+
concurrency either way. (`rack.hijack` stays out on h2 as it is on h1;
|
|
331
|
+
the protocol has no 101 upgrade to hijack anyway.) `http2 false` pins
|
|
332
|
+
the server to HTTP/1 and drops `h2` from ALPN.
|
|
333
|
+
|
|
262
334
|
## Config file and CLI
|
|
263
335
|
|
|
264
336
|
Settings can live in a Puma-style Ruby DSL file: `kino.rb` in the
|
|
@@ -424,8 +496,8 @@ not `queue_time`.
|
|
|
424
496
|
|
|
425
497
|
## Logging
|
|
426
498
|
|
|
427
|
-
With one log line per request, `Kino::Logger` sustained **
|
|
428
|
-
throughput of a shared `::Logger`** (
|
|
499
|
+
With one log line per request, `Kino::Logger` sustained **1.7× the
|
|
500
|
+
throughput of a shared `::Logger`** (155k vs 90k req/s on the benchmark
|
|
429
501
|
box). There are two native pieces. Both write through a lock-free
|
|
430
502
|
channel to a Rust flusher thread, so request threads never take a log
|
|
431
503
|
mutex and never make a write syscall:
|
data/doc/architecture.md
CHANGED
|
@@ -11,10 +11,13 @@ tokio (Rust threads) Ruby
|
|
|
11
11
|
└──────────────────────────┘ └────────────────────────────┘
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
All network I/O lives in Rust on
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
All network I/O lives in Rust on tokio runtimes; hyper parses HTTP/1.1 and
|
|
15
|
+
handles keep-alive; rustls terminates TLS. The default is Tokio's
|
|
16
|
+
multi-thread runtime. With `io_shards true`, one current-thread runtime
|
|
17
|
+
accepts connections and assigns them to current-thread I/O shards, where
|
|
18
|
+
each connection stays for its lifetime. Ruby never touches a socket. Each
|
|
19
|
+
request becomes a Rust-side `RequestCtx` pushed to a bounded flume MPMC
|
|
20
|
+
queue; Ruby workers pull from it.
|
|
18
21
|
|
|
19
22
|
## Topology
|
|
20
23
|
|
|
@@ -106,13 +109,63 @@ plain decrement after an `.await` would never run).
|
|
|
106
109
|
|
|
107
110
|
## Graceful shutdown
|
|
108
111
|
|
|
109
|
-
`stop_accepting` →
|
|
110
|
-
|
|
112
|
+
`stop_accepting` → every live connection switches to graceful shutdown
|
|
113
|
+
(finish the in-flight request, answer with `Connection: close` on
|
|
114
|
+
HTTP/1 or GOAWAY on h2, take nothing new—so drains converge instead
|
|
115
|
+
of chasing chatty keep-alive clients) → drain until queue + in-flight
|
|
116
|
+
reach zero or the deadline passes → `close_queue` (idle workers see
|
|
117
|
+
Disconnected and exit) →
|
|
111
118
|
join workers → past deadline: abort remaining clients (a 500, or a
|
|
112
119
|
connection abort mid-stream), interrupt blocked workers, reap
|
|
113
120
|
stragglers → tear down the tokio runtime. Idempotent;
|
|
114
121
|
a second INT/TERM force-exits.
|
|
115
122
|
|
|
123
|
+
## HTTP/2
|
|
124
|
+
|
|
125
|
+
One connection builder serves every protocol: hyper-util's auto builder
|
|
126
|
+
picks h2 by ALPN on TLS connections, by the 24-byte preface sniff on
|
|
127
|
+
plaintext (prior-knowledge h2c), and HTTP/1.x otherwise; `http2 false`
|
|
128
|
+
pins the HTTP/1 codec and skips the sniff entirely. Streams multiplex
|
|
129
|
+
into the same bounded queue as keep-alive requests, so h2 concurrency
|
|
130
|
+
is bounded by workers × threads exactly as h1's is, and the bounded
|
|
131
|
+
body channels give per-stream backpressure for free: while the
|
|
132
|
+
forwarder blocks, hyper withholds WINDOW_UPDATE and the client stalls.
|
|
133
|
+
|
|
134
|
+
The env bridge fills SERVER_NAME/SERVER_PORT/HTTP_HOST from the request
|
|
135
|
+
URI's `:authority` (h2 requests carry no Host header), through the same
|
|
136
|
+
host LRU the Host-header path uses, keyed by the authority bytes;
|
|
137
|
+
SERVER_PROTOCOL is the interned "HTTP/2". h2 trailer frames are dropped
|
|
138
|
+
(Rack has no trailer surface), and the h2 codec itself rejects
|
|
139
|
+
connection-ish headers before they can reach the env.
|
|
140
|
+
|
|
141
|
+
The upload path needed one h2-shaped fix: `read_body` drains every
|
|
142
|
+
already-queued chunk in a single native call (one GVL round-trip and
|
|
143
|
+
one Ruby string per 64 KB read), because a body arriving as 16 KB DATA
|
|
144
|
+
frames otherwise paid one crossing per frame—that alone took h2
|
|
145
|
+
uploads from half of h1's throughput to parity. A knob sweep over
|
|
146
|
+
hyper's h2 codec (frame size, adaptive windows, window sizes) moved
|
|
147
|
+
nothing after that, so hyper's defaults stay.
|
|
148
|
+
|
|
149
|
+
SETTINGS_MAX_CONCURRENT_STREAMS is derived from slot capacity
|
|
150
|
+
(workers × threads, clamped to [8, 1024]) rather than hyper's flat 200:
|
|
151
|
+
a smart balancer sees the server's true admission, and a hostile client
|
|
152
|
+
cannot multiply one connection into hundreds of queued requests—the
|
|
153
|
+
h2 analogue of h1's one-request-per-connection shape. The codec's own
|
|
154
|
+
abuse bounds ship as hyper/h2 defaults and were reviewed: 16 KB header
|
|
155
|
+
lists, 20 pending remote resets then GOAWAY (rapid reset), per-second
|
|
156
|
+
reset-churn and empty-frame budgets.
|
|
157
|
+
|
|
158
|
+
Low-cardinality header values (UA, accept-*, sec-ch-*, sec-fetch-*)
|
|
159
|
+
are interned in an LRU of frozen strings—the env-side analogue of
|
|
160
|
+
HPACK's wire dedup, and the practical form of it: hyper does not expose
|
|
161
|
+
HPACK table indices, so the cache keys on value bytes and works for
|
|
162
|
+
HTTP/1 too. Cookie and authorization are deliberately excluded
|
|
163
|
+
(per-user cardinality, secret lifetime).
|
|
164
|
+
|
|
165
|
+
Deferred until benchmarks justify it: slot-aware flow-control window
|
|
166
|
+
grants (a memory/abuse lever, not a throughput one—the knob sweep
|
|
167
|
+
showed windows don't gate upload throughput).
|
|
168
|
+
|
|
116
169
|
## Timer waits: `Kino.sleep`
|
|
117
170
|
|
|
118
171
|
MRI's `sleep` parks the thread on the VM timer, whose wakeups inside
|
|
@@ -125,7 +178,8 @@ at the interrupt tick so `Thread#kill` and shutdown stay responsive.
|
|
|
125
178
|
|
|
126
179
|
- **tokio + hyper**: the bottleneck is the Ruby dispatch boundary, not raw
|
|
127
180
|
I/O throughput; what matters is HTTP correctness, keep-alive, TLS, and
|
|
128
|
-
h2-
|
|
181
|
+
h2 (since shipped, via hyper-util's protocol-auto builder)—hyper's
|
|
182
|
+
territory. Cross-platform out of the box.
|
|
129
183
|
- **monoio**: thread-per-core io_uring looks great in echo-server
|
|
130
184
|
benchmarks, but hyper only works through its poll-io compat layer
|
|
131
185
|
(forfeiting io_uring on the hot path), and the share-nothing advantage
|
data/doc/benchmarks.md
CHANGED
|
@@ -17,9 +17,21 @@ the deployment most apps run today.
|
|
|
17
17
|
9R14 (Genoa), 16 GB RAM, Amazon Linux 2023, kernel 6.18. A realistic
|
|
18
18
|
app-server size, deliberately: nobody provisions a 32-core box per
|
|
19
19
|
app process.
|
|
20
|
-
- Toolchain built on the box via mise: Ruby 4.0.
|
|
21
|
-
`RUBY_YJIT_ENABLE=1` for every server), Rust
|
|
20
|
+
- Toolchain built on the box via mise: Ruby 4.0.6 (**YJIT enabled**,
|
|
21
|
+
`RUBY_YJIT_ENABLE=1` for every server), Rust stable, Kino compiled in
|
|
22
22
|
the release profile.
|
|
23
|
+
- **2026-09 full re-measurement** (a fresh c7a.2xlarge): every number in
|
|
24
|
+
this document and the README was re-run on Ruby 4.0.6 / kernel 6.18 /
|
|
25
|
+
Puma 8.0.2, adding the sharded-I/O and HTTP/2 studies. Kino's numbers
|
|
26
|
+
reproduced within 1-3% across the board—including the /io slot
|
|
27
|
+
ceiling and the arena balloon, both intact. The one real shift: Puma
|
|
28
|
+
8.0.2 is faster than 7.x (142k plaintext, 61k /cpu), narrowing
|
|
29
|
+
ractor mode's /cpu lead to +25% (was +34%). Reversed-boot-order
|
|
30
|
+
re-runs reproduced within ~1%. A methodology trap worth recording:
|
|
31
|
+
a 5-second single-endpoint warmup understates memory badly (81 MB
|
|
32
|
+
where the full battery shows 137 MB) and hides the arena balloon
|
|
33
|
+
entirely—memory is only comparable after the full endpoint battery,
|
|
34
|
+
and /io numbers are only comparable at equal slot counts.
|
|
23
35
|
- Load generator: wrk 4.2 on the same host, 8-second windows, 64
|
|
24
36
|
connections (`bench/run.sh 8 64`). Same-host load generation costs
|
|
25
37
|
both sides CPU equally; we verified the generator was not the
|
|
@@ -249,14 +261,17 @@ visible.
|
|
|
249
261
|
|
|
250
262
|
| config | RSS | PSS |
|
|
251
263
|
|---|---:|---:|
|
|
252
|
-
| Kino :ractor 8×1 (default) |
|
|
253
|
-
| Kino lanes 8×1 |
|
|
254
|
-
| Kino :ractor 8×3 |
|
|
264
|
+
| Kino :ractor 8×1 (default) | 137 | **135** |
|
|
265
|
+
| Kino lanes 8×1 | 128 | **126** |
|
|
266
|
+
| Kino :ractor 8×3 | 170 | **168** |
|
|
255
267
|
| Kino :threaded 8×3 (`MALLOC_ARENA_MAX=2`) | 109 | **107** |
|
|
256
|
-
| Kino :threaded 8×3 (no arena cap) |
|
|
257
|
-
| Puma cluster 8×3 | 1,
|
|
268
|
+
| Kino :threaded 8×3 (no arena cap) | 672 | **670**¹ |
|
|
269
|
+
| Puma cluster 8×3 | 1,216 | **1,072** |
|
|
258
270
|
|
|
259
|
-
|
|
271
|
+
(2026-09 re-measure; the 2026-06 numbers reproduced within a few MB on
|
|
272
|
+
every row—the arena-capped threaded row to the megabyte.)
|
|
273
|
+
|
|
274
|
+
The tiny app is ~8× lighter than the cluster in ractor mode, ~10× in
|
|
260
275
|
arena-capped threaded mode. RSS ≈ PSS for every Kino row (one process,
|
|
261
276
|
nothing to share) and within ~12% for Puma here: a trivial app has almost
|
|
262
277
|
no shared state, so Puma's footprint is ~1,051 MB of *private* per-worker
|
|
@@ -280,18 +295,19 @@ Here copy-on-write **does** matter, which is exactly why PSS is mandatory:
|
|
|
280
295
|
|
|
281
296
|
| config | RSS | PSS |
|
|
282
297
|
|---|---:|---:|
|
|
283
|
-
| Kino :threaded (one process) | 97 | **
|
|
284
|
-
| Puma cluster 8×
|
|
298
|
+
| Kino :threaded (one process) | 97 | **95** |
|
|
299
|
+
| Puma cluster 8×5 (preload) | 813 | **405** |
|
|
300
|
+
| Puma cluster 8×5 (no preload) | 824 | **646** |
|
|
285
301
|
|
|
286
302
|
Puma serves the same Rails framework from 8 forks that share it
|
|
287
|
-
copy-on-write; RSS counts that shared framework once per worker (
|
|
288
|
-
PSS counts it once (
|
|
303
|
+
copy-on-write; RSS counts that shared framework once per worker (813 MB),
|
|
304
|
+
PSS counts it once (405 MB). The fair ratio is **~4×**, not the ~8× a
|
|
289
305
|
naive RSS sum reports—this is the correction that prompted the whole
|
|
290
|
-
re-measure.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
not the live object heap.
|
|
306
|
+
re-measure. The 2026-09 run also split out preload: it now saves a
|
|
307
|
+
third (405 vs 646 MB PSS)—worth turning on—but even preloaded, Ruby's
|
|
308
|
+
GC dirties heap pages and breaks copy-on-write, so each worker keeps a
|
|
309
|
+
large private heap. That is why "CoW should make a fork cluster nearly
|
|
310
|
+
free" is only half true—it shares the code, not the live object heap.
|
|
295
311
|
|
|
296
312
|
## Run-to-run variance (a.k.a. "is this a regression?")
|
|
297
313
|
|
|
@@ -376,27 +392,46 @@ crash semantics, stealing fairness, and drain behavior have spec
|
|
|
376
392
|
coverage but not production mileage. (On loopback-bound macOS, lanes
|
|
377
393
|
lose a few percent instead; see the secondary table below.)
|
|
378
394
|
|
|
395
|
+
## Sharded I/O (`io_shards true`)
|
|
396
|
+
|
|
397
|
+
`bench/studies.sh 8 64 shards`, ractor 8×3, 2026-09 reference box:
|
|
398
|
+
|
|
399
|
+
| case (/plaintext) | req/s |
|
|
400
|
+
|---|---:|
|
|
401
|
+
| shared tokio runtime (baseline) | 193,461 |
|
|
402
|
+
| io_shards, default shard count | 195,436 |
|
|
403
|
+
| io_shards, `io_threads 2` | 170,509 |
|
|
404
|
+
| io_shards, `io_threads 4` | 196,298 |
|
|
405
|
+
| io_shards, `io_threads 8` | 199,657 |
|
|
406
|
+
|
|
407
|
+
On 8 cores the shards buy +1-3%, best at `io_threads 8` (the
|
|
408
|
+
half-the-cores default is close behind; 2 shards choke on accept
|
|
409
|
+
handoff). The design removes work-stealing and cross-thread wakeups,
|
|
410
|
+
so the win scales with scheduler contention—expect more on boxes with
|
|
411
|
+
more cores and connections, and measure on your own core count.
|
|
412
|
+
|
|
379
413
|
## Logging costs
|
|
380
414
|
|
|
381
415
|
Measured at full plaintext saturation (one log line per request—rates
|
|
382
416
|
that no real deployment logs at; treat these as worst-case ceilings, not
|
|
383
417
|
typical costs):
|
|
384
418
|
|
|
385
|
-
| case (8×3, same session) | req/s |
|
|
419
|
+
| case (8×3, same session, 2026-09) | req/s |
|
|
386
420
|
|---|---:|
|
|
387
|
-
| threaded, no logging |
|
|
388
|
-
| threaded, `log_requests true` (native access log) |
|
|
389
|
-
| ractor, access log off / on |
|
|
390
|
-
| app logs 1 line/req via shared `::Logger` (file) | **
|
|
391
|
-
| app logs 1 line/req via `Kino::Logger` (file) | **
|
|
421
|
+
| threaded, no logging | 213,166 |
|
|
422
|
+
| threaded, `log_requests true` (native access log) | 173,501 (−19%) |
|
|
423
|
+
| ractor, access log off / on | 191,152 / 163,777 (−14%) |
|
|
424
|
+
| app logs 1 line/req via shared `::Logger` (file) | **90,175** |
|
|
425
|
+
| app logs 1 line/req via `Kino::Logger` (file) | **154,653 (1.7×)** |
|
|
392
426
|
|
|
393
427
|
The shared-`::Logger` cost is the mutex: 24 worker threads serialize
|
|
394
428
|
through one lock plus a write syscall per line. `Kino::Logger` hands the
|
|
395
429
|
formatted line to a lock-free channel and returns—the remaining cost vs
|
|
396
430
|
not logging at all is Ruby-side formatting, which no device can remove.
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
431
|
+
(The multiple moves with the environment: 2.4× on the 2026-06 box,
|
|
432
|
+
1.7× on the 2026-09 one, 8.5× under Docker, where overlay-fs write
|
|
433
|
+
latency punished the synchronous logger hardest. The ranking is
|
|
434
|
+
environment-independent; the multiple is not.)
|
|
400
435
|
|
|
401
436
|
One trade-off worth knowing: the sink **never blocks** request threads,
|
|
402
437
|
so at absurd rates against a slow disk it drops lines once its 8192-line
|
|
@@ -408,6 +443,65 @@ Puma comparison note: request logging is opt-in there too (`--quiet` is
|
|
|
408
443
|
the default, `-v/--log-requests` enables it)—Kino's default-off
|
|
409
444
|
`log_requests` matches the ecosystem's standard behavior.
|
|
410
445
|
|
|
446
|
+
## HTTP/2
|
|
447
|
+
|
|
448
|
+
`bench/h2.sh`, Linux only (on macOS run it under Docker; the 2026-09
|
|
449
|
+
numbers below are from the c7a.2xlarge reference box). Every lane is
|
|
450
|
+
measured with h2load so the generator is identical everywhere: h2
|
|
451
|
+
lanes run 8 connections × 8 concurrent streams, h1 lanes 64
|
|
452
|
+
connections—the same total in-flight. Servers without native h2 get
|
|
453
|
+
the standard pattern instead: nginx terminating h2 and proxying
|
|
454
|
+
HTTP/1.1 upstream over keep-alive. One labeled run (kino ractor 8×3,
|
|
455
|
+
falcon `--count 8`, puma `-w 8 -t 3:3`, 5 s/lane); re-run the whole
|
|
456
|
+
script for close calls, per the variance section.
|
|
457
|
+
|
|
458
|
+
| target (h2 unless noted) | /plaintext | /10k | /big-cookie | /upload (64 KB) |
|
|
459
|
+
|---|---:|---:|---:|---:|
|
|
460
|
+
| kino h2c | 207,461 | 150,105 | 195,093 | 26,304 |
|
|
461
|
+
| kino h1 cleartext (same boot) | 116,082 | 97,634 | 110,614 | 24,338 |
|
|
462
|
+
| kino h2 TLS | 164,044 | 116,617 | 154,301 | 19,226 |
|
|
463
|
+
| kino h1 TLS (same boot) | 80,789 | 69,942 | 76,654 | 17,210 |
|
|
464
|
+
| falcon TLS (native h2) | 55,637 | 37,541 | 49,151 | 18,838 |
|
|
465
|
+
| nginx h2 → puma h1 | 81,234 | 57,891 | 51,332 | 1,247 |
|
|
466
|
+
| nginx h2 → kino h1 | 109,219 | 66,634 | 54,996 | 1,217 |
|
|
467
|
+
|
|
468
|
+
What the numbers say:
|
|
469
|
+
|
|
470
|
+
- **Native h2 beats h1 on the same server by +79% cleartext and +103%
|
|
471
|
+
over TLS** on /plaintext: the same 64 in-flight requests ride 8
|
|
472
|
+
connections instead of 64, so frames batch into fewer, larger
|
|
473
|
+
syscalls—and TLS amplifies it, since h1's 64 connections each pay
|
|
474
|
+
crypto per record. The `/big-cookie` lane (a ~2 KB cookie per
|
|
475
|
+
request) shows HPACK on top: the cookie crosses the wire once per
|
|
476
|
+
connection, not once per request, and holds 94% of bare-plaintext
|
|
477
|
+
throughput where h1 loses 5%.
|
|
478
|
+
- **Native h2 beats proxied h2 by +50%** with the *same backend*: the
|
|
479
|
+
nginx→kino-h1 lane is the proxy-cost control, and the extra hop,
|
|
480
|
+
re-parse, and re-serialize cost ~55k req/s on /plaintext.
|
|
481
|
+
- **Uploads run at h1 parity**—but only after a fix this lane
|
|
482
|
+
caught: h2 delivers bodies as 16 KB DATA frames, and `read_body`
|
|
483
|
+
originally crossed the GVL once per chunk, halving upload
|
|
484
|
+
throughput. It now drains every queued chunk per crossing
|
|
485
|
+
(doc/architecture.md), and a knob sweep over hyper's h2 codec
|
|
486
|
+
(frame size, adaptive/bigger windows) moved nothing afterwards.
|
|
487
|
+
nginx's h2 upload collapse (~1.5k) is its default-config
|
|
488
|
+
request-body flow control; tune `http2_body_preread_size`/buffering
|
|
489
|
+
before drawing conclusions there.
|
|
490
|
+
- falcon lands at roughly a third of kino-h2-TLS on fast handlers and
|
|
491
|
+
slightly behind on uploads. Single-run caveat: the nginx lanes
|
|
492
|
+
showed ±20% swings between runs in the Docker environment; on the
|
|
493
|
+
reference box the kino-vs-kino and kino-vs-proxy ratios reproduced
|
|
494
|
+
across runs, the nginx lanes remain the noisiest.
|
|
495
|
+
- **Header-value interning** (user-agent, accept-*, sec-ch-*: one
|
|
496
|
+
frozen string instead of a fresh allocation per request) was
|
|
497
|
+
measured with a realistic 11-header browser set on /plaintext,
|
|
498
|
+
using the within-boot header cost (bare vs with-headers) as the
|
|
499
|
+
drift-resistant metric: over h2 that cost fell from ~16% to ~12-13%
|
|
500
|
+
(~+3-5% throughput on the headers lane); h1's smaller header cost
|
|
501
|
+
stayed within noise. The effect the tiny app understates: 6-8 fewer
|
|
502
|
+
string allocations per request is GC pressure a real app feels more
|
|
503
|
+
than this one does.
|
|
504
|
+
|
|
411
505
|
## Hot-path notes
|
|
412
506
|
|
|
413
507
|
For the curious, the dispatch-path work behind the numbers: a try-pop
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -26,8 +26,11 @@ module Kino
|
|
|
26
26
|
after_request_complete: nil,
|
|
27
27
|
on_worker_exit: nil,
|
|
28
28
|
shutdown_timeout: 30,
|
|
29
|
+
io_shards: false,
|
|
30
|
+
io_threads: nil,
|
|
29
31
|
tokio_threads: nil,
|
|
30
32
|
tls: nil,
|
|
33
|
+
http2: true,
|
|
31
34
|
environment: nil,
|
|
32
35
|
pidfile: nil,
|
|
33
36
|
control_bind: nil,
|
|
@@ -148,6 +151,8 @@ module Kino
|
|
|
148
151
|
# queue_depth 2048
|
|
149
152
|
# queue_timeout 0.5
|
|
150
153
|
# shutdown_timeout 15
|
|
154
|
+
# io_shards true
|
|
155
|
+
# io_threads 6
|
|
151
156
|
# tokio_threads 4
|
|
152
157
|
# tls cert: "cert.pem", key: "key.pem"
|
|
153
158
|
#
|
|
@@ -229,12 +234,23 @@ module Kino
|
|
|
229
234
|
# Graceful-shutdown drain deadline in seconds.
|
|
230
235
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
231
236
|
|
|
237
|
+
# Run native HTTP I/O on current-thread shards instead of Tokio's shared pool.
|
|
238
|
+
def io_shards(enabled = true) = @config.set(:io_shards, !!enabled)
|
|
239
|
+
|
|
240
|
+
# Native HTTP I/O shard count; default with io_shards: half available CPUs.
|
|
241
|
+
def io_threads(count) = @config.set(:io_threads, Integer(count))
|
|
242
|
+
|
|
232
243
|
# Threads for the tokio (Rust I/O) runtime; default: one per core.
|
|
233
244
|
def tokio_threads(count) = @config.set(:tokio_threads, Integer(count))
|
|
234
245
|
|
|
235
246
|
# TLS termination; file paths or inline PEM strings.
|
|
236
247
|
def tls(cert:, key:) = @config.set(:tls, {cert: cert, key: key})
|
|
237
248
|
|
|
249
|
+
# HTTP/2: negotiated via ALPN on TLS binds, served to
|
|
250
|
+
# prior-knowledge clients on plaintext. On by default; set false
|
|
251
|
+
# to serve HTTP/1 only.
|
|
252
|
+
def http2(enabled = true) = @config.set(:http2, !!enabled)
|
|
253
|
+
|
|
238
254
|
# Sets RACK_ENV (unless already set) before the CLI loads the app.
|
|
239
255
|
def environment(env) = @config.set(:environment, env.to_s)
|
|
240
256
|
|
data/lib/kino/kino.so
CHANGED
|
Binary file
|
data/lib/kino/server.rb
CHANGED
|
@@ -98,11 +98,18 @@ module Kino
|
|
|
98
98
|
@lanes = !!settings[:lanes]
|
|
99
99
|
@log_requests = !!settings[:log_requests]
|
|
100
100
|
@shutdown_timeout = settings[:shutdown_timeout]
|
|
101
|
+
@io_shards = !!settings[:io_shards]
|
|
102
|
+
@io_threads = Integer(settings[:io_threads]) unless settings[:io_threads].nil?
|
|
103
|
+
if @io_threads && @io_threads < 1
|
|
104
|
+
raise ArgumentError, "io_threads must be >= 1"
|
|
105
|
+
end
|
|
106
|
+
Log.warn("io_threads has no effect unless io_shards is true") if @io_threads && !@io_shards
|
|
101
107
|
@tokio_threads = settings[:tokio_threads]
|
|
102
108
|
@tls = validate_tls(settings[:tls])
|
|
103
109
|
if @tls && unix?
|
|
104
110
|
raise ArgumentError, "TLS is not supported on a unix socket bind; terminate TLS at the proxy in front"
|
|
105
111
|
end
|
|
112
|
+
@http2 = settings.fetch(:http2, true) ? true : false
|
|
106
113
|
@pidfile = settings[:pidfile]
|
|
107
114
|
@control_bind = settings[:control_bind]&.to_s
|
|
108
115
|
@control_token = settings[:control_token]&.to_s
|
|
@@ -145,8 +152,11 @@ module Kino
|
|
|
145
152
|
request_timeout_ms: @request_timeout_ms,
|
|
146
153
|
max_connections: @max_connections,
|
|
147
154
|
max_body_size: @max_body_size,
|
|
155
|
+
io_shards: @io_shards,
|
|
156
|
+
io_threads: @io_threads,
|
|
148
157
|
tokio_threads: @tokio_threads,
|
|
149
158
|
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
159
|
+
http2: @http2,
|
|
150
160
|
lanes: @lanes, log_requests: @log_requests,
|
|
151
161
|
mode: @mode.to_s, workers: @workers, threads: @threads, batch: @batch,
|
|
152
162
|
control_bind: @control_bind, control_token: @control_token
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
# PEM strings also work).
|
|
21
21
|
# tls cert: "config/certs/server.pem", key: "config/certs/server.key"
|
|
22
22
|
|
|
23
|
+
# HTTP/2 is on by default: negotiated via ALPN on TLS binds, and served
|
|
24
|
+
# to prior-knowledge (h2c) clients on plaintext. Everything else keeps
|
|
25
|
+
# getting HTTP/1.1. Set false to serve HTTP/1 only.
|
|
26
|
+
# http2 false
|
|
27
|
+
|
|
23
28
|
## Topology
|
|
24
29
|
|
|
25
30
|
# How many workers to run. Each worker handles requests independently;
|
|
@@ -125,8 +130,14 @@
|
|
|
125
130
|
|
|
126
131
|
## Runtime
|
|
127
132
|
|
|
128
|
-
#
|
|
129
|
-
#
|
|
133
|
+
# Run native HTTP I/O on current-thread shards instead of Tokio's shared
|
|
134
|
+
# worker pool, reducing scheduler contention on very fast handlers.
|
|
135
|
+
# io_shards true
|
|
136
|
+
|
|
137
|
+
# I/O shard count. Default with io_shards: half available CPUs.
|
|
138
|
+
# io_threads 6
|
|
139
|
+
|
|
140
|
+
# Threads for the Tokio multi-thread runtime. Default: one per available CPU.
|
|
130
141
|
# tokio_threads 4
|
|
131
142
|
|
|
132
143
|
## Control plane
|
data/lib/kino/version.rb
CHANGED
data/lib/kino/worker.rb
CHANGED
|
@@ -20,12 +20,16 @@ module Kino
|
|
|
20
20
|
|
|
21
21
|
def run(server_id, worker_id, app, batch_size = 1, hooks = nil)
|
|
22
22
|
fire_after_worker_boot(hooks, worker_id)
|
|
23
|
+
# One resolution of (server, worker) into a native handle; every take
|
|
24
|
+
# after this skips the registry lookup. nil = server already gone.
|
|
25
|
+
worker = Native.worker(server_id, worker_id)
|
|
26
|
+
return unless worker
|
|
23
27
|
if batch_size <= 1
|
|
24
|
-
env =
|
|
25
|
-
env = handle_one(env,
|
|
28
|
+
env = worker.take_one
|
|
29
|
+
env = handle_one(env, worker, app, hooks) while env
|
|
26
30
|
else
|
|
27
|
-
batch =
|
|
28
|
-
batch = process(batch,
|
|
31
|
+
batch = worker.take_batch(batch_size)
|
|
32
|
+
batch = process(batch, worker, app, batch_size, hooks) while batch
|
|
29
33
|
end
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -35,22 +39,21 @@ module Kino
|
|
|
35
39
|
NOT_FUSED = Object.new.freeze
|
|
36
40
|
|
|
37
41
|
# Handle one request; returns the next env (fused take) or nil.
|
|
38
|
-
def handle_one(env,
|
|
42
|
+
def handle_one(env, worker, app, hooks)
|
|
39
43
|
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
40
|
-
request.respond_and_take_one(
|
|
44
|
+
request.respond_and_take_one(worker, status, headers, chunks)
|
|
41
45
|
end
|
|
42
|
-
result.equal?(NOT_FUSED) ?
|
|
46
|
+
result.equal?(NOT_FUSED) ? worker.take_one : result
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# Handle every env in the batch; returns the next batch (the last
|
|
46
50
|
# simple response rides the fused respond_and_take) or nil on shutdown.
|
|
47
|
-
def process(batch,
|
|
51
|
+
def process(batch, worker, app, batch_size, hooks)
|
|
48
52
|
last = batch.size - 1
|
|
49
53
|
batch.each_with_index do |env, index|
|
|
50
54
|
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
51
55
|
if index == last
|
|
52
|
-
request.respond_and_take(
|
|
53
|
-
status, headers, chunks)
|
|
56
|
+
request.respond_and_take(worker, batch_size, status, headers, chunks)
|
|
54
57
|
else
|
|
55
58
|
request.send_simple(status, headers, chunks)
|
|
56
59
|
NOT_FUSED
|
|
@@ -58,7 +61,7 @@ module Kino
|
|
|
58
61
|
end
|
|
59
62
|
return result if index == last && !result.equal?(NOT_FUSED)
|
|
60
63
|
end
|
|
61
|
-
|
|
64
|
+
worker.take_batch(batch_size)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# Run one request through the app. Complete bodies are yielded so the
|
data/sig/kino.rbs
CHANGED
|
@@ -126,8 +126,11 @@ module Kino
|
|
|
126
126
|
def after_request_complete: (?^(Hash[String, untyped], Integer) -> void handler) ?{ (Hash[String, untyped], Integer) -> void } -> untyped
|
|
127
127
|
def on_worker_exit: (?^(Integer, Exception?) -> void handler) ?{ (Integer, Exception?) -> void } -> untyped
|
|
128
128
|
def shutdown_timeout: (Numeric seconds) -> untyped
|
|
129
|
+
def io_shards: (?boolish enabled) -> untyped
|
|
130
|
+
def io_threads: (int? count) -> untyped
|
|
129
131
|
def tokio_threads: (int count) -> untyped
|
|
130
132
|
def tls: (cert: String, key: String) -> untyped
|
|
133
|
+
def http2: (?boolish enabled) -> untyped
|
|
131
134
|
def environment: (String | Symbol env) -> untyped
|
|
132
135
|
def pidfile: (String path) -> untyped
|
|
133
136
|
def control_bind: (String addr) -> String
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kino
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '3.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: httpx
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.3'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.3'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: rackup
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|