ruby

Concurrency Deep Dive: Multi-threading

Thijs Cadier

Thijs Cadier on

Concurrency Deep Dive: Multi-threading

In the previous edition of Ruby Magic we showed how you can implement a chat system using multiple processes. This time we'll show you how you can do the same thing using multiple threads.

Quick recap

If you want to get a full explanation of the basic setup check out the previous article. But to remind you quickly: this is what our chat system looks like:

Chat example

We're using the same client we used earlier:

1# client.rb
2# $ ruby client.rb
3require 'socket'
4client = TCPSocket.open(ARGV[0], 2000)
5
6Thread.new do
7  while line = client.gets
8    puts line.chop
9  end
10end
11
12while input = STDIN.gets.chomp
13  client.puts input
14end

The basic setup for the server is the same:

1# server_threads.rb
2# $ ruby server_threads.rb
3require 'socket'
4
5puts 'Starting server on port 2000'
6
7server = TCPServer.open(2000)

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

Multi-threaded chat server

Now we're getting to the part that is different compared to the multi-process implementation. Using Multi-threading we can do multiple things at the same time with just one Ruby process. We will do this by spawning multiple threads that do the work.

Threads

A thread runs independently, executing code within a process. Multiple threads can live in the same process and they can share memory.

1<img src="/images/blog/2017-04/threads.png">

Some storage will be needed to store the incoming chat messages. We'll be using a plain Array, but we also need a Mutex to make sure that only one thread changes the messages at the same time (we'll see how the Mutex works in a bit).

1mutex = Mutex.new
2messages = []

Next up we start a loop in which we'll accept incoming connections from chat clients. Once a connection has been established, we'll spawn a thread to handle the incoming and outgoing messages from that client connection.

The Thread.new call blocks until server.accept returns something, and then yields the following block in the newly created thread. The code in the thread then proceeds to read the first line that's sent and stores this as the nickname. Finally it starts sending and reading messages.

1loop do
2  Thread.new(server.accept) do |socket|
3    nickname = read_line_from(socket)
4
5    # Send incoming message (coming up)
6
7    # Read incoming messages (coming up)
8  end
9end

Mutex

A mutex is an object that lets multiple threads coordinate how they use shared resources, such as an array. A thread can indicate that it needs access, and during this time other threads cannot access the shared resource.

The server reads incoming messages from the socket. It uses synchronize to get a lock on the messages store, so it can safely add a message to the messages Array.

1# Read incoming messages
2while incoming = read_line_from(socket)
3  mutex.synchronize do
4    messages.push(
5      :time => Time.now,
6      :nickname => nickname,
7      :text => incoming
8    )
9  end
10end

Finally, a Thread is spawned that runs continuously in a loop, to make sure all the new messages that have been received by the server are being sent to the client. Again it gets a lock so it knows that other threads are not interfering. After it's done with a tick of the loop it sleeps for a bit and then continues.

1# Send incoming message
2Thread.new do
3  sent_until = Time.now
4  loop do
5    messages_to_send = mutex.synchronize do
6      get_messages_to_send(nickname, messages, sent_until).tap do
7        sent_until = Time.now
8      end
9    end
10    messages_to_send.each do |message|
11      socket.puts "#{message[:nickname]}: #{message[:text]}"
12    end
13    sleep 0.2
14  end
15end

Global interpreter lock

You might have heard the story that Ruby cannot do "real" threading because of Ruby's Global Interpreter Lock (GIL). This is partially true. The GIL is a lock around the execution of all Ruby code and prevents a Ruby process from using multiple CPUs concurrently. IO operations (such as the network connections we used in this article) operate outside of the GIL, which means you can actually achieve decent concurrency in this case.

Concluding

Now we have a chat server running within a single process using a thread per connection. This will use a lot less resources than the multi-process implementation. If you want to see the details of the code or try it you can find the example code here.

In the final article in this series we'll implement this same chat server using a single thread and an event loop. Theoretically this should even use less resources than the thread implementation!

Share this article

RSS
Thijs Cadier

Thijs Cadier

Thijs is a co-founder of AppSignal who sometimes goes missing for months on end to work on our infrastructure. Makes sure our billions of requests are handled correctly. Holds the award for best drummer in the company.

All articles by Thijs Cadier

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps