As you saw in Chapter 3, Writing a Simple HTTP Client, writing a simple TCP client is mostly straightforward. We have to jump through a few more hoops to make our server work, though, so before we go into the details, let’s take a step back to look at the big picture.
A typical server program does three things:
At first glance this isn’t much different from what a TCP client does. The confounding difference is that a server must perform all these actions at the same time, with an unknown number of uncoordinated clients. Later in this chapter, we’ll use some of the multiprocessing techniques we looked at in Chapter 4, Managing Processes: A Deconstructed Shell, to deal with these concurrent operations.
Although these process-based techniques are not always optimal for a high-performance server, we’ll use them, for now, to allow us to focus our attention on the nuances of the socket API, and to serve as a baseline for later improvement. Many successful projects do use this sort of technique, however, including the Postgres database daemon.[27]
So, how does a server function from a systems perspective?
As you learned in Chapter 3, Writing a Simple HTTP Client, a TCP client uses the socket() and connect() system calls to establish a connection with a server. The pattern for servers is different. Servers don’t initiate TCP connections; they simply wait for inbound connections to arrive and respond appropriately. Thus, implementing the server side requires the use of socket(), but also requires three new system calls: bind(), listen(), and accept().
These calls have to be made in a particular order. A typical flow of a client-server interaction looks similar to the illustration.
These three new calls are what distinguish a server from a client, so let’s take a moment to examine each one.
| def bind(socket: CInt, |
| address: Ptr[sockaddr], |
| address_len: socklen_t |
| ): CInt = extern |
bind() associates a socket file descriptor with an address and port on the host machine. The address is defined as a pointer to the same sockaddr structure that we used with connect() in Chapter 3. The difference is that connect() refers to the address of a remote machine, whereas bind() refers to a port and IP on our own machine.
When we try to bind() to a port and address, our OS performs several checks:
Is the address valid for this machine?
Is the address valid for this kind of socket?
Do we have the permission to bind to this port? (Only root can bind to ports below 1024, typically.)
Are any other sockets bound to this address and port? (Only one socket can be bound to an address in most protocols.)
If everything is okay, bind() succeeds and returns 0. A simple example looks like this:
| val server_socket = socket(AF_INET, SOCK_STREAM, 0) |
| |
| val server_address = stackalloc[sockaddr_in] |
| !server_address._1 = AF_INET.toUShort // IP Socket |
| !server_address._2 = htons(port) // port |
| !server_address._3._1 = INADDR_ANY // bind to 0.0.0.0 |
| val server_sockaddr = server_address.cast[Ptr[sockaddr]] |
| val addr_size = sizeof[sockaddr_in].toUInt |
| |
| val bind_result = bind(server_socket, server_sockaddr, addr_size) |
| println(s"bind returned $bind_result") |
Note the type pun: our address is a sockaddr_in, and we need to use the fields of that structure to initialize the address, but we’ll perform a cast to sockaddr when it’s time to pass it to bind().
One interesting technique here is that we’re binding to the special address INADDR_ANY, which is equivalent to the IP address 0.0.0.0. This special value binds to every available address of the host. It’s especially useful when working in containers, since the internal IP address of a container may not be known until after it is created.
Another useful value for the address is 127.0.0.1, also known as localhost. 127.0.0.1 is also valid on every host, and always refers back to itself; however, that means that if you bind a socket to localhost, you’ll only serve requests on the local loopback address, and no remote clients will ever be able to reach you. For now, since we’re running in Docker, we’ll stick with INADDR_ANY.
| def listen(socket: CInt, backlog: CInt): CInt = extern |
listen() is generally called immediately after bind(), and it only takes two arguments: the socket file descriptor and an integer backlog.
Calling listen() on a bound socket puts it into a listening state. Now our operating system will start to accept and establish incoming connections on the associated address, and then put those connections on an internal queue of size backlog. This will keep happening until we close the bound socket.
listen() is straightforward to invoke:
| val listen_result = listen(server_socket, 1024) |
| println(s"listen returned $listen_result") |
One subtlety is that your operating system will also place limits on the maximum value of backlog. In most Linux environments, including our Docker container, you can see the current setting with cat /proc/sys/net/core/somaxconn. Although you could traditionally change this with echo or sysctl, Docker actually fixes this value at container initialization; you’ll need to pass an argument of the form --sysctl net.core.somaxconn=1024 to docker run. If you’re using our Docker build environment, it should already be set to 1024.
| def accept(socket: CInt, |
| address: Ptr[sockaddr], |
| address_len: Ptr[socklen_t] |
| ): CInt = extern |
Once we have a listening socket, we can start accepting incoming connections with our application. But as you can see from the function signature, accept() is a bit trickier to invoke. We’ll pass in a pointer to an allocated but initialized sockaddr_in object, again cast to the generic sockaddr type. We’ll also pass in a pointer to an integer containing the length of the address.
When accept() returns, it’ll set address to the inbound client’s IP address and address_len to its length. If we don’t care about the client address, we can also pass in NULL and 0 for the address and length:
| val client_address = stackalloc[sockaddr_in] |
| val client_sockaddr = client_address.cast[Ptr[sockaddr]] |
| val client_addr_size = stackalloc[UInt] |
| !client_addr_size = sizeof[sockaddr_in].toUInt |
| |
| val connection_socket = accept(server_socket, client_sockaddr, |
| client_addr_size) |
| println( |
| s"""accept returned fd $connection_socket; |
| |connected client address is |
| |${format_sockaddr_in(client_address)}""".stripMargin |
| ) |
Upon success, accept() returns a new connected socket file descriptor, which is completely distinct from the listening socket descriptor we passed in.
Once we have a connection, we can read and write to the connected socket much like a pipe or other ordinary file descriptor. accept() will also set the address sockaddr to the address of the client that has connected to us. If we don’t care about the incoming address, we can just use NULL for both the sockaddr and the length, which allows us to skip the allocation and the cast. To keep things concise, we’ll do just that in our examples to follow; but in a production system, you’d probably want to retain that information for logging, even if your application logic doesn’t make use of it directly.