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().
no system calls
until buffer empty
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;
}
fill buf
reset pos=0
buf[pos++]
(no syscall)
and return line
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
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.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.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.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
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.
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.
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.
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.
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.
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.
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.
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.
