Skip to main content

Client/Server Network Programming

· 4 min read

Client/Server Communications

Most of the time for network programming we’re going to build client/server applications. This blog posts provides a quick overview of how we do that.

Establishing a Connection

A server is a program that has a socket that is bound to a specific port number. The main server thread just waits, listening to the socket for a client to make a connection request.

On the client-side, the client knows the hostname of the machine on which the server is running and the port number on which the server is listening. To make a connection request, the client sends a message to the server's machine and port.

The client also needs to identify itself to the server so it binds to a local port on the client’s machine that it will use during this connection. This local port is usually assigned by the system.

If messages from the client can reach the server machine, then server software accepts the connection. Upon acceptance, the server gets a new socket bound and has its remote endpoint set to the address and port of the client. It needs a new socket so that it can continue to listen to the original socket for connection requests while tending to the needs of the connected client. In some servers a new thread is also created to respond to messages received on the new connection.

On the client side, if the connection is accepted, a socket is successfully created, and the client can use the socket to communicate with the server.

The client and server can now communicate by writing to or reading from their sockets. The server will normally continue to listen on the original socket for new connections, allowing it to handle more than one client at a time.

The connection will be terminated if either end closes the socket. The Operating System will normally close any open sockets when a program is closed.

Reading and Writing Data

A common mistake that many developers make when network programming using TCP is to assume that message boundaries are preserved. In other words, they assume that sending a single message will result in a single receive.

This is not the case; TCP operates on streams of data. When data is sent by one side, it is added to the stream going to the other side. When the other side reads the stream, it reads a specified number of bytes at a time – usually the call to read bytes from a socket allows us to specify the maximum number of bytes to read and a buffer into which to place those bytes.

When the read function returns, the buffer contains new data and the number of bytes read will be returned.

So, a client might send a 100-byte message and the server might read 10 bytes at a time – meaning that no single read will ‘read’ a full message. Instead, our application will need to know how to combine the data from the reads into a single message. This is known as message framing.

There are two commonly used approaches to handle this: length prefixing and delimiting.

Length prefixing prepends each message with the length of the message. This length will have to be clearly defined, for example: “the length is a 4-byte unsigned little-endian value”.

Sending a message is relatively easy, the sending side first converts the message to a byte array and then sends the length of the byte array followed by the byte array itself.

Receiving a length-prefixed message is harder, because of the possibility of partial receives. First, one must read the length of the message into a buffer until the buffer is full (for the “4-byte signed little-endian” length, this buffer is 4 bytes). Once the length is known a second buffer can be allocated and the data read into that buffer. When the second buffer is full, then a single message has arrived, and the receiving process goes back to reading the length of the next message.

Using a delimiter, which is more complex to get right. When sending a message, the sender will have to replace any delimiter characters in the data, usually with an escaping function.

The receiver cannot predict the incoming message size, so it must append all received data onto the end of a receiving buffer, growing the buffer as necessary. Once a delimiter is found, the receiver applies an inverse of the escaping function to the receiving buffer to get the message.