Question

Does Ruby Have Real Multithreading? MRI Threads, GVL, and CPU Cores

rubymultithreadingconcurrency

Question

Does Ruby support real operating-system-level threads rather than only cooperative green threads? How can an application use multiple CPU cores for CPU-intensive processing?

Short Answer

You will learn the difference between native OS threads and parallel CPU execution in Ruby. In particular, you will understand why modern MRI Ruby threads are useful for I/O concurrency but are limited by the Global VM Lock (GVL), and when to choose Ractors, processes, or another Ruby implementation for CPU-bound work.

Concept

Modern Ruby implementations can create threads that are scheduled by the operating system. However, having OS-level threads does not always mean Ruby code can execute on multiple CPU cores at the same instant.

In the most common Ruby implementation, MRI (also called CRuby), a mechanism called the Global VM Lock (GVL) allows only one thread to execute Ruby bytecode at a time within one process.

That produces two important outcomes:

  • I/O-bound work can benefit from threads. While one thread waits for a network response, file read, database result, or sleep timer, another thread can run.
  • CPU-bound Ruby work does not run in parallel through ordinary threads. Two threads calculating large values in pure Ruby still take turns holding the GVL.

Ruby 1.8 historically used green threads in its standard implementation. Modern MRI Ruby uses native threads, but its GVL remains relevant for CPU parallelism.

To use several CPU cores for pure Ruby calculations, common choices are:

  • Ractors in Ruby 3+, which provide isolated concurrent execution designed for parallel work.
  • Multiple processes, using Process.fork on supported platforms or process-worker libraries.
  • Alternative implementations such as JRuby, whose threading model does not have MRI's GVL limitation.
  • Native extensions that release the GVL while doing intensive native work, when an appropriate library supports it.

The key distinction is: native threads are about OS scheduling; parallelism is about simultaneously executing work on more than one core.

Mental Model

Think of an MRI Ruby process as a workshop with many workers (threads) but only one key to the machine room (the GVL).

  • A worker can hold the key and operate Ruby code.
  • If that worker is waiting for a delivery, such as a database or HTTP response, another worker can use the key.
  • But two workers cannot operate Ruby machines at the same time because there is only one key.

Ractors are more like separate workshops. Each has its own ability to work, but they must pass carefully packaged messages instead of freely sharing mutable tools.

Take Quiz

Syntax and Examples

Create a Ruby thread with Thread.new:

thread = Thread.new do
  puts "Running in a separate thread"
  42
end

result = thread.value
puts result

Thread.new starts a native Ruby thread. Calling thread.value waits for the thread to finish and returns the block's final value.

Threads are especially appropriate when tasks spend time waiting on I/O:

urls = [
  "https://example.com",
  "https://example.org"
]

threads = urls.map do |url|
  Thread.new do
    # Pretend this is an HTTP request.
    sleep 1
    "Downloaded #{url}"
  end
end

puts threads.map(&:value)

Both threads can wait during roughly the same one-second period. In real code, replace sleep with an HTTP, database, or file operation.

For CPU-bound work in Ruby 3+, use a Ractor when the work and data can be isolated:

workers = [10, 20].map do |number|
  Ractor.new(number) do |n|
    n * n
  end
end

results = workers.map(&:take)
p results # [100, 400]

Each Ractor receives an argument and returns a result with take. Ractors do not freely share mutable objects, which helps make parallel execution safer.

Step by Step Execution

Consider this I/O-style threaded example:

first = Thread.new do
  sleep 1
  "first finished"
end

second = Thread.new do
  sleep 1
  "second finished"
end

puts first.value
puts second.value

Step by step:

  1. Thread.new creates first, which begins and immediately calls sleep(1).
  2. Because first is sleeping, it is not actively executing Ruby instructions.
  3. The main thread creates second, which also begins and sleeps.
  4. The two one-second waits overlap.
  5. first.value waits until first has completed, then returns "first finished".
  6. second.value returns "second finished".

The program normally finishes in about one second rather than two because waiting overlaps.

Now replace sleep with a large pure-Ruby calculation. On MRI, ordinary threads may still interleave their work, but only one can execute Ruby bytecode at a time due to the GVL. That means they generally do not provide CPU-speedup proportional to the number of cores.

Real World Use Cases

Threads are a good fit in MRI Ruby when a program has many independent waits:

  • Downloading data from several HTTP APIs.
  • Reading many files from slow storage or network locations.
  • Polling multiple queues or sockets.
  • Running background work while a service waits on databases or remote services.
  • Maintaining connections in a web server or job worker.

Use processes or Ractors for CPU-heavy work such as:

  • Generating image thumbnails or processing large media files, when the underlying tool can use parallelism.
  • Computing large reports, simulations, or transformations in pure Ruby.
  • Parsing and analyzing independent large datasets.
  • Running several independent CPU-intensive jobs from a queue.

The best choice depends on what dominates the runtime: waiting suggests threads; calculation suggests parallel workers.

Take Quiz

Real Codebase Usage

In real Ruby applications, threads are usually managed through a bounded worker pool rather than creating an unlimited number of threads.

A common pattern is concurrent I/O with error collection:

urls = ["https://example.com", "https://example.org"]

threads = urls.map do |url|
  Thread.new do
    begin
      # response = Net::HTTP.get(URI(url))
      sleep 0.1
      [url, :ok]
    rescue StandardError => error
      [url, error]
    end
  end
end

results = threads.map(&:value)

Important production practices include:

  • Bound concurrency. Hundreds or thousands of threads can exhaust memory, connections, or API rate limits. Use a fixed-size pool or a job queue.
  • Always wait for worker results. Use join, value, or a pool API so failures are not silently ignored.
  • Handle errors inside workers. An exception in a thread may otherwise be noticed only when you call value.
  • Protect shared mutable state. Use Mutex when multiple threads update the same object.
  • Prefer message passing for CPU workers. Ractors and separate processes reduce accidental shared-state bugs.

Web servers and background-job systems often use multiple processes, each with a limited thread pool, to balance CPU usage, I/O concurrency, and isolation.

Common Mistakes

Assuming Thread.new makes CPU work parallel in MRI

# This does not make pure Ruby calculations run simultaneously on two cores in MRI.
2.times do
  Thread.new { 50_000_000.times { 1 + 1 } }
end

Modern MRI threads are native, but the GVL permits only one thread to run Ruby bytecode at a time. Use Ractors, multiple processes, or a suitable native library for CPU-bound parallelism.

Forgetting to wait for threads

Thread.new do
  sleep 1
  puts "May never be printed"
end

The main program can exit before the worker finishes. Keep the thread and call join or value:

worker = Thread.new { sleep 1; puts "Finished" }
worker.join

Sharing mutable data without synchronization

counter = 0

threads = 10.times.map do
  Thread.new do
    1_000.times { counter += 1 }
  end
end

threads.each(&:join)

The result can be unreliable because counter += 1 is a read-modify-write operation. Protect it with a Mutex:

counter = 0
lock = Mutex.new

threads = 10.times.map do
  Thread.new do
    1_000.times do
      lock.synchronize { counter += 1 }
    end
  end
end

threads.each(&:join)

Creating one thread per item

Creating thousands of threads for thousands of jobs can overload the machine or remote services. Use a bounded worker pool so only a controlled number of tasks run at once.

Trying to share ordinary mutable objects between Ractors

Ractors intentionally restrict mutable sharing. Design Ractor tasks to receive simple values, immutable data, or messages, then return results.

Comparisons

OptionUses OS threads?Runs pure Ruby CPU work in parallel?Best for
MRI ThreadYesUsually no, because of the GVLI/O concurrency
MRI RactorUses parallel execution facilitiesYes, when work is isolatedCPU-bound independent tasks
Multiple processesYes, through separate OS processesYesStrong isolation and CPU workers
JRuby ThreadYesYes, subject to normal thread-safety concernsJVM-based Ruby applications
TermMeaning
ConcurrencyMultiple tasks make progress during the same period, often by taking turns or waiting efficiently.
ParallelismMultiple tasks execute literally at the same time on different CPU cores.
GVLMRI's lock that allows one thread at a time to execute Ruby bytecode in a process.
Green threadA thread scheduled by a language runtime rather than directly by the OS.
Take Quiz

Cheat Sheet

  • Create a thread: Thread.new { work }
  • Wait for completion: thread.join
  • Get result and re-raise worker errors: thread.value
  • Modern MRI threads are native OS threads.
  • MRI has a GVL, so pure Ruby CPU work in ordinary threads is not parallel.
  • Use MRI threads mainly for I/O-bound tasks.
  • Use Ractors (Ruby 3+) or processes for independent CPU-bound work.
  • Use Mutex#synchronize when threads modify shared mutable state.
  • Avoid an unbounded number of threads; limit concurrency with a pool.
  • sleep, network calls, file operations, and database waits can allow other threads to run.
  • A native extension may release the GVL, but this depends on the extension; do not assume it does.

FAQ

Does Ruby have real OS threads?

Yes. Modern MRI Ruby uses native threads provided by the operating system. Older Ruby 1.8 behavior is the source of many references to green threads.

Why do Ruby threads not use all CPU cores in MRI?

MRI's Global VM Lock allows only one thread at a time to execute Ruby bytecode within one process. Threads can still be useful while other threads wait for I/O.

Can MRI Ruby threads run in parallel during database or HTTP calls?

They can make progress concurrently when calls wait for I/O, provided the library performs blocking work in a way that releases execution for other threads. This improves throughput for waiting-heavy workloads.

What should I use for CPU-bound work in Ruby?

For modern Ruby, consider Ractors for isolated tasks. Multiple processes are also a common and broadly useful approach. Benchmark with realistic work before choosing.

Are Ractors the same as threads?

No. Ractors are isolated execution units with restricted object sharing and message passing. Their isolation is intended to support safer parallel work.

Is Thread#join different from Thread#value?

join waits for a thread and returns the thread object. value waits, returns the block's result, and raises an exception if the thread failed.

Does JRuby have the same GVL limitation as MRI?

No. JRuby uses JVM threads and does not use MRI's GVL model, so Ruby threads can run CPU-bound work in parallel. Thread-safe shared-state design is still necessary.

Take Quiz

Mini Project

Description

Build a small parallel calculation tool using Ractors. It calculates the sum of squares for several independent numeric ranges, then combines the partial results. This models a reporting or data-processing job whose input can be split into independent CPU-bound chunks.

Goal

Use multiple Ractors to calculate independent partial totals and combine their results in the main Ractor.

Requirements

Create several independent numeric ranges to process. Start one Ractor for each range. Calculate the sum of squares within each Ractor. Collect every partial result with Ractor#take. Print the combined total.

Take Quiz

Keep learning

Related questions

View more