06 · Networking Basics¶
Everything up to this point has run inside one process. Networking is what
lets two separate processes — potentially on different machines — exchange
bytes over a connection. The standard library has no networking API of its
own (that arrives with C++23's std::net in some implementations, but isn't
yet universal), so this module uses the POSIX sockets API — the C
interface that underlies almost every networking library on Linux and macOS.
On Windows the same concepts apply through Winsock, with WSAStartup()
required first and header names differing; the ideas below transfer
directly even though the exact function names don't.
The core idea: a socket is a file descriptor for a connection¶
A socket is created, configured, and used through a small, consistent set of
calls: socket() to create it, then either connect() (client) or
bind()+listen()+accept() (server), then send()/recv() to exchange
bytes, then close() — the RAII lesson from
Module 4 applies directly here: a raw file
descriptor is exactly the kind of non-RAII resource worth wrapping in a
class that closes it in its destructor.
#include <iostream>
#include <unistd.h>
// A minimal RAII wrapper around a POSIX socket file descriptor.
class Socket {
public:
explicit Socket(int fd) : fd_(fd) {}
~Socket() { if (fd_ >= 0) ::close(fd_); }
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
Socket(Socket&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
int get() const { return fd_; }
private:
int fd_;
};
int main() {
std::cout << "Socket is just an int file descriptor under the hood." << std::endl;
}
// Socket is just an int file descriptor under the hood.
A TCP echo server¶
The server side: create a socket, bind it to a port, listen for incoming connections, accept one, and echo back whatever it reads.
#include <iostream>
#include <cstring>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
int runEchoServer(int port) {
int serverFd = socket(AF_INET, SOCK_STREAM, 0); // IPv4, TCP
if (serverFd < 0) { std::cerr << "socket() failed" << std::endl; return 1; }
int yes = 1;
setsockopt(serverFd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // listen on all local interfaces
address.sin_port = htons(port); // host-to-network byte order
if (bind(serverFd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) < 0) {
std::cerr << "bind() failed" << std::endl;
return 1;
}
listen(serverFd, /*backlog=*/1);
std::cout << "server listening on port " << port << std::endl;
sockaddr_in clientAddr{};
socklen_t clientLen = sizeof(clientAddr);
int clientFd = accept(serverFd, reinterpret_cast<sockaddr*>(&clientAddr), &clientLen);
std::cout << "server accepted a connection" << std::endl;
char buffer[1024];
ssize_t bytesRead = read(clientFd, buffer, sizeof(buffer) - 1);
if (bytesRead > 0) {
buffer[bytesRead] = '\0';
std::cout << "server received: " << buffer << std::endl;
write(clientFd, buffer, bytesRead); // echo it straight back
}
close(clientFd);
close(serverFd);
return 0;
}
Note htons(port) — "host to network short." Network byte order is
big-endian regardless of the machine's own endianness, so any multi-byte
value going onto the wire (a port number, an IPv4 address) must be converted
with htons/htonl when sending and ntohs/ntohl when receiving. Skipping
this is a classic bug that only shows up cross-platform, since a little-
endian machine talking to itself never notices the mismatch.
A TCP client¶
The client side connects to that address and port, sends a message, and reads the echoed reply:
#include <iostream>
#include <cstring>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
int runEchoClient(int port) {
int sockFd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); // loopback: same machine
if (connect(sockFd, reinterpret_cast<sockaddr*>(&serverAddr), sizeof(serverAddr)) < 0) {
std::cerr << "connect() failed" << std::endl;
return 1;
}
const char* message = "hello from client";
write(sockFd, message, strlen(message));
char buffer[1024];
ssize_t bytesRead = read(sockFd, buffer, sizeof(buffer) - 1);
if (bytesRead > 0) {
buffer[bytesRead] = '\0';
std::cout << "client received echo: " << buffer << std::endl;
}
close(sockFd);
return 0;
}
Running the server in one process (or background thread) and the client in
another against 127.0.0.1 (the loopback address — "this same machine")
produces:
server listening on port 5555
server accepted a connection
server received: hello from client
client received echo: hello from client
connect() performs the three-way TCP handshake before returning, so by the
time write() runs, the server is guaranteed to already be past accept()
— but starting the server before the client tries to connect is still the
caller's responsibility; connect() fails immediately with "connection
refused" if nothing is listening yet.
Cheat sheet¶
| Call | Role | Notes |
|---|---|---|
socket() |
Create an endpoint | AF_INET + SOCK_STREAM = IPv4 TCP |
bind() |
Server: attach to a local port | Needed before listen() |
listen() |
Server: start accepting connections | Second arg is the pending-connection backlog |
accept() |
Server: block until a client connects | Returns a new fd for that client |
connect() |
Client: initiate a connection | Blocks until the handshake completes or fails |
send()/write() |
Send bytes | May send fewer bytes than requested — check the return value |
recv()/read() |
Receive bytes | Returns 0 on a graceful close, negative on error |
htons/htonl, ntohs/ntohl |
Byte-order conversion | Required for any multi-byte value on the wire |
close() |
Release the socket | Wrap in an RAII type, as shown above |
Traps¶
TCP is a byte stream, not a message stream. A single write() on one
side is not guaranteed to arrive as a single read() on the other — a large
message can be split across multiple reads, and small messages can be
coalesced into one. Real protocols prefix each message with a fixed-size
length field (or use a delimiter) so the reader knows where one message ends
and the next begins; this echo example works only because both sides do one
write and one read and stop.
Forgetting htons/htonl silently works on same-endianness round trips
(a Mac talking to itself) and silently breaks talking to a big-endian
device or a value inspected by a packet sniffer on the wire — always convert
multi-byte fields, even when testing locally makes it look unnecessary.
Leaking file descriptors. Every socket()/accept() call that succeeds
must eventually reach a matching close() on every code path, including
error returns — exactly the RAII problem from Module 4, and exactly why the
Socket wrapper above exists.
How It Actually Works¶
A socket is a handle the OS kernel gives your process — mechanically
the same kind of file-descriptor abstraction as an open file (Level 2's
file I/O module), which is why socket I/O uses the same read/write-style
syscalls under the hood. Creating a TCP socket and calling connect()
triggers the kernel's networking stack to perform an actual three-way
handshake (SYN, SYN-ACK, ACK) at the TCP protocol level before your
connect() call returns successfully — this is why establishing a
connection has real, measurable latency (at minimum one round trip to the
remote host) even before a single byte of your application data is sent.
TCP presents itself to your program as a reliable, ordered byte stream,
not discrete messages — this is a real architectural fact, not just an API
quirk: the kernel is free to buffer, coalesce, or split your send() calls
into however many underlying IP packets it wants, and a recv() on the
other end can return fewer bytes than were sent in one call, or bytes from
multiple send() calls concatenated together. This is exactly why
application protocols built on TCP need their own framing (a length prefix,
a delimiter) — the transport layer guarantees byte order and delivery, but
has no concept of "where one message ends and the next begins."
Blocking socket calls (recv() with no data available) put the calling
thread to sleep at the kernel level, identical in mechanism to a mutex
lock() waiting on a contended lock (Module 3) — the kernel removes the
thread from the CPU run queue and wakes it via an interrupt once data
arrives on the socket's receive buffer, which is why a naive blocking
server can only handle one connection per thread without either spawning a
thread per client or using non-blocking/async I/O.
Exercise¶
Turn the Socket class above into a complete RAII wrapper with bind(),
listen(), accept() (returning another Socket), connect(), send(),
and recv() member functions that wrap the raw POSIX calls and throw
std::runtime_error on failure. Then extend the echo server to loop —
accept() and handle client connections repeatedly instead of exiting after
one — and add a length-prefixed framing scheme (send a 4-byte big-endian
length before the payload, and have the reader loop on read() until it has
that many bytes) so a message longer than one read() call's buffer arrives
intact.