Howto: Multithreaded TCP Server in Common Lisp
15 September 2022
I recently decided to start doing the Protohackers challenges (in addition to cryptopals which I'm already doing). Whereas I'm doing cryptopals in C#, I decided I'd flex a different muscle and do Protohackers in Common Lisp. I have never done any direct network programming before, which is to say all the programming I've done that touches networking (web scraping, IRC clients, etc.) has all been at a higher level of abstraction and I've never had to worry about this stuff.
Turns out that one or possibly both of the following are true:
A. Google search results suck these days, and B. There seems to be a real dearth of tutorials for actual multithreaded servers.
As a result I had to learn a lot from trial and error, so I've compiled what I learned here for future reference and in case anyone else stumbles upon it.
The Smoke Test
Problem 0, aka the Smoke Test, is a qualification of sorts. The
aspiring protocol hacker must write a TCP echo server that doesn't
mangle binary and can handle up to 5 concurrent connections. Let's do
it. We start by loading usocket
:
(ql:quickload :usocket)
We can create a passive ("server") socket:
setf listener (usocket:socket-listen host
(
portt
:reuse-address :element-type '(unsigned-byte 8)))
And we will make a blocking call to wait-for-input
which
will return only when the list of sockets you feed it sees activity
(which in our case means a new socket we can accept
):
t) (usocket:wait-for-input listener :ready-only
And we'll write a function to handle I/O to and from the client. Note
that we won't be using read-line
because it will block
until it sees a newline character. It's fine during testing because
telnet
sends 0x0D 0x0A
(Carriage Return, Line
Feed) by default but we have no guarantees that the protohackers server
will do so. Thus if none of the bytes coming in are ASCII newline
characters we'll block forever and fail the challenge.
defun handle-client (socket)
(let ((s (usocket:socket-stream socket))
(make-array 1 :fill-pointer 0 :element-type '(unsigned-byte 8))))
(recv (loop for b = (read-byte s nil :eof)
(eq b :eof)
until (do (vector-push-extend b recv))
unless (zerop (length recv))
(loop for b across recv
(do (write-byte b s)))
(usocket:socket-close socket)))
Concurrency
Normally we could just go ahead and call socket-accept
to get a socket we can talk to but any time spent talking to the
accepted socket is time we can't spend accepting new clients, so instead
when we invoke our handler we'll spin it off into its own thread:
setf accepted (usocket:socket-accept client))
(
(sb-thread:make-threadlambda ()
( (handle-client accepted)))
And that's the innards. handle-client
does all the real
work, and all we need to do now is write a loop that will listen for
connections via wait-for-input
, which returns a list of
sockets that can be accepted, walk that list, and call
socket-accept
on each one. After that we spawn a new thread
and pass the accept
'ed client socket to a dedicated handler
thread.
Trade-offs
There are other ways to do this. One popular alternative is to eschew multithreading entirely, because for large numbers of connections you will quickly exhaust the number of threads that your OS will let you create. The workaround is to have a single-threaded application that keeps all sockets (the listener and the clients) in a big list and just walk that list continually.
When any socket on that list has activity you either
socket-accept
it or you handle-client
it as
appropriate. Optionally you can still spin off some work into a new
thread if it's processing-intensive but at that point you might as well
make the whole thing multithreaded.
The complete server (one which passes the Smoke Test) is available on my github repository.