Buffered Line Reading Over TCP Sockets

 

Part 1: Buffered Line Reading Over TCP Sockets
Chapter 59 · TLPI Series · EmbeddedPathashala

The Core Problem

Many application protocols are line-based. HTTP headers, SMTP commands, FTP responses — they all use newline characters (\n or \r\n) to separate messages. But TCP is a byte-stream protocol. It has no concept of record boundaries or lines. When you call read() on a TCP socket, you might get 1 byte, or 500 bytes, or anything in between — regardless of how the sender called write().

This creates a real problem: how do you read one line at a time from a TCP connection without reading too much or too little?

The Naive Approach — One Byte at a Time

The simplest approach that actually works is to call read() one byte at a time, stopping when you see a newline. This is correct but very slow because each read() is a system call, and system calls have overhead. For a 200-byte line, you make 200 system calls.

/* Naive readline — correct but slow */
ssize_t readline_slow(int fd, char *buf, size_t maxlen)
{
    ssize_t n, total;
    char c;

    total = 0;
    for (;;) {
        n = read(fd, &c, 1);   /* One system call per byte! */
        if (n == -1) {
            if (errno == EINTR)
                continue;      /* Interrupted — retry */
            return -1;         /* Error */
        }
        if (n == 0) {
            if (total == 0)
                return 0;      /* EOF with no data */
            break;             /* EOF after some data */
        }

        if (total < maxlen - 1)
            buf[total++] = c;

        if (c == '\n')
            break;
    }

    buf[total] = '\0';
    return total;
}

This works, but every single byte costs a system call. On a busy server handling thousands of requests per second, this is a serious performance problem.

The Buffered Approach — How It Works

The solution is to read a large chunk from the socket into a local buffer in one system call, then serve bytes from that buffer one at a time without any more system calls — until the buffer is empty, at which point we refill it with another single read().

How the Buffer Works

TCP Socket (fd)
read() call fills buffer

Internal Buffer (4096 bytes)
H
e
l
l
o
\n
W
o
r
l
d
\n
↑ ptr (next unread)
Already read data   Newline (\n)   Empty

readLineBuf()
Reads from buffer,
no system calls
until buffer empty
Key insight: One read() fills the buffer with many bytes. The application then reads lines from the buffer entirely in user space — no system calls needed until the buffer runs out.

The rlbuf Structure

To implement buffered reading, we need a structure to track the state of the buffer for each open file descriptor. This is called rlbuf (readline buffer).

#include <sys/types.h>
#include <unistd.h>

#define RLBUF_SIZE 4096    /* How many bytes we read at once */

typedef struct {
    int    fd;             /* File descriptor this buffer belongs to */
    char   buf[RLBUF_SIZE]; /* The buffer itself */
    int    buf_len;        /* How many valid bytes are in buf right now */
    int    buf_pos;        /* Index of next unread byte in buf */
} RlBuf;

/*
 * fd      — the socket or file descriptor to read from
 * buf     — the internal buffer that holds data read from fd
 * buf_len — total number of bytes currently valid in buf
 * buf_pos — index of the next character to return to the caller
 *
 * When buf_pos == buf_len, the buffer is exhausted and we need
 * to call read() again to refill it.
 */

Think of it like a local “cache” for one file descriptor. Instead of going to the kernel for every byte, we go to the kernel once to fill buf, then serve from buf until it’s empty.

Function 1: initRlBuf()

Before using the buffered reader, you must initialize the structure. This sets up the file descriptor and marks the buffer as empty.

/*
 * initRlBuf - Initialize a readline buffer for a given file descriptor.
 *
 * fd   - the file descriptor (socket) to read from
 * rlbuf - pointer to the RlBuf structure to initialize
 */
void initRlBuf(int fd, RlBuf *rlbuf)
{
    rlbuf->fd      = fd;
    rlbuf->buf_len = 0;   /* Buffer starts empty */
    rlbuf->buf_pos = 0;   /* Next read position is 0 */
}

/* Usage example */
int main(void)
{
    int sockfd;
    RlBuf rlbuf;

    /* ... connect sockfd ... */

    initRlBuf(sockfd, &rlbuf);   /* Initialize before use */

    /* Now use readLineBuf() to read lines */
    return 0;
}

Function 2: readLineBuf()

This is the main function. It reads one complete line (up to and including the newline) from the buffer. If the buffer is empty, it refills it first with a read() system call.

/*
 * readLineBuf - Read one line from the buffered stream into 'line'.
 *
 * rlbuf  - the buffer state (initialized with initRlBuf)
 * line   - destination buffer for the line
 * maxlen - maximum bytes to write into 'line' (including null terminator)
 *
 * Returns: number of bytes in the line (excluding null terminator)
 *           0 on EOF
 *          -1 on error (errno is set)
 */
ssize_t readLineBuf(RlBuf *rlbuf, char *line, size_t maxlen)
{
    ssize_t total = 0;
    char    c;

    for (;;) {
        /* --- Step 1: Is the buffer empty? Refill it. --- */
        if (rlbuf->buf_pos >= rlbuf->buf_len) {
            ssize_t n = read(rlbuf->fd, rlbuf->buf, RLBUF_SIZE);

            if (n == 0) {
                /* EOF */
                if (total == 0)
                    return 0;   /* EOF with no data read yet */
                break;          /* EOF after reading some data */
            }

            if (n == -1) {
                if (errno == EINTR)
                    continue;   /* Signal interrupted us — retry */
                return -1;      /* Real error */
            }

            rlbuf->buf_len = (int) n;   /* Valid bytes in buffer */
            rlbuf->buf_pos = 0;         /* Start from the beginning */
        }

        /* --- Step 2: Take the next byte from the buffer (no syscall!) --- */
        c = rlbuf->buf[rlbuf->buf_pos++];

        /* --- Step 3: Store the byte in the caller's line buffer --- */
        if (total < (ssize_t)(maxlen - 1))
            line[total++] = c;

        /* --- Step 4: Stop at newline --- */
        if (c == '\n')
            break;
    }

    line[total] = '\0';   /* Null-terminate the line */
    return total;
}

readLineBuf() — Flow
readLineBuf() called
buf_pos >= buf_len ?
YES (empty)
call read(fd)
fill buf
reset pos=0
NO (has data)
take byte from
buf[pos++]
(no syscall)
Is byte == ‘\n’ or EOF?
YES → null-terminate
and return line
NO → loop back
for next byte

Complete Working Example

Here is a minimal TCP echo server and client using the buffered readline implementation. The server reads lines and echoes them back; the client sends lines and prints responses.

rlbuf.h — Header

/* rlbuf.h */
#ifndef RLBUF_H
#define RLBUF_H

#include <sys/types.h>

#define RLBUF_SIZE 4096

typedef struct {
    int  fd;
    char buf[RLBUF_SIZE];
    int  buf_len;
    int  buf_pos;
} RlBuf;

void    initRlBuf(int fd, RlBuf *rlbuf);
ssize_t readLineBuf(RlBuf *rlbuf, char *line, size_t maxlen);

#endif

rlbuf.c — Implementation

/* rlbuf.c */
#include <errno.h>
#include <unistd.h>
#include "rlbuf.h"

void initRlBuf(int fd, RlBuf *rlbuf)
{
    rlbuf->fd      = fd;
    rlbuf->buf_len = 0;
    rlbuf->buf_pos = 0;
}

ssize_t readLineBuf(RlBuf *rlbuf, char *line, size_t maxlen)
{
    ssize_t total = 0;
    char    c;

    for (;;) {
        if (rlbuf->buf_pos >= rlbuf->buf_len) {
            ssize_t n;
            do {
                n = read(rlbuf->fd, rlbuf->buf, RLBUF_SIZE);
            } while (n == -1 && errno == EINTR);

            if (n == -1)
                return -1;
            if (n == 0) {
                if (total == 0) return 0;
                break;
            }
            rlbuf->buf_len = (int)n;
            rlbuf->buf_pos = 0;
        }

        c = rlbuf->buf[rlbuf->buf_pos++];

        if (total < (ssize_t)(maxlen - 1))
            line[total++] = c;

        if (c == '\n')
            break;
    }

    line[total] = '\0';
    return total;
}

echo_server.c

/* echo_server.c — uses buffered readline */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include "rlbuf.h"

#define PORT   54321
#define MAXLINE 1024

int main(void)
{
    int listenfd, connfd;
    struct sockaddr_in servaddr;
    RlBuf rlbuf;
    char  line[MAXLINE];
    ssize_t n;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1) { perror("socket"); exit(EXIT_FAILURE); }

    /* Allow port reuse during testing */
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(PORT);

    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        perror("bind"); exit(EXIT_FAILURE);
    }

    if (listen(listenfd, 5) == -1) { perror("listen"); exit(EXIT_FAILURE); }

    printf("Server listening on port %d\n", PORT);

    for (;;) {
        connfd = accept(listenfd, NULL, NULL);
        if (connfd == -1) { perror("accept"); continue; }

        printf("Client connected\n");

        /* Initialize the buffered reader for this connection */
        initRlBuf(connfd, &rlbuf);

        /* Read lines and echo them back */
        while ((n = readLineBuf(&rlbuf, line, sizeof(line))) > 0) {
            printf("Received: %s", line);

            /* Echo the line back */
            if (write(connfd, line, n) != n) {
                perror("write"); break;
            }
        }

        printf("Client disconnected\n");
        close(connfd);
    }

    return 0;
}

echo_client.c

/* echo_client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "rlbuf.h"

#define PORT   54321
#define MAXLINE 1024

int main(void)
{
    int sockfd;
    struct sockaddr_in servaddr;
    RlBuf rlbuf;
    char  send_line[MAXLINE];
    char  recv_line[MAXLINE];

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) { perror("socket"); exit(EXIT_FAILURE); }

    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port   = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);

    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
        perror("connect"); exit(EXIT_FAILURE);
    }

    initRlBuf(sockfd, &rlbuf);   /* Initialize buffered reader */

    while (fgets(send_line, sizeof(send_line), stdin) != NULL) {
        size_t len = strlen(send_line);

        /* Send the line to server */
        if (write(sockfd, send_line, len) != (ssize_t)len) {
            perror("write"); break;
        }

        /* Read echoed line back using buffered readline */
        if (readLineBuf(&rlbuf, recv_line, sizeof(recv_line)) <= 0)
            break;

        printf("Echo: %s", recv_line);
    }

    close(sockfd);
    return 0;
}

Build and Test

# Compile
gcc -Wall -o echo_server echo_server.c rlbuf.c
gcc -Wall -o echo_client echo_client.c rlbuf.c

# Terminal 1 — run server
./echo_server

# Terminal 2 — run client, type lines
./echo_client
Hello World
Echo: Hello World
Embedded Programming
Echo: Embedded Programming

Important Edge Cases

⚠ Line Too Long
If the line is longer than maxlen-1, characters are silently dropped. The loop keeps consuming bytes until it hits \n, but stops writing to line. The caller gets a truncated line. You should either increase maxlen or detect truncation and return an error.
✓ EINTR Handling
When a signal interrupts read(), it returns -1 with errno == EINTR. The implementation handles this by retrying the read() call. This is essential for robust server code.
ℹ EOF Handling
If the connection closes mid-line (no newline before EOF), readLineBuf() returns whatever it collected so far as a complete line (without a trailing newline). If the connection closes before any data arrives, it returns 0. Your caller must handle both cases.
⚡ One RlBuf Per FD
Never share one RlBuf between two file descriptors. The buffer stores data from a specific fd. Mixing them will corrupt your data. In a multi-client server, each accepted connection gets its own RlBuf.

Performance: Naive vs Buffered

Approach System Calls for 200-byte line Throughput Complexity
Naive (1 byte/call) 200 calls Very low Simple
Buffered (4096 bytes/call) 1 call (usually) High Moderate

The buffered approach reduces system calls by up to 200x for typical lines. In a production server reading millions of lines per day, this difference is enormous.

Interview Questions & Answers

Q1. Why can’t you just call read() until you get a ‘\n’ without buffering?

You can, but each read() for a single byte is a system call — a context switch to the kernel and back. For a 200-byte line you’d make 200 system calls. With buffering, you read 4096 bytes in one system call and serve subsequent bytes from user-space memory. This is 200x cheaper for typical lines.

Q2. What does buf_pos track in the RlBuf structure?

buf_pos is the index of the next unread byte in the buffer. After reading a byte, we increment buf_pos. When buf_pos == buf_len, the buffer is exhausted and we must call read() again to refill it.

Q3. What happens if a TCP segment boundary falls in the middle of a line?

Nothing special happens from the application’s perspective. TCP reassembles the byte stream transparently. Our buffered reader just calls read() again when the buffer empties — it doesn’t care about segment boundaries. This is one of the key advantages of TCP’s byte-stream model.

Q4. Why does readLineBuf() need to handle EINTR?

If a signal arrives while read() is blocking, the kernel interrupts the system call and returns -1 with errno == EINTR. This is not a real error — it just means “please retry”. Without EINTR handling, a signal (like SIGCHLD when a child exits) would falsely look like a read error and break your connection.

Q5. Can you use the same RlBuf for two different file descriptors?

No. The buffer belongs to one specific fd. If you mixed two fds, data from one connection would be returned when reading from the other. Each connection needs its own RlBuf instance.

Q6. What is the difference between line-oriented and record-oriented protocols?

Line-oriented protocols (HTTP/1.x headers, SMTP, FTP commands) use newline characters as message delimiters. Record-oriented protocols use a fixed or length-prefixed binary structure. Line-oriented is simpler to debug with telnet but requires a buffered readline layer. Record-oriented is more efficient for binary data.

Q7. What does readLineBuf() return when the peer closes the connection?

If the peer closes with no pending data: returns 0 (indicating EOF). If there is a partial line in the buffer when the peer closes: returns the count of bytes collected so far (without a newline at the end). Your caller must handle both cases appropriately.

Q8. How is this different from stdio’s fgets()?

fgets() works the same way conceptually — it has an internal buffer and only calls the underlying read() when the buffer is empty. However, fgets() uses FILE* streams which have some overhead and limitations. Our RlBuf approach works directly with file descriptors, which is necessary for sockets, and gives us full control over buffer sizes and error handling behavior.

Leave a Reply

Your email address will not be published. Required fields are marked *