Puma memory bloat in single-process multi-threaded configurations
0 reputation · 08 May 2020, 22:52 UTC
0 reputation · 08 May 2020, 22:52 UTC
To minimize infrastructure costs for low-traffic workloads, Ruby on Rails applications often utilize a single-process, multi-threaded Puma configuration. This approach aims to reduce the total RAM footprint by avoiding the overhead of multiple worker processes.
However, there is uncertainty regarding the relationship between thread count and memory bloat when the application relies on memory-intensive gems. While increasing threads can improve concurrency, it may lead to unpredictable memory growth that offsets the savings of a single-process setup.
There is no documented thread-count threshold where memory suddenly balloons. What actually happens is subtler: each additional Puma thread increases the number of requests being processed concurrently, and every in-flight request allocates Ruby objects. More threads means more simultaneous allocation churn, which grows the Ruby heap and — critically on MRI with glibc malloc — fragments it. The RSS you see is mostly retained memory, not leaked memory, and it scales with concurrency rather than with any magic thread number.
Three mechanisms compound in a single-process, multi-threaded setup:
Thread.current, per-thread memoization, or shared mutable caches grows with thread count and can hold references indefinitely.Confirmed, well-established behavior: MRI does not aggressively return memory to the OS, and glibc arena behavior worsens it. What is not established is any universal "threads vs. workers" crossover point — it depends entirely on your app's allocation profile. A worker process has a higher fixed baseline (a full copy of the app, though copy-on-write forking shares much of it), but it can be recycled independently. Threads share one heap, so one memory-hungry code path inflates the whole process.
Log RSS and GC stats per request via middleware:
class MemLog
def initialize(app); @app = app; end
def call(env)
status, h, b = @app.call(env)
rss = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1]
Rails.logger.info("RSS=#{rss}kB live_slots=#{GC.stat[:heap_live_slots]}")
[status, h, b]
end
end
Interpretation: if heap_live_slots grows monotonically over hours, you have a genuine leak (hunt it with memory_profiler or derailed_benchmarks in staging). If live slots stay flat while RSS climbs, it's fragmentation/retention.
MALLOC_ARENA_MAX=2 in the environment — this alone often flattens RSS curves significantly under glibc. Compare curves under identical load before and after.Thread.current usage.puma_worker_killer or periodic restarts as a safety valve — but treat restarts as mitigation, not a fix.Note: this assumes MRI (CRuby). JRuby and TruffleRuby have different GC and memory behavior. If you can share whether RSS grows steadily or only after traffic spikes, that single data point would distinguish leak from fragmentation definitively.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 09 May 2020, 10:46 UTC
A useful clarification is that raising Puma’s threads setting does not create multiple copies of the Rails heap in a single-process setup. The heap remains shared; the direct additions are thread stacks and per-thread state. The larger effect is indirect: more threads allow more memory-heavy requests to run simultaneously, increasing peak allocation and heap fragmentation.
So compare configurations under identical request concurrency, not merely identical threads values. Sample RSS/PSS and GC.stat with a fixed load, and record ruby -v and puma -v because allocator and Puma behavior vary by version.
Also, on MRI the GVL means extra threads mainly improve I/O concurrency. If the memory-heavy dependency is CPU-bound, a worker may deliver better throughput per megabyte even though its fixed baseline is higher.