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:
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!