An Ha

Simple TCP Server-Client part 1

What are sockets?

If you have ever worked with REST APIs services, for example. You probably used something like Nodejs, Flask,... to listen to "localhost:8000", and your APIs can be called by clients to send requests to this address. Underlying all the abstractions that we have taken for granted of such as HTTP, TCP,... Let's dive in the underlying mechanism that is used to implement those protocols: socket.

What are sockets exactly?

Sockets are like files that you can write and read from. Imagine you are a restaurant owner; Whenever customers want to order something from your restaurant, you will give them a paper so that they can write their order onto the paper. When other clients want to order, you will also give them a new order paper. This order paper is our socket.

Sockets are created by using the socket() system call, which will return a file descriptor. With file descriptors, we can perform I/O operations such as read, write. In a server-client scenario, applications must open their own sockets. The server will bind its sockets to a known address so that clients can locate it.

socket_a_b

How socket communication relates to a restaurant analogy

To understand socket programming better, let's use a restaurant analogy:

  1. Setting up the restaurant (server):

    • First, you need to find a place to set up your restaurant (create a socket)
    • Next, you announce your location at a specific address (bind to an address)
    • Then, you open for business and wait for customers (listen for connections)
    • When a customer arrives, you give them an order paper (accept the connection)
  2. Customer visiting the restaurant (client):

    • The customer finds your restaurant address (identifies server socket)
    • They enter your restaurant (connect to your socket)
    • They write their order on the paper you provided (send data)
    • You respond with their food (send back data)
    • When finished, they leave (close connection)

What are socket system calls?

socket_flow

Implementation

Server flows

  1. Server asks the kernel for a file descriptor (socket_fd).
  2. Server binds the file descriptors to an address (for example: localhost:8000).
  3. With listen(), servers can wait for incoming requests and handle them.
  4. When clients issue connect(), servers will accept the connection from clients; at this point, we also receive a new file descriptors that point to the client's socket so that we can interact with clients.

Client flows

  1. Client creates a socket using the socket() system call to server's address, and clients will receive from the kernel a file descriptor point to server's socket.
  2. Client uses connect() to establish a connection with the server at its known address (e.g., localhost:8000).
  3. Once the connection is established, the client can communicate with the server using read() and write() system calls or socket-specific functions like send() and recv().
  4. When finished, the client closes the socket connection.

Unlike the server, which passively waits for connections, the client actively initiates the connection process to the server's predefined address.

Code Implementation

You can find the full code implementation here: Simple Server-Client Example

// server.c
/**
 * socket() creates an endpoint for communication and returns a file descriptor
 * that refers to that endpoint. The socket has the following parameters:
 * - domain (family): Specifies the protocol family (AF_INET for IPv4, AF_INET6 for IPv6)
 * - type: Specifies the communication semantics (SOCK_STREAM for TCP)
 * - protocol: Usually 0 to choose the default protocol for the given domain and type
 */
socket_fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (socket_fd == -1) {
    perror("server: socket");
    continue;
}

/**
 * setsockopt() is used to set socket options at the socket level. In this case:
 * - SOL_SOCKET: Specifies that the option is at the socket level
 * - SO_REUSEADDR: Allows reuse of local addresses and ports
 * This is important when the server restarts quickly after shutdown to avoid "Address already in use" errors
 * since the previous connection may still be in the TIME_WAIT state in the TCP stack.
 */
int yes = 1;
if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) {
    perror("setsockopt");
    close(socket_fd);
    continue;
}

/**
 * bind() assigns the address specified by ai_addr to the socket referred to by socket_fd.
 * It binds the socket to a local address so that clients can connect to this address.
 * - ai_addr: Contains the IP address and port information (from getaddrinfo)
 * - ai_addrlen: Length of the address structure
 */
if (bind(socket_fd, p->ai_addr, p->ai_addrlen) == -1) {
    close(socket_fd);
    perror("server: bind");
    continue;
}

/**
 * listen() marks the socket as passive, i.e., as a socket that will be used to accept
 * incoming connection requests with accept().
 * - socket_fd: The socket file descriptor to listen on
 * - BACKLOG: Maximum length of the queue of pending connections
 */
if (listen(socket_fd, BACKLOG) == -1) {
    close(socket_fd);
    fprintf(stderr, "server: failed to listen\n");
    exit(EXIT_FAILURE);
}

/**
 * accept() extracts the first connection request on the queue of pending connections,
 * creates a new socket with the same properties as socket_fd, and returns a new file descriptor.
 * - socket_fd: The socket that was previously created with socket() and bound with bind()
 * - their_addr: Will be filled with the address of the connecting entity
 * - addr_size: Size of the their_addr structure (modified to indicate actual size)
 */
int newfd = accept(socket_fd, (struct sockaddr *) &their_addr, &addr_size);
if (newfd == -1) {
    // perror("accept");
    continue;
}

// send() and recv() loop

Here is the client's code:

// client.c
/**
 * socket() creates an endpoint for communication and returns a file descriptor
 * that refers to that endpoint. The socket has the following parameters:
 * - domain (family): Specifies the protocol family (AF_INET for IPv4, AF_INET6 for IPv6)
 * - type: Specifies the communication semantics (SOCK_STREAM for TCP)
 * - protocol: Usually 0 to choose the default protocol for the given domain and type
 */
socket_fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (socket_fd == -1) {
    perror("client: socket");
    continue;
}

/**
 * connect() initiates a connection on the socket to the specified address.
 * - socket_fd: The socket file descriptor returned by socket()
 * - p->ai_addr: Contains the address of the server to connect to
 * - p->ai_addrlen: Length of the address structure
 */
if (connect(socket_fd, p->ai_addr, p->ai_addrlen) == -1) {
    close(socket_fd);
    perror("client: connect");
    continue;
}

//  send() and recv() loop

How do I scale my restaurants so that they can serve multiple customers at the same time?

If you remember, accept() system calls will block until it receive a connection, so how can we serve multiple connections at the same time? We can use 2 patterns to solve this problem:process-per-connection and thread-per-connection. Thread-per-connection is the most common pattern used in modern servers. It is more efficient than process-per-connection because threads share the same memory space, which reduces the overhead of creating and managing multiple processes.

The flow of the server using threads will be like this:

  1. A connection comes in to the server.
  2. The main server process accepts the connection.
  3. It creates a new thread to handle this connection.
  4. The thread continues to handle its connection in parallel while the server process goes back to step #1.

Using threads instead of processes is more efficient because threads share the same memory space, which reduces the overhead of creating and managing multiple connections. Each thread will handle its own client independently while the main thread continues to accept new connections. You can find the full code here: Simple Server-Client Example with Threading

    // server setup to receive new connections
    // socket
    // bind
    // listen
    while (keep_running) {
        struct client_data *client = malloc(sizeof(struct client_data));
        socklen_t addr_size = sizeof(client->addr);

        // accept new connection here
        client->socket_fd = accept(socket_fd,
                                   (struct sockaddr *) &client->addr,
                                   &addr_size);

        if (client->socket_fd == -1) {
            free(client);
            continue;
        }

        // create new thread to handle new connection
        pthread_t thread;
        if (pthread_create(&thread, NULL, handle_client, client) != 0) {
            perror("pthread_create");
            free(client);
            close(client->socket_fd);
            continue;
        }
        pthread_detach(thread);
    }

Using a thread-per-connection, no extra code is needed to handle multiple connections. Each thread will handle its own connection and can communicate with the client independently. This is a simple and effective way to scale your server to handle multiple clients simultaneously. The simplicity requires little more extra cognitive load to understand the code.

Conclusion

In this article, we have learned about the basics of socket programming, how to create a simple server-client application using sockets, and how to scale our server to handle multiple clients using threads.