Logo of AppSignal

Menu

Ruby Magic

Concurrency Deep Dive: Multi-process

Thijs Cadier on

In a previous Ruby Magic article on Mastering Concurrency, we gave an introduction to the three methods of achieving concurrency that are available to us as Ruby developers. This article is the first in a three-part series where we take a deep dive into each method.

First up: Multi-process. With this method a master process forks itself to multiple worker processes. The worker process does the actual work, while the master manages the workers.

The full source code that is used in the examples in this article is available on GitHub, so you can experiment with it yourself.

Let’s build a chat system!

Building a chat system is a good way to dive into concurrency. We’ll need a server component of a chat system that’s able to maintain connections with multiple clients. This will allow us to distribute the messages it receives from one client to all the other connected clients.

Chat example

Our chat server is running in the left tab. There are two chat clients running in the right tabs. Any message that is sent by a client will be received by all other clients.

The chat client

This article focuses on the chat server, but to communicate with it we’ll need a chat client first. The following code will be our very simple client. (A more complete example can be found on GitHub.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# client.rb
# $ ruby client.rb
require 'socket'
client = TCPSocket.open(ARGV[0], 2000)

Thread.new do
  while line = client.gets
    puts line.chop
  end
end

while input = STDIN.gets.chomp
  client.puts input
end

The client opens a TCP connection to a server running on port 2000. When connected, it spawns a thread that will puts anything the server sends, so the chat is visible in the terminal output. Finally, there’s a while loop that sends any line you type to the server, which it will send to all other connected clients.

The chat server

In this example a client connects to a chat server in order to communicate with other clients. For all three concurrency approaches we will use the same TCP server from Ruby’s standard library.

1
2
3
4
5
6
7
# server_processes.rb
# $ ruby server_processes.rb
require 'socket'

puts 'Starting server on port 2000'

server = TCPServer.open(2000)

Up to this point the code is the same for all three concurrency models. The chat server in every model will then need to handle two scenarios:

  1. Accept new connections from clients.
  2. Receive messages from clients and send them to all the other clients.

A multi-process chat server

To handle these two scenarions with a multi-process chat server, we will be spawning a process per client connection. This process will handle all the messages being sent and received for that client. We can create these processes by forking the original server process.

Forking processes

When you call the fork method, it creates a copy of the current process with the exact same state that the process is in.

A forked process has its own process id, and will be visible separately in a tool like top or Activity Monitor. That looks something like this:

Multiple processes

The process you start with is called the master process, and the processes that are forked out of the master process are called worker processes.

Since these newly forked worker processes are truly separate processes, we cannot share memory between them and the master process. We need something to communicate between them.

Unix pipes

To communicate between processes we will use Unix pipes. A Unix pipe sets up a two-way stream of bytes between two processes, and you can use it to to send data from one process to the other. Luckily, Ruby offers a nice wrapper around these pipes so we don’t need to re-invent the wheel.

In the following example we set up a pipe in Ruby –with a reading and a writing end– and we fork the master process. The code within the block that’s passed to fork is running in the forked process. The original process continues after this block. We then write a message to the original process from the forked one.

1
2
3
4
5
6
7
8
9
10
reader, writer = IO.pipe

fork do
  # This is running in the forked process.
  writer.puts 'Hello from the forked process'
end

# This is running in the original process, it will puts the
# message from the forked process.
puts reader.gets

Using pipes we can communicate between separate processes even though the processes are completely isolated from each other.

The chat server’s implementation

First we set up an array to keep track of the pipes for all clients and their “writers” (the writing end of the pipe), so we can communicate with the clients. Then we make sure that all incoming messages from the clients are sent to all the other clients.

1
2
3
4
client_writers = []
master_reader, master_writer = IO.pipe

write_incoming_messages_to_child_processes(master_reader, client_writers)

You can find the implementation of write_incoming_messages_to_child_processes on GitHub if you want to see the details of how it operates.

Accepting new connections

We will need to accept incoming connections and set up the pipes. The new writer will be pushed onto the client_writers array. The main process will be able to loop through the array and send a message to each worker process by writing to its pipe.

We then fork the master process, and the code within the forked worker process will handle the client connection.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
loop do
  while socket = server.accept
    # Create a client reader and writer so that the master
    # process can write messages back to us.
    client_reader, client_writer = IO.pipe

    # Put the client writer on the list of writers so the
    # master process can write to them.
    client_writers.push(client_writer)

    # Fork child process, everything in the fork block
    # only runs in the child process.
    fork do
      # Handle connection
    end
  end
end

Handling client connections

We also need to handle the client connection.

The forked process starts by getting the nickname from the client (the client sends the nickname by default). After that it starts a thread in write_incoming_messages_to_client that listens for messages from the main process.

Finally, the forked process starts a loop that listens for incoming messages and sends them to the master process. The master process makes sure the other worker process receive the message.

1
2
3
4
5
6
7
8
9
10
11
nickname = read_line_from(socket)
puts "#{Process.pid}: Accepted connection from #{nickname}"

write_incoming_messages_to_client(nickname, client_reader, socket)

# Read incoming messages from the client.
while incoming = read_line_from(socket)
  master_writer.puts "#{nickname}: #{incoming}"
end

puts "#{Process.pid}: Disconnected #{nickname}"

A working chat system

Now the whole chat system works! But as you can see, writing a program that uses multiprocessing is quite complex and uses a lot of resources. The upside is that it’s very robust. If one of the child processes crashes the rest of the system just keeps working. You can try that by running the example code and running kill -9 <process-id> on one of the processes (you can find the process id in the server’s log output).

In the next article we’ll implement the same chat system only using threads, so we can run a server with the same features using just one process and less memory.

Latest Ruby Magic articles (see all)

Go back

Subscribe to

Ruby Magic

Magicians never share their secrets. But we do. Sign up for our Ruby Magic email series and receive deep insights about garbage collection, memory allocation, concurrency and much more.