Hacker News

Kino: A high-performance Ractor web server for Ruby 4.0

Kino is a high-performance Ractor web server for Ruby 4.0+. Ruby threads cannot run Ruby code in parallel, so production setups fork a process per core and pay for each copy in memory. Kino runs your code on every core in one small process. A Rust (tokio + hyper) front-end owns the network, parallel Ractors run your Rack 3 app, and a threaded fallback mode runs everything else, Rails included. - Fast. On a real 8-core server, every Kino mode is 1.5-2Γ— ahead of a Puma fork cluster on I/O-light endpoints. Ractor mode also wins on pure CPU, 30%+. Benchmarks below. - A fraction of the memory. About ~7Γ— on the simplistic bench Ractor app, and about 4Γ— less memory than a Puma cluster serving Rails in fallback threaded mode. - Parallel without forking. Ractor mode runs CPU work more than 5Γ— faster than Kino's own GVL-bound threaded mode, in the same small process. - Production plumbing included. Graceful drain, crash supervision and respawn, bounded queues with 503 backpressure, request timeouts, hardened intake (slowloris and TLS-handshake deadlines, connection and body-size caps), an on_error hook for your error tracker, TLS (rustls), live stats, async access and app logging. - Tells you why. kino --check lists exactly what blocks your app from ractor mode, finding by finding, so you do not have to decodeRactor::IsolationError yourself. - Puma-shaped. The same workers Γ— threads topology, a familiar config DSL, akino CLI. If you can run Puma, you can run Kino. N.B.: Ractors are officially experimental in Ruby 4.0, and so is this server. The threaded mode is solid. Still, Kino aims to be the best way to experiment with Ractors today-and the best Ractor server when they become stable. - Why - Benchmarks - Install - Usage - Config file and CLI kino --check - Request timeouts - Stats - Logging - Timer waits - Rack 3 compliance - Rails The GVL allows only one Ruby thread to run at a time. To use all cores, Ruby servers fork processes, and every fork costs a full copy of the app. Ractors do not have this limit: each one has its own lock, so one process can run Ruby in parallel. What was missing is a server that dispatches requests to them. Ruby 4.0 reworked Ractors (Ractor::Port , shareable_proc , less lock contention) and made this worth building. Why a Ractor server has to be built this way, and which Rust parts make Ractors fast here: doc/why-kino.md. The full design notes live in doc/architecture.md. Measured on a real server: AWS c7a.2xlarge (8-core AMD EPYC 9R14, 16 GB, Amazon Linux 2023). This is a realistic app-server size. These tables run a tiny synthetic Rack app-plaintext, a 10 KB body, a CPU-bound fib , a 5 ms wait-deliberately small, to measure the server rather than an app. It is Ractor-shareable, so Kino runs it in :ractor mode (and :threaded for comparison). A real Rails app is a different story: it is not Ractor-shareable, so it runs only in Kino's :threaded fallback, with its own numbers-see Rails below. Ruby 4.0.5 with YJIT, every server at its defaults: Puma forks 8 workers Γ— 3 threads, Kino stays in one process (8 workers; 1 thread each in ractor modes, 3 in threaded). Numbers are req/s by wrk (8-second windows, 64 connections, same host). Methodology: doc/benchmarks.md. | endpoint | Kino :ractor | + lanes | :ractor, workers 32 Β² | Kino :threaded | Puma (cluster) | |---|---|---|---|---|---| | /plaintext | 229,534 | 250,222 | 182,997 | 216,994 | 118,176 | | /10k | 178,083 | 189,862 | 151,034 | 160,400 | 106,768 | | /cpu (fib) | 77,999ΒΉ | 70,885 | 66,100 | 13,429 | 58,006 | | /io (5 ms) | 1,552 | 1,551 | 5,888 | 4,709 | 4,693 | | /io_native | 1,570 | 1,571 | 6,274 | 4,695 | 4,691 | Memory tells two different stories depending on the app, both by PSS (proportional set size; see note) after sustained load. The tiny benchmark app (Ractor-shareable, so Kino runs it in :ractor or :threaded ). Kino is ~7Γ— lighter in :ractor mode, ~10Γ— in :threaded than the Puma cluster - the gap stays large because a trivial app is almost all private per-worker heap, which copy-on-write can't share: | tiny app, Kino | Kino (one process) | Puma cluster (8 workers) | ratio | |---|---|---|---| | :ractor (8Γ—1) | 148 MB | 1,068 MB | ~7Γ— | | :threaded (8Γ—3) | 107 MBΒ³ | 1,068 MB | ~10Γ— | A real Rails app (not Ractor-shareable-Kino's :threaded fallback only, below). The gap is ~4Γ—, smaller because Rails' large framework is shared copy-on-write across Puma's forks: | Rails hello-world | Kino :threaded | Puma cluster (8 workers) | ratio | |---|---|---|---| | PSS | 92 MB | 389 MB | ~4Γ— | "+ lanes" is the experimental per-worker-queue dispatcher (lanes true ). It posts the fastest plaintext/10k of any configuration here. Details: doc/benchmarks.md. ΒΉ Stock settings, no tuning. Ractor mode beats the fork cluster on pure CPU by +34% (+22% with lanes). Threaded mode shows the GVL ceiling that every single-process Ruby server hits. The old CPU-tuning recipe is retired: its threads 1 half is the default now, and its tokio_threads 1 half costs βˆ’12% on real hardware; see doc/benchmarks.md. Β² Wait-bound throughput is slots Γ· wait, and the default columns bring 8 single-thread workers against the cluster's 24 threads. Kino slots are threads, not processes-when your app waits a lot, raise workers . The workers 32 column is that tuning: +25% over the cluster on /io (+34% via Kino.sleep ) while still ahead of it on pure CPU, all in one small process. The cost is the CPU-light rows (32 ractors oversubscribe 8 cores); pick the topology your app's wait profile needs. See doc/benchmarks.md. Β³ With MALLOC_ARENA_MAX=2 (the standard Ruby deployment setting; Heroku's default). Without it, 24 threads churning 10 KB responses through one glibc heap balloon to ~670 MB-an arena-fragmentation footgun, not a leak, and ractor mode sidesteps it. See doc/benchmarks.md. A common first idea is to keep your current server and wrap the app in a ractor pool. We measured that too (same box; the analysis is in the doc): | endpoint | Kino :ractor (8Γ—3) | Puma + ractor wrapper | Falcon + ractor wrapper | |---|---|---|---| | /plaintext | 193,826 | 19,480 | 99,776 | | /cpu (fib) | 68,061 | 17,755 | 48,721 | | /io (5 ms) | 4,530 | 1,454 | 1,549 | Rails is not Ractor-shareable today, so Kino serves it in :threaded fallback - one GVL-bound process. On the same box (examples/rails-hello , edge Rails, production, 8Γ—5): | Rails hello-world | req/s | memory (PSS) | |---|---|---| | Kino :threaded (one process) | 2,637 | 92 MB | | Puma cluster (8 workers) | 12,138 | 389 MB | The honest trade-off: Puma's fork cluster uses all 8 cores, so it serves ~4.6Γ— the throughput - at ~4Γ— the memory. Ractor-mode Rails would close the throughput gap at one-process memory cost; the upstream blockers are tracked in doc/rails-on-ractors.md. In short: on the tiny synthetic app, ractor mode beats fork-level CPU parallelism (5.8Γ— Kino's own GVL-bound threaded mode, +34% over the cluster) in one process, at about 1/7th of the cluster's memory by PSS (~4Γ— on a real Rails app). Every Kino mode is 1.5-2.1Γ— ahead of the cluster on I/O-light endpoints. The macOS numbers (secondary; everything there hits the loopback ceiling) and the YJIT Γ— Ractors gotcha are in doc/benchmarks.md. Reproduce: bench/run.sh [seconds] [concurrency] for the main table, bench/studies.sh for the follow-ups (CPU recipe, topology, scaling, logging, memory). You need Ruby >= 4.0. Add Kino to your application's bundle: bundle add kino # or: gem install kino (outside a bundle) or put it in the Gemfile yourself: gem "kino" Then generate a config and serve: bundle exec kino --init # writes kino.rb; every directive documented in place bundle exec kino # picks up config.ru + kino.rb, serves on :9292 (After a standalone gem install , the kino command works without bundle exec .) No Rust compiler needed: released versions ship precompiled native gems for Linux (x86_64/aarch64, glibc and musl) and macOS (arm64). On other platforms the gem compiles at install time; that needs a Rust toolchain, plus clang/libclang on Linux. require "kino" # Ractor mode needs a Ractor-shareable app: capture nothing, freeze config. app = Ractor.shareable_proc do |env| [200, { "content-type" => "text/plain" }, ["Hello from #{Ractor.current}"]] end Kino::Server.run(app, port: 9292) # traps INT/TERM; Ctrl-C drains gracefully Or embedded, with everything spelled out: server = Kino::Server.new(app, bind: "127.0.0.1", port: 9292, # 0 = ephemeral; read back via server.port workers: Etc.nprocessors, # ractors (parallelism) threads: 1, # per worker; ractor default 1, threaded default 3 mode: :auto, # :auto | :ractor | :threaded queue_depth: 1024, # bounded queue; overflow β†’ 503 queue_timeout: 5.0, # seconds before 503 on a full queue request_timeout: nil, # seconds before a slow response becomes a 504 (nil = off) max_connections: 8192, # cap concurrent connections; default: most of ulimit -n max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500 shutdown_timeout: 30, # drain deadline control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM ) server.start server.shutdown # graceful: drain β†’ deadline β†’ abort stragglers :ractor :workers Ractors Γ—threads Threads each. The app must beRactor.shareable? (frozen middleware,shareable_proc endpoints). Forcing:ractor with an unshareable app raisesKino::UnshareableAppError . A crashed ractor returns 500 to its in-flight requests right away, then respawns.:threaded : the same machinery onworkers Γ— threads plain Threads. Runs any Rack app, including Rails, today. Parallel for I/O, serialized by the GVL for CPU.:auto (default)::ractor when the app is shareable, otherwise a warning and:threaded . One caveat: a class use

Read on Hacker News ↗ ← Back to News

Comments

No comments yet. Start the discussion.