Creating a UDP server with Ruby Ractors
Table of Contents
Introduction
Let me start by saying that the influence for this post comes from Kir Shatrov’s post Writing a Ractor-based web server. You should definitely read that post as well, if you’re interested in Ractors!
What are Ractors in Ruby?
Ruby (MRI) has a Global VM lock. This means that you cannot execute code in multiple threads at the same time. You can achieve a lot of great things with Threads, especially if you have a lot of I/O, but parallel execution in Threads does not exist in Ruby.
Ruby 3.0 introduces Ractors, a concept that lets you create true parallel execution with messages flowing between Ractors instead of shared objects. You can think of a Ractor as an independent worker that you can call up and ask to do a task for you while you do something else.
This feature is experimental in Ruby 3.0 and might have changes in the future. All examples here are with Ruby 3.0.
Coming from TCP
Kir solves the problem of messages in his TCP server by passing the TCP client
socket (returned by the #accept method), and “moving” the socket to another
Ractor. That works great for TCP, but UDP does not have this concept of a
client socket, so how do we get this to work in UDP?
Let’s start by looking at the TCP example.
Here we have three types of Ractors:
- A
listenerthat opens the TCP socket, listens for and accept incoming connections and moves the client socket topipe - A
pipethat takes messages received and yields them to what might be wanting them - A number of
listener‘s that pulls client sockets frompipe, reads from the socket, replies, and closes the socket.
This is Kir’s solution (without HTTP):
require 'socket'
pipe = Ractor.new do
loop do
Ractor.yield(Ractor.recv, move: true)
end
end
CPU_COUNT = 4
workers = CPU_COUNT.times.map do
Ractor.new(pipe) do |pipe|
loop do
s = pipe.take
puts "taken from pipe by #{Ractor.current}"
data = s.recv(1024)
puts data.inspect
s.print data
s.close
end
end
end
listener = Ractor.new(pipe) do |pipe|
server = TCPServer.new("::", 8080)
loop do
conn, _ = server.accept
pipe.send(conn, move: true)
end
end
loop do
Ractor.select(listener, *workers)
# if the line above returned, one of the workers or the listener has crashed
end
This approach does not work with UDP as we do not have a client socket to move over the message interface, so how do we solve this?
UDP Ractor solutions
I have found two solutions to this issue:
- Create the server socket inside the listener, create a socket copy with
Object#dup, and pass both this new socket, the received message, and client info to thepipe - Create the server socket outside the context of the Ractors, and give the
socket as an argument when starting the Ractors. Then pass the received message
and client info to the
pipe
I like the second solution best as it involves less duplication of objects and skips the step of copying that socket object between the Ractors.
require "socket"
pipe = Ractor.new do
loop do
Ractor.yield(Ractor.recv)
end
end
# Create socket outside of Ractor context
server_socket = UDPSocket.new :INET6
server_socket.bind("::", 8080)
CPU_COUNT = 4
workers = CPU_COUNT.times.map do
Ractor.new(pipe, server_socket) do |pipe, server_socket|
loop do
message, client = pipe.take
puts "taken from pipe by #{Ractor.current}"
# Extract client information
addr_info = Addrinfo.new(client)
# Write original message back to the client
server_socket.send(message, 0, addr_info.ip_address, addr_info.ip_port)
end
end
end
listener = Ractor.new(pipe, server_socket) do |pipe, server_socket|
loop do
msg, client = server_socket.recvfrom(MSG_LENGTH)
pipe.send([msg, client])
end
end
loop do
Ractor.select(listener, *workers)
# if the line above returned, one of the workers or the listener has crashed
end
If you know a better way to do this with Ractors, I’d love to hear from you!