Class: PumaPlus::RactorWorker
- Inherits:
-
Object
- Object
- PumaPlus::RactorWorker
- Defined in:
- lib/puma_plus/ractor_worker.rb
Overview
A worker process whose unit of capacity is a Ractor rather than a thread.
Same contract as Worker: N units of capacity, each dialing the Go server and serving one request at a time. What differs is what a unit is. Threads share one lock, so N threads running CPU-bound Ruby is still one core's worth of throughput -- that is the entire reason this project scales by forking processes, and the reason the controller carries GVL instrumentation to detect when adding a thread would be pointless. Ractors each hold their own lock and genuinely run in parallel, so the actuator the controller wants ("add capacity") becomes available without a fork.
This architecture is an unusually good fit for Ractors, and the reason is what is absent rather than what is present. The hard part of a Ractor web server is work distribution: Ractors cannot share a mutable queue, so cougar (github.com/jhawthorn/cougar) has every Ractor call accept() on one shared TCPServer and lets the kernel do the balancing. puma-plus has no listener and no queue in Ruby at all -- both are in Go -- so each Ractor dials its own unix socket and shares literally nothing with its siblings. There is no shared object to make shareable, and no accept-balancing to get wrong.
Cost model versus forking a worker: a Ractor starts in roughly a millisecond against tens to hundreds for fork-plus-app-boot, and it starts warm -- same heap, same JIT state, no re-require. Fork dead time is exactly what costs the autoscaler its p99 in the step-mix benchmark, so this is the actuator most likely to close that gap.
The remaining constraint is the app, which must be deeply frozen to cross a Ractor boundary. That is a real limitation and it excludes most of the ecosystem today, Rails very much included. See #shareable!.
Known gap for the spike: WS/WebTransport publishing (PumaPlus::WS) keeps module-level state in the main Ractor and is not reachable from inside one, so realtime apps must still use the threaded worker.
Instance Method Summary collapse
-
#heartbeat_kv ⇒ Object
One process, so one pid.
-
#initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil, logger: $stderr) ⇒ RactorWorker
constructor
A new instance of RactorWorker.
-
#quiesce ⇒ Object
Nothing to stop replacing: Ractors are never respawned on death here, and Go decides the count.
- #run ⇒ Object
-
#run_preloaded(app) ⇒ Object
Serve using an already-loaded app, mirroring Worker#run_preloaded so a shepherd can preload once and hand the same object to either worker kind.
-
#running? ⇒ Boolean
--- ControlChannel handler protocol ---.
-
#set_slots(target) ⇒ Object
SET_SLOTS carries the desired Ractor count.
- #shutdown(_grace_ms) ⇒ Object
Constructor Details
#initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil, logger: $stderr) ⇒ RactorWorker
Returns a new instance of RactorWorker.
47 48 49 50 51 52 53 54 55 |
# File 'lib/puma_plus/ractor_worker.rb', line 47 def initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil, logger: $stderr) @socket_path = socket_path @app_path = app_path @ractors = ractors @worker_id = worker_id @logger = logger @hooks = Hooks.load(config_path, logger: logger) end |
Instance Method Details
#heartbeat_kv ⇒ Object
One process, so one pid. Go reads its RSS from /proc; a per-Ractor share is not a real quantity, because Ractors share a heap.
136 137 138 |
# File 'lib/puma_plus/ractor_worker.rb', line 136 def heartbeat_kv [["workers", live_count], ["worker_pids", Process.pid.to_s]] end |
#quiesce ⇒ Object
Nothing to stop replacing: Ractors are never respawned on death here, and Go decides the count.
130 |
# File 'lib/puma_plus/ractor_worker.rb', line 130 def quiesce = @logger.puts("[puma-plus] quiescing") |
#run ⇒ Object
57 58 59 |
# File 'lib/puma_plus/ractor_worker.rb', line 57 def run run_preloaded(AppLoader.load(@app_path)) end |
#run_preloaded(app) ⇒ Object
Serve using an already-loaded app, mirroring Worker#run_preloaded so a shepherd can preload once and hand the same object to either worker kind.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/puma_plus/ractor_worker.rb', line 63 def run_preloaded(app) # Ruby still prints an experimental-feature warning on first Ractor use. # Scoped to this call rather than set globally, so warnings from anything # else stay visible. warning_was = Warning[:experimental] Warning[:experimental] = false app = shareable!(app) # The event hook is VM-wide, so one registration covers every Ractor. What # it measures changes meaning here: siblings no longer contend for a shared # lock, so a rising GVL fraction now indicates contention *within* a # Ractor rather than across the process. PumaPlus::GVL.start! # Each argument crosses a Ractor boundary, so each must be shareable. # Integers and the frozen path are; `app` was just checked. @app = app @path = @socket_path.dup.freeze @live = {} @mutex = Mutex.new @exit_port = Ractor::Port.new @next_index = 0 @running = true # In the main Ractor, before any Ractor starts. Hooks cannot run *inside* a # Ractor at all -- they are blocks closing over the config file's scope, # which is exactly what cannot cross a Ractor boundary -- so there is one # boot hook per process here rather than one per unit of capacity. For the # usual use, establishing a connection pool, per-process is what you want # anyway. @hooks.run(:on_worker_boot, @worker_id) reaper = Thread.new { reap_loop } @ractors.times { spawn_ractor } @logger.puts "[puma-plus] worker #{@worker_id} pid=#{Process.pid} " \ "serving with #{@ractors} ractors" # A Ractor cannot be killed from outside, so unlike the threaded worker # there is no Thread#kill equivalent to drain with. The graceful path is # GOAWAY from Go, which breaks each WorkerThread's read loop from the # inside; TERM is the abrupt fallback and does drop in-flight requests. trap("TERM") { @running = false } trap("INT") { @running = false } # The control connection is what makes autoscaling possible: without it # Go can retire Ractors on its own (GOAWAY an idle conn) but has no way to # ask for a new one, since only Ruby can call Ractor.new. @control = ControlChannel.new(socket_path: @socket_path, logger: @logger).connect! @control.run(self) reaper.kill ensure @hooks.run(:on_worker_shutdown, @worker_id, fatal: false) @control&.close Warning[:experimental] = warning_was unless warning_was.nil? end |
#running? ⇒ Boolean
--- ControlChannel handler protocol ---
123 |
# File 'lib/puma_plus/ractor_worker.rb', line 123 def running? = @running |
#set_slots(target) ⇒ Object
SET_SLOTS carries the desired Ractor count.
126 |
# File 'lib/puma_plus/ractor_worker.rb', line 126 def set_slots(target) = adjust_ractors(target) |
#shutdown(_grace_ms) ⇒ Object
132 |
# File 'lib/puma_plus/ractor_worker.rb', line 132 def shutdown(_grace_ms) = @running = false |