Gen_tcp performance help

I feel like I’m doing something silly. I’m just trying to compare a dummy http server to cowboy and even though my “implementation” is absolutely incomplete, I’m getting considerably worse performance. I figure I’m missing something fundamental.

I know this isn’t a valid http server. I know I can’t just recv once and get an entire payload (though, in this case it’s fine because the payloads are very small)

Here’s what I have:

def run() do
tcp_opts = [:binary, packet: :raw, active: false, nodelay: true, send_timeout_close: true, reuseaddr: true, backlog: 1024]
{:ok, socket} = :gen_tcp.listen(8420, tcp_opts)
accept_loop(socket)
end

defp accept_loop(listen_socket) do
   {:ok, client_socket} = :gen_tcp.accept(listen_socket) do
   pid = spawn fn -> read_loop(client_socket) end
   :gen_tcp.controlling_process(client_socket, pid)
  accept_loop(listen_socket)
end

defp read_loop(socket) do
  {:ok, data} = :gen_tcp.recv(socket, 0)  
  conn = %Plug.Conn{
    port: 8420,
    scheme: :http,
    method: "GET",
    owner: self(),
    path_info: [],
    query_string: "",
    req_headers: %{},
    request_path: "",
    host: "127.0.0.1",
    remote_ip: {127, 0, 0, 1},
    adapter: {__MODULE__, socket},
  }
  Router.call(conn, [])
  read_loop(socket)
end

def send_resp(socket, _status, _headers, _body) do
  res = [
    "HTTP/1.1 404 Not Found\r\n",
    "Content-Length: 0\r\n\r\n"
  ]
  :ok = :gen_tcp.send(socket, res)
  {:ok, nil, socket}
end

Router is just a Plug router (what you’d pass as :plug to the Plug.Cowboy.

I’m using autocannon. It’s only starting 10 connections and then doing keepalive (so I doubt the problem is in the accept loop). Cowboy does ~30K/second. This code does a bit less than half. This seems incredible to me given that Cowboy actually has to parse the request and worry about TCP fragmentation.

Anyone know what’s up?

Well first note, you don’t want to use :gen_tcp.recv for the fastest speed, you probably want to use active: :once or whatever it was with async messaging back into the app (or if you don’t have to worry about reading messages fast enough then just active true). A recv is synchronous and involves even more messages, so that would be one of a few causes of slowdowns there, but the first to fix. :slight_smile:

The next step to optimize is that when you receive a message with active: once then you probably want to go to a full active loop until no message in X milliseconds, at which point drop back to active: once.

Though if this is a completely arbitrary ‘fast-as-possible’ test then just staying fully active receiving would be fastest.

2 Likes

Have a couple more minutes right now, looking more.

Could start up a couple of listen sockets on that same port in different processes for faster accepting, in ‘some’ cases it helps.

Also I see you are using :gen_tcp.send here as well, this is also synchronous for a response from a successful send from the OS and can slow you down a good bit if you are using keepalive to handle many requests on a single socket, it can be easiest just to slave it out to another process and then immediately receive for the next packet. However as a single connection on HTTP1 is serialized anyway, it’s not a big deal, just inet setopt to do active: :once again then call send in that order and you’ll have a packet waiting in your mailbox about the time it returns.

There are lots of other things that can be done, but at the very least I’d use accept: :once or accept: true depending. For this specific case (although someone could massively slow down the server in that case by sending tons of packets) active: true would probably be fastest, but active: :once is both quite fast (much more so then recv/2 and safe.

This is super low level stuff so ignore this post unless you really really want every microsecond, but you can bypass most of the :gen_tcp.send cost by sending asynchronously so the socket port directly via low level port commands, you can even get a notification back that way allowing it to fail (and it would tell you) so you know when to back off if you are sending too fast.

I typically create or set socket with active: false then send message to genserver itself periodically to receive using :inet.setopts(socket, active: 1). And definitely create a new process for each client with :gen_tcp.controlling_process/2. In the rare case the protocol dictates more message is expected after parsing then use :gen_tcp.recv/2.

1 Like

Thanks. I’ll try that. I initially had a more complete parser with active: once…but I started to dumb it down to try to figure out what was going on. active: once wasn’t any faster than recv but setting it before the send is a good idea!

1 Like