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.
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.)
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.
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:
- Accept new connections from clients.
- 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:
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.
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.
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.
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.
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.