Updated October 2026: Corrected the GVL and shared-memory claims, added Ruby 3.x options (Ractors, the fiber scheduler, the M:N thread scheduler, JRuby/TruffleRuby), and replaced the thread-sizing math with the Puma workers × threads model.
In Ruby, threads and processes are essential concepts for concurrent programming, allowing you to execute multiple tasks concurrently. Let’s delve into these concepts and explore when to use each one.
Everything below about the GVL applies to CRuby (MRI), the default Ruby. JRuby and TruffleRuby behave differently — see section III.
I. Threads
What are Threads?
A thread is the smallest unit of execution within a process. Threads share the same memory space and resources of the parent process, which makes communication between them cheap. In CRuby, each Thread is a real native thread, but only one of them can run Ruby code at a time because of the Global VM Lock (GVL) — often called the GIL, by analogy with Python. A thread releases the GVL while it waits on I/O (network, disk, database, sleep), which is why threads still help a lot in web apps.
Key Features of Threads
Concurrency: Threads enable concurrent execution of tasks within a single process. While one thread waits on I/O, another can run.
Shared Memory: Threads share the same memory space, so passing data between them is cheap. The flip side: shared mutable state is not safe by default. Two threads doing
counter += 1on the same variable can lose updates, because the GVL can switch threads between the read and the write. You need synchronization.Synchronization: Threads coordinate with primitives such as
Mutex,Thread::Queue, andConditionVariable, to prevent race conditions on shared data:counter = 0 lock = Mutex.new threads = 10.times.map do Thread.new { 1_000.times { lock.synchronize { counter += 1 } } } end threads.each(&:join) counter # => 10000Often the simplest design is to not share state at all and pass work through a queue:
require 'net/http' queue = Thread::Queue.new urls.each { |url| queue << url } queue.close # pop returns nil once the queue is closed and empty workers = 4.times.map do Thread.new do while (url = queue.pop) Net::HTTP.get(URI(url)) end end end workers.each(&:join)Parallelism: CRuby threads do not run Ruby code in parallel because of the GVL, but I/O waits overlap, and C extensions that release the GVL can do real parallel work. For I/O-bound tasks this is usually enough.
Use Cases for Threads
I/O Operations: Threads are suitable for I/O-bound tasks such as network requests, file operations, and database queries, where other threads keep working while one waits for I/O to complete.
Concurrency: Threads are ideal for implementing concurrent tasks that can run independently of each other, such as processing multiple requests simultaneously (this is what Puma and Sidekiq do).
Asynchronous Operations: Threads can be used to perform background operations, allowing the main thread to continue executing other tasks while waiting for the result.
II. Processes
What are Processes?
A process is an independent instance of a running program that has its own memory space, resources, and execution environment. Processes are isolated from each other and communicate through inter-process communication (IPC) mechanisms such as pipes, sockets, or a shared datastore (Redis, the database). In Ruby, you can create processes in two main ways:
forkcopies the current process (copy-on-write). The child starts with everything already loaded — this is how Puma’s cluster mode and Unicorn create workers after preloading the app.forkis not available everywhere: not on Windows (which has noforksystem call), and not on JRuby on any platform. Check withProcess.respond_to?(:fork).Process.spawn(andsystem, backticks,Open3) starts a new program. It works on every platform and returns the pid without waiting.
# Child process sharing the already-loaded code (Unix CRuby only)
pid = fork { generate_report }
Process.wait(pid)
# A separate program, portable
pid = Process.spawn('bin/rails', 'runner', 'Report.generate')
Process.wait(pid) # or Process.detach(pid) if you never wait for it
Key Features of Processes
Isolation: Processes are isolated from each other and do not share memory space, making them more robust and secure.
Resource Management: Processes have their own memory space and resources, allowing them to run independently without affecting other processes. The cost: each process holds its own copy of the app in memory (copy-on-write after
forkhelps only partially).Fault Tolerance: A crash in one process does not take down the others; a supervisor (like Puma’s master process) can simply replace it.
Parallelism: Each CRuby process has its own GVL, so processes run Ruby code truly in parallel on multi-core CPUs.
Use Cases for Processes
CPU-bound Tasks: Processes are suitable for CPU-bound tasks that require significant computational resources and can benefit from parallel execution.
Fault Isolation: Processes are ideal for isolating faults and preventing crashes in one process from affecting other processes.
Security: Processes provide better security by isolating memory space and resources, making it harder for one process to access or modify data in another process.
III. What changed in Ruby 3.x
Threads and processes are no longer the only tools:
Ractors (Ruby 3.0+, still experimental — Ruby prints a warning when you create one): actor-like units that each have their own GVL, so they can run Ruby code in parallel inside one process. The price is that they can’t share mutable objects; data is passed as messages and copied or moved. Many gems are not Ractor-safe yet, and the API is still changing between releases. See the Ractor docs.
Fiber scheduler (Ruby 3.0+): with a scheduler installed via
Fiber.set_scheduler, blocking calls (I/O,sleep, waiting on processes) inside non-blocking fibers yield to other fibers instead of blocking. Ruby only defines the interface; the implementation comes from gems such asasync. Fibers are much lighter than threads, which helps when you have thousands of concurrent I/O waits (websockets, fan-out HTTP calls):require 'async' require 'net/http' Async do |task| urls.map { |url| task.async { Net::HTTP.get(URI(url)) } }.map(&:wait) endM:N thread scheduler (Ruby 3.3+): runs many Ruby threads on a smaller number of native threads. It is disabled by default on the main Ractor; enable it with
RUBY_MN_THREADS=1(the native thread count is set withRUBY_MAX_CPU, default 8). It makes threads cheaper, but it does not remove the GVL.JRuby and TruffleRuby: JRuby maps Ruby threads to JVM/native threads and runs them in parallel, and TruffleRuby has no global interpreter lock. On these, threads give real CPU parallelism — and race conditions show up even faster, so the synchronization advice above matters even more.
IV. When to Use Threads vs. Processes in Ruby on Rails applications?
Threads: Use threads for I/O-bound tasks, concurrency, and asynchronous operations where you need to perform multiple tasks concurrently within a single process.
Processes: Use processes for CPU-bound tasks, fault isolation, and parallelism where you need to run multiple tasks independently with their own memory space and resources.
Hybrid Approach: Most production Rails setups already combine both: Puma runs several worker processes (one per CPU core, for parallelism) and each worker runs several threads (to overlap I/O waits). Sidekiq does the same with threads per process, and you scale it by running more processes.
Examples of I/O-bound tasks that are suitable for threads include network requests, file operations, and database queries, while CPU-bound tasks such as image processing, video encoding, and data analysis are better suited for processes.
Network requests: fetching data from multiple APIs concurrently, processing the responses, and updating the database.
File operations: reading and writing files concurrently, processing the data, and generating reports.
Database queries: executing multiple queries concurrently, processing the results, and rendering the output.
Image processing: resizing images, applying filters, and generating thumbnails in parallel.
Video encoding: transcoding videos, compressing files, and uploading content in parallel.
Data analysis: processing large datasets, running complex algorithms, and generating reports in parallel.
V. How many workers and threads?
The first version of this section divided “concurrent requests” by “CPU cores” to get a thread count. That’s the wrong model. In CRuby, processes give you parallelism (bounded by cores) and threads only help while requests are waiting on I/O. So size the two separately.
1. Workers (processes) ≈ CPU cores
The Puma deployment guide recommends cluster mode with roughly one worker per CPU core (workers :auto or WEB_CONCURRENCY), and at least 2 workers if you use cluster mode at all. Memory is usually the real limit: each worker holds a full copy of the app.
2. Threads per worker by I/O-wait ratio
Threads help only as much as your requests wait on I/O. A request that spends 25% of its time waiting on the database gains little from a 10th thread; one that spends 80% waiting on external APIs gains a lot. More threads also make each request slower, because they compete for the same GVL.
- Rails 7.2 and later generate
config/puma.rbwith 3 threads by default (RAILS_MAX_THREADS, previously 5), described as “a decent compromise between throughput and latency for the average Rails application”. - Puma’s own guide suggests keeping the default unless your app spends 50%+ of its time waiting on I/O.
Start with the default, measure, and raise threads only if CPU is idle while requests queue.
3. Database pool ≥ threads
Every thread that touches Active Record needs its own connection. The pool in config/database.yml must be at least the number of threads per process (the Rails template uses RAILS_MAX_THREADS for both). Then check the total against your database limit:
total DB connections ≈ (Puma workers × pool) + (Sidekiq processes × pool) + consoles/cron
Example
A web server with 4 CPU cores and 8 GB of memory, targeting more than 100 requests/s with a response time under 100 ms:
- How much concurrency do we need? By Little’s law, requests in flight ≈ throughput × response time = 100 req/s × 0.1 s ≈ 10. “100 concurrent users” does not mean 100 requests in flight at the same moment.
- Workers: 4 (one per core), if 4 copies of the app fit comfortably in 8 GB.
- Threads: 3 per worker (the Rails default) → 4 × 3 = 12 requests in flight, which covers the ~10 we need with some headroom.
- DB pool: 3 per worker → 12 connections from Puma, plus whatever Sidekiq uses.
If the app turns out to be I/O-heavy (CPU is low but requests queue up), try 4–5 threads per worker. If CPU is pegged, more threads won’t help — you need more cores, more processes, or faster code.
In general, start from these defaults and adjust based on observed CPU usage, request queue time, and response-time percentiles, not on a formula alone.
VI. Conclusion
In Ruby, threads and processes are essential tools for concurrent programming, each with its unique characteristics and use cases. Threads are lightweight and suitable for I/O-bound work, but in CRuby they share one GVL and still need proper synchronization for shared state. Processes provide isolation, fault tolerance, and true parallelism for CPU-bound work. Ruby 3.x adds Ractors and the fiber scheduler for more specialized cases, while in Rails the practical answer is usually Puma workers ≈ cores, a few threads per worker, and a database pool to match.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.