A 23 line web server in Ruby

(written by lawrence krubner, however indented passages are often quotes). You can contact lawrence at: lawrence@krubner.com, or follow me on Twitter.

Part of the Ruby philosophy is to rely on Unix as much as possible. That is, in theory, what makes Unicorn so great. Here is an example of how simple it can be to build a server:

At the heart of everything that has to do with networking under Unix are sockets. You want to read a website? You need to open a socket first. Send something to the logserver? Open a socket. Wait for incoming connections? Open a socket. Sockets are, simply put, endpoints between computers (or processes!) talking to each other.

There are a ton of different sockets: TCP sockets, UDP sockets, SCTP sockets, Unix domain sockets, raw sockets, datagram sockets, and so on. But there is one thing they all have in common: they are files. Yes, “everything is file” and that includes sockets. Just like a pipe, a socket is a file descriptor, from which you can read and write to just like with a file. The sockets API for reading and writing is deep down the same as the file API.

So, let’s say we are writing a server. How do we use sockets for that? The basic lifecycle of a server socket looks like this:

First we ask the kernel for a socket with the socket(2) system call. We specify the family of the socket (IPv4, IPv6, local), the type (stream, datagram) and the protocol (TCP, UDP, …). The kernel then returns a file descriptor, a number, which represents our socket.

Then we need to call bind(2), to bind our socket a network address and a port. After that we need to tell the kernel that our socket is a server socket, that will accept new connections, by calling listen(2). So now the kernel forwards incoming connections to us. (This is the main difference between the lifecycles of a server and a client socket).

Now that our socket is a real server socket and waiting for new incoming connections we can call accept(2), which accepts connections and returns a new socket. This new socket represents the connection. We can read from it and write to it.

But here’s the thing: accept(2) is a blocking call. It only returns if the kernel has a new connection for us. A server that doesn’t have too many incoming connections will be blocking for a long time on accept(2). This makes it really difficult to work with multiple sockets. How are you going to accept a connection on one socket if you’re still blocking on another socket that nobody wants to connect to?

This is where select(2) comes into play.

select(2) is a pretty old and famous (maybe infamous) Unix system call for working with file descriptors. It allows us to do multiplexing: we can monitor several file descriptors with select(2) and let the kernel notify us as soon as one of them has changed its state. And since sockets are file descriptors too, we can use select(2) to work with multiple sockets. Like this:

sock1 = Socket.new(:INET, :STREAM)
addr1 = Socket.pack_sockaddr_in(8888, '0.0.0.0')
sock1.bind(addr1)
sock1.listen(10)

sock2 = Socket.new(:INET, :STREAM)
addr2 = Socket.pack_sockaddr_in(9999, '0.0.0.0')
sock2.bind(addr2)
sock2.listen(10)

5.times do
  fork do
    loop do
      readable, _, _ = IO.select([sock1, sock2])

      connection, _ = readable.first.accept
      puts "[#{Process.pid}] #{connection.read}"
      connection.close
    end
  end
end

Process.wait
That’s a 23-line TCP server, listening on two ports, with 5 worker processes accepting connections. Besides missing some minor things like HTTP request parsing, HTTP response writing and error handling it’s pretty much ready to ship.

Post external references

  1. 1
    http://thorstenball.com/blog/2014/11/20/unicorn-unix-magic-tricks/
Source