Rill v0.13 Reference

Chapter 9

Sockets

TCP, with the scheduler doing the waiting. A strand blocked on a socket parks and its descriptor goes to the operating system's poller, so a worker is never held up by a connection that has nothing to say.

fn handle(conn) =
  msg = sock_read(conn)
  if str_len(msg) == 0 then sock_close(conn) else reply(conn, msg)

fn reply(conn, msg) =
  sock_write(conn, "echo: " + msg)
  handle(conn)

fn serve(server) =
  conn = tcp_accept(server)
  spawn handle(conn)
  serve(server)
tcp_listen(port) -> Int listening socket, or -1
tcp_accept(server) -> Int next connection, parking until one arrives
tcp_listen_at(ipv4, port) -> Int the same on one address β€” "127.0.0.1" for this machine alone, which a tool that runs programs on request should be
tcp_connect(ipv4, port) -> Int client socket, or -1
sock_read(fd) -> Str whatever has arrived; "" means the peer closed
sock_wait(fd, ms) -> Bool wait for something to read, or give up after ms
sock_write(fd, s) -> Int writes all of it, parking as needed
sock_peer(fd) -> Int the address at the far end, packed into one number
sock_port(fd) -> Int the port this end is on, which is what a program asks after listen_on(0) β€” the kernel picks a free one and only it knows the number
sock_nodelay(fd) -> Int Nagle off; 1 if the kernel took it
sock_buffered(fd) give the socket a 64 KB write buffer: sock_write queues, and the queue goes out when it fills, on sock_flush, when the strand waits, and on sock_close
sock_flush(fd) -> Int write out what is queued; how many bytes went, or -1
sock_shutdown(fd) -> Int end the connection without closing the descriptor β€” the only way one strand gets another out of a read
sock_close(fd)

The prelude wraps the four that can fail: listen_on, listen_at, accept_on and connect_to return Result. Addresses are dotted-quad IPv4; a name is resolved by net/dns, whose lookup is a blocking extern, so the strand parks for it and no worker is held. TLS is web/tls, over the same sockets through OpenSSL, with read and write of the same shape.

sock_wait is how a connection gets a deadline. A read has none β€” a peer that opens a socket and then says nothing holds a strand and a descriptor for as long as it likes, and there is no shortage of people who will open several thousand of those. Closing the socket from another strand is not the answer: the reader is parked on that descriptor inside the poller, closing it from elsewhere does not wake it, and the number can be handed to the next connection while the old registration is still armed.

fn greet(conn) =
  if sock_wait(conn, 5000) then handshake(conn, sock_read(conn)) else sock_close(conn)

Underneath it the descriptor and a deadline are armed together and whichever arrives first wakes the strand; a peek at the socket then says which it was.

sock_shutdown is the other half of that, for the case a deadline cannot answer: one strand deciding that another strand's connection is over. Closing the descriptor is what does not work, for the reason above. Shutting down acts on the connection rather than on the descriptor, so the number stays this program's until it closes it, and the socket becomes readable-at-end-of-file at once β€” which is a readiness event, so the poller wakes the parked strand and its sock_read answers "". The reader closes the descriptor itself, on its own strand, where that has always been safe. A server dropping every connection on the way down now has something to call.

sock_nodelay is for a server that sends small messages on a clock. Nagle's algorithm holds a small segment until the previous one has been acknowledged, which is the right trade for a file being copied and the wrong one the moment the clock is faster than the round trip: every message then waits on the one before it, and the wait is the difference between the two. It is a call rather than a default because it is a real trade β€” a program that writes in small pieces and does not care when they land is better off leaving it on.

fn serve(conn) =
  sock_nodelay(conn)
  greet(conn)

sock_buffered is the other half of that trade, for a program that writes many small pieces before it waits for anything: a reply built from a dozen sock_writes is a dozen system calls, and with a buffer it is one. The buffer never holds anything back while the program is waiting β€” a read, a recv, a sleep_ms, any park writes every pending buffer first β€” so a request-and-reply loop behaves exactly as before, and gains nothing; the gain is on the side that streams. Buffering is per socket and opt-in (nothing changes for a socket that has not asked), and a --parallel build with several workers writes straight through, as the buffer is one worker's. sock_flush is for the moment a program wants the bytes gone without waiting for anything itself.

Everything above TCP is ordinary Rill: lib/web/http.rill is an HTTP/1.1 server in about a hundred lines β€” request parsing, routing, responses, and keep-alive β€” and it answers curl. Open the listening socket before spawning the accept loop, or the first client can arrive before the socket exists.

That server outruns Go's net/http on the same one-route service: 214k req/s against 198k with keep-alive, in a 52 KB binary and 7Γ— less memory (benchmarks/http/run.sh).