CNetworking

C TCP/IP Socket Programming: Build Network Servers and Clients from Scratch

TT
TopicTrick Team
C TCP/IP Socket Programming: Build Network Servers and Clients from Scratch

C TCP/IP Socket Programming: Build Network Servers and Clients from Scratch


Table of Contents


The Network Stack: How TCP Fits In

The C socket API operates at the TCP/UDP layer. You don't manage IP routing or Ethernet framing — the OS kernel handles those. You deal with: connect endpoints (IP + port), send/receive byte streams (TCP), and file descriptor management.


The Socket Connection Lifecycle


Creating a TCP Server: Step by Step

c
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define PORT    8080
#define BACKLOG 128
#define BUFSIZE 4096

int create_server(uint16_t port) {
    // Step 1: Create TCP socket (IPv4, stream)
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return -1; }
    
    // Step 2: Allow port reuse (prevents "Address already in use" after restart)
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // Step 3: Bind to address:port
    struct sockaddr_in addr = {
        .sin_family      = AF_INET,
        .sin_port        = htons(port),         // htons: host→network byte order
        .sin_addr.s_addr = INADDR_ANY,          // Accept connections on any interface
    };
    
    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(server_fd); return -1;
    }
    
    // Step 4: Mark as passive (accept connections, don't initiate)
    if (listen(server_fd, BACKLOG) < 0) {
        perror("listen"); close(server_fd); return -1;
    }
    
    printf("Server listening on port %d\n", port);
    return server_fd;
}

int main(void) {
    int server_fd = create_server(PORT);
    if (server_fd < 0) return 1;
    
    while (1) {
        // Step 5: Accept a new connection (blocks until client connects)
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        
        int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
        if (client_fd < 0) { perror("accept"); continue; }
        
        char client_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
        printf("New connection from %s:%d\n", client_ip, ntohs(client_addr.sin_port));
        
        // Step 6: Read data from client
        char buffer[BUFSIZE];
        ssize_t bytes_received = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
        if (bytes_received > 0) {
            buffer[bytes_received] = '\0';
            printf("Received (%zd bytes): %s\n", bytes_received, buffer);
            
            // Step 7: Send response
            const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
            send(client_fd, response, strlen(response), 0);
        }
        
        close(client_fd); // Step 8: Close the connection
    }
    
    close(server_fd);
    return 0;
}

Creating a TCP Client

c
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int connect_to(const char *host, uint16_t port) {
    // Resolve hostname to IP address
    struct hostent *he = gethostbyname(host);
    if (!he) { herror("gethostbyname"); return -1; }
    
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) { perror("socket"); return -1; }
    
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(port),
    };
    memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
    
    if (connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("connect"); close(sockfd); return -1;
    }
    
    printf("Connected to %s:%d\n", host, port);
    return sockfd;
}

int main(void) {
    int fd = connect_to("127.0.0.1", 8080);
    if (fd < 0) return 1;
    
    const char *request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
    send(fd, request, strlen(request), 0);
    
    char response[4096] = {0};
    ssize_t n = recv(fd, response, sizeof(response) - 1, 0);
    if (n > 0) printf("Response:\n%s\n", response);
    
    close(fd);
    return 0;
}

Handling Multiple Clients: select() and poll()

The simple server above handles one client at a time. For multiple concurrent clients, use select() or poll():

c
#include <sys/select.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>

void multi_client_server(int server_fd) {
    fd_set master_set, read_set;
    FD_ZERO(&master_set);
    FD_SET(server_fd, &master_set);
    int max_fd = server_fd;
    
    while (1) {
        read_set = master_set; // select() modifies the set, restore each iteration
        
        // Wait until any fd has data ready to read (no timeout)
        if (select(max_fd + 1, &read_set, NULL, NULL, NULL) < 0) {
            perror("select"); break;
        }
        
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &read_set)) continue;
            
            if (fd == server_fd) {
                // New connection incoming
                int client_fd = accept(server_fd, NULL, NULL);
                FD_SET(client_fd, &master_set);
                if (client_fd > max_fd) max_fd = client_fd;
                printf("New client: fd=%d\n", client_fd);
            } else {
                // Data from existing client
                char buf[4096];
                ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
                if (n <= 0) {
                    // Client disconnected
                    close(fd);
                    FD_CLR(fd, &master_set);
                    printf("Client fd=%d disconnected\n", fd);
                } else {
                    buf[n] = '\0';
                    send(fd, buf, n, 0); // Echo back
                }
            }
        }
    }
}

[!NOTE] select() has a limit of FD_SETSIZE (typically 1024) file descriptors. For production servers handling thousands of connections, use epoll (Linux) or kqueue (macOS/BSD).


Non-Blocking Sockets and epoll (Linux)

For high-performance servers (10,000+ concurrent connections), epoll is the standard:

c
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

void epoll_server(int server_fd) {
    int epoll_fd = epoll_create1(0);
    set_nonblocking(server_fd);
    
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = server_fd };
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
    
    struct epoll_event events[64];
    
    while (1) {
        int nready = epoll_wait(epoll_fd, events, 64, -1);
        for (int i = 0; i < nready; i++) {
            int fd = events[i].data.fd;
            
            if (fd == server_fd) {
                int client = accept(server_fd, NULL, NULL);
                set_nonblocking(client);
                struct epoll_event cev = { .events = EPOLLIN | EPOLLET, .data.fd = client };
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client, &cev);
            } else {
                char buf[4096];
                ssize_t n = recv(fd, buf, sizeof(buf), 0);
                if (n <= 0) {
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    send(fd, buf, n, 0);
                }
            }
        }
    }
}

epoll with edge-triggered mode (EPOLLET) is the foundation of Nginx's event loop and the mechanism enabling its legendary 10,000+ concurrent connection handling.


Sending and Receiving Complete Messages

send() and recv() may not send/receive all bytes in one call for large messages. Production code must loop:

c
ssize_t send_all(int sockfd, const void *buf, size_t len) {
    ssize_t total_sent = 0;
    const char *ptr = (const char*)buf;
    
    while (total_sent < (ssize_t)len) {
        ssize_t n = send(sockfd, ptr + total_sent, len - total_sent, 0);
        if (n <= 0) {
            if (n < 0 && errno == EINTR) continue; // Interrupted — retry
            return n; // Error or connection closed
        }
        total_sent += n;
    }
    return total_sent;
}

ssize_t recv_all(int sockfd, void *buf, size_t len) {
    ssize_t total_recv = 0;
    char *ptr = (char*)buf;
    
    while (total_recv < (ssize_t)len) {
        ssize_t n = recv(sockfd, ptr + total_recv, len - total_recv, 0);
        if (n <= 0) {
            if (n < 0 && errno == EINTR) continue;
            return total_recv == 0 ? n : total_recv;
        }
        total_recv += n;
    }
    return total_recv;
}

Socket Options: SO_REUSEADDR and TCP_NODELAY

c
// SO_REUSEADDR: Allow binding to a port that's in TIME_WAIT state
// CRITICAL for servers — prevents "Address already in use" after restart
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

// TCP_NODELAY: Disable Nagle's algorithm — send small packets immediately
// Use for real-time protocols (games, trading systems) where low latency > throughput
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

// SO_KEEPALIVE: Enable TCP keepalive — detect dead connections
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));

// SO_RCVTIMEO: Set receive timeout to 5 seconds
struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

Frequently Asked Questions

Why do I need htons() for port numbers? Different CPU architectures store multi-byte numbers in different byte orders. x86-64 is little-endian; the internet uses big-endian (network byte order). htons() (host-to-network short) converts a 16-bit port number from your CPU's format to big-endian. Always use htons() for ports and htonl() for IP addresses.

What is the difference between send/recv and write/read on sockets? On POSIX, write(fd, buf, n) and send(fd, buf, n, 0) are functionally identical for TCP sockets (with flags=0). send/recv provide the additional flags parameter for features like MSG_DONTWAIT (non-blocking) and MSG_PEEK (peek without consuming).

How does Nginx handle 10,000 concurrent connections? Nginx uses a single-threaded event loop with epoll in edge-triggered mode. All sockets are non-blocking. When a socket has data, epoll notifies the event loop; the handler reads what's available and returns immediately (no blocking). One thread serves all connections through multiplexing.

What is the TIME_WAIT state? After a TCP connection closes, the OS keeps the (source_ip, source_port, dest_ip, dest_port) tuple in TIME_WAIT for 2× Maximum Segment Lifetime (~60-120 seconds). This prevents old delayed packets from a previous connection being misinterpreted as belonging to a new connection on the same ports. SO_REUSEADDR bypasses this for the server's listening socket.


Key Takeaway

TCP socket programming is the Gateway to Networked Systems. The six-step server lifecycle (socket → bind → listen → accept → recv/send → close) is the template behind every web server, database network protocol, and distributed system message passing system ever written in C.

Once you understand blocking I/O with select/poll and non-blocking I/O with epoll, you have the foundation to understand (and contribute to) the Nginx, Redis, and PostgreSQL networking code at its core.

Read next: UDP Datagrams & Broadcast: Fast, Low-Latency Networking →


Part of the C Mastery Course — 30 modules from C basics to expert networking and systems engineering.