Ruby concurrency

dRuby, Unix pipes, and forked worker pools

How to think about dRuby and Unix pipes for local Ruby process pools, and what the model shares with prefork servers such as Unicorn.

Two local process models

Ruby gives several ways to split work across local processes. dRuby and Unix pipes are two old, useful options, and both become easier to reason about when compared with the prefork model used by servers such as Unicorn.

The shared idea is process isolation. Instead of making one Ruby process do everything, a parent process can delegate work to child processes. That can avoid some global-state problems, take advantage of copy-on-write memory after fork, and contain crashes better than threads in a single process.

The difference is the communication contract. dRuby exposes Ruby objects across a local boundary. Pipes expose byte streams. Unicorn exposes a socket-accepting worker model. Each one makes a different trade-off between convenience and explicitness.

dRuby as object boundary

dRuby lets Ruby processes call methods on remote objects. The interface can feel natural because it looks like ordinary Ruby message sending. That is the benefit and the trap.

The benefit is quick composition. A worker process can expose a small service object, and the parent can call methods without designing a text protocol. For trusted local tools, experiments, or internal process pools, that can be very productive.

The trap is forgetting that the call crosses a process boundary. Arguments must be marshaled or proxied. Latency, failure, object lifetime, and security matter. A dRuby method call should be treated like an RPC call, not like a free local method call.

Pipes as byte boundary

Unix pipes are more primitive and often more honest. One process writes bytes. Another reads bytes. The protocol is whatever the programs agree on: lines, JSON, length-prefixed messages, or a small binary format.

The advantage is explicit control. Backpressure is real because writes can block. The data format is visible. Non-Ruby programs can participate. The failure mode is usually easier to inspect with ordinary Unix tools.

The cost is ceremony. A pipe-based worker pool needs framing, parsing, error messages, shutdown handling, and careful treatment of partial writes or reads. For simple payloads, that cost is acceptable. For object-rich APIs, it can become noisy.

Prefork workers

Unicorn popularized a practical Ruby server shape: a master process forks workers, each worker handles requests, and the operating system provides isolation. The master supervises. Workers can be replaced after deploys or memory growth.

This model is not a general task queue, but it teaches a useful lesson. Forking creates a clear lifecycle. Load code before fork when copy-on-write helps. Reopen resources after fork when sockets, database connections, or file handles should not be shared accidentally.

The same lesson applies to custom process pools. The boundary is not only how work is sent. It is also how workers boot, what they inherit, how they report errors, and how they die.

Choosing the boundary

dRuby is attractive when the protocol is naturally Ruby-shaped and the callers are trusted. Pipes are attractive when the protocol should be language-neutral, inspectable, and small. A prefork worker pool is attractive when the workload is request-like and the parent mainly supervises capacity.

The wrong choice is usually the one that hides failure. If the process boundary is important for reliability or memory control, the code should make that boundary visible: timeouts, error messages, restart behavior, and resource cleanup belong in the design.

Local process pools can be simple. They only stay simple when the communication contract is simpler than the work being delegated.

A small dRuby service

dRuby makes a Ruby object available to another Ruby process. That can be useful for local coordination experiments, but the object boundary should be treated like a network API.

# server.rb
require "drb/drb"

class Counter
  def initialize
    @value = 0
  end

  def increment
    @value += 1
  end
end

DRb.start_service("druby://drb.example.test:8787", Counter.new)
DRb.thread.join
# client.rb
require "drb/drb"

DRb.start_service
counter = DRbObject.new_with_uri("druby://drb.example.test:8787")
puts counter.increment

With pre-fork servers such as Unicorn, process boundaries matter. Objects created before fork may be copied into workers, while sockets and external services need careful lifecycle handling. That is why pipes, local sockets, or explicit per-worker clients are often clearer than pretending all workers share one Ruby object.

Forking cautions

The tricky part is not the dRuby call itself. It is process lifetime. Unicorn forks workers, and every worker has its own object space after fork. A client object created before fork can carry file descriptors or connection state into children in surprising ways. A server object created in one process is not magically shared by all workers unless every worker talks to it through an explicit IPC boundary.

That is why local sockets, pipes, or per-worker clients are easier to reason about. They force the code to admit where serialization happens, where retries happen, and what should occur when the peer disappears.

This also affects testing. A unit test that calls the object in one process does not prove the IPC behavior. At least one smoke test should cross the real process boundary and cover restart or disconnect behavior.

Sources

  1. Ruby documentation, DRb. https://docs.ruby-lang.org/en/master/DRb.html
  2. Ruby documentation, Process.fork. https://docs.ruby-lang.org/en/master/Process.html#method-c-fork
  3. Ruby documentation, IO.pipe. https://docs.ruby-lang.org/en/master/IO.html#method-c-pipe
  4. Unicorn project. https://yhbt.net/unicorn/