Of je nu een gameserver, een chatdienst of een eenvoudige API schrijft, de basis is altijd dezelfde: twee machines die met elkaar praten over het netwerk. Aan de C++-kant is de laagste laag die dit werk doet de c++ socket-API — de Berkeley sockets die sinds de jaren 80 standaard zijn en vandaag de dag nog steeds onder de Linux-kernel draaien. In dit artikel bouwen we vanaf nul een eenvoudige TCP-server, uitsluitend met POSIX-systeemaanroepen en zonder externe bibliotheek, die binnenkomende clients beluistert, hun data leest en een antwoord terugstuurt.
Wat is een socket en waarom is TCP belangrijk?
Een socket is een communicatie-eindpunt dat het besturingssysteem aanbiedt. Vanuit het oogpunt van je programma gedraagt het zich als een file descriptor: je leest ervan en schrijft erin, en de kernel handelt alle netwerkcomplexiteit ertussenin af. Er zijn twee belangrijke transportprotocollen:
- TCP (
SOCK_STREAM): verbindingsgericht, geordend en betrouwbaar. De bytes die je verstuurt komen volledig en in de juiste volgorde aan. Gebruikt overal waar alles moet aankomen — chat, HTTP, inloggen bij een game. - UDP (
SOCK_DGRAM): verbindingsloos, snel maar zonder garanties. Pakketten kunnen verloren gaan of van volgorde wisselen. Geprefereerd waar latentie belangrijker is dan betrouwbaarheid, zoals real-time spelersposities.
We richten ons hier op TCP omdat dit het patroon dat men een "eenvoudige server" noemt het best illustreert. De flow is altijd hetzelfde: socket() → bind() → listen() → accept() → recv()/send() → close().
De headers die je nodig hebt
Socketprogrammeren op Linux vereist een handvol POSIX-headers. Op Windows zou je Winsock gebruiken (winsock2.h en WSAStartup); hier gaan we uit van Linux/macOS.
#include <iostream>
#include <cstring> // memset, strlen
#include <unistd.h> // close, read, write
#include <sys/socket.h> // socket, bind, listen, accept
#include <netinet/in.h> // sockaddr_in, htons
#include <arpa/inet.h> // inet_ntop
De luistersocket aanmaken
De eerste stap is het aanmaken van een eindpunt met de socket()-aanroep. AF_INET selecteert IPv4 en SOCK_STREAM selecteert TCP. De aanroep geeft -1 terug bij mislukking; het controleren van de retourwaarde van elke systeemaanroep is essentieel bij socketprogrammeren.
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
return 1;
}
// Voorkomt de fout "Address already in use" bij het herstarten van de server
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
De optie SO_REUSEADDR is klein maar belangrijk: wanneer je de server stopt en meteen weer start, kan de poort nog in TIME_WAIT staan en mislukt bind(). Deze optie neemt die hindernis weg.
Binden, luisteren en accepteren
Nu binden we de socket aan een IP en poort. We vullen het adres in met een sockaddr_in-structuur. Het meest kritieke punt hier is de bytevolgorde: het poortnummer wordt op het netwerk in big-endian verwacht, dus we converteren het met htons() (host-to-network short). INADDR_ANY betekent "luister op alle netwerkinterfaces van deze machine".
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY; // alle interfaces
addr.sin_port = htons(8080); // poort 8080 in netwerk-bytevolgorde
if (bind(server_fd, (sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind");
return 1;
}
if (listen(server_fd, SOMAXCONN) == -1) { // wachtrij
perror("listen");
return 1;
}
std::cout << "Server luistert op poort 8080...\n";
Het tweede argument van listen() is de grootte van de wachtrij voor openstaande verbindingen; SOMAXCONN gebruikt de grootste redelijke waarde die het systeem toestaat. We kunnen nu binnenkomende clients een voor een verwelkomen met accept(). Deze aanroep blokkeert totdat er een nieuwe verbinding binnenkomt en geeft voor elke verbinding een aparte socketdescriptor terug.
Lezen, schrijven en de lus bouwen
Zodra de verbinding tot stand is gebracht, praten we met de client via recv() en send() (equivalent werken read()/write() ook). De meest gemaakte fout hier is vergeten dat recv() niet zoveel bytes hoeft terug te geven als je hebt gevraagd, maar zoveel als er op dat moment beschikbaar zijn. De retourwaarde is het aantal gelezen bytes; 0 betekent dat de tegenpartij de verbinding heeft gesloten en -1 betekent een fout.
while (true) {
sockaddr_in client{};
socklen_t len = sizeof(client);
int client_fd = accept(server_fd, (sockaddr*)&client, &len);
if (client_fd == -1) { perror("accept"); continue; }
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
std::cout << "Nieuwe verbinding: " << ip << "\n";
char buffer[1024];
ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
buffer[n] = '\0';
std::cout << "Ontvangen: " << buffer;
const char* reply = "Hallo, bericht ontvangen!\n";
send(client_fd, reply, strlen(reply), 0);
}
close(client_fd); // klaar met deze client
}
close(server_fd);
Deze lus bedient één client tegelijk. Na het compileren van het programma (g++ -std=c++17 server.cpp -o server) en het uitvoeren ervan, kun je het vanuit een andere terminal testen door verbinding te maken met nc localhost 8080 of telnet localhost 8080 en een bericht te typen.
Waar je op moet letten in productie
De bovenstaande server is perfect als tutorial, maar op zichzelf is hij niet genoeg voor de echte wereld. Een paar punten om in gedachten te houden:
- Gelijktijdigheid: één lus bedient slechts één client tegelijk. Voor veel clients moet je elke verbinding een thread geven of — als schaalbaardere oplossing — een op
epollgebaseerde event-lus bouwen. - Gedeeltelijke lees-/schrijfacties: net als
recv()verstuurt ooksend()mogelijk niet alle bytes in één keer. Je moet het in een lus opnieuw aanroepen totdat de hele buffer is verzonden. - Signalen: schrijven naar een gesloten socket kan het proces doden met
SIGPIPE; de vlagMSG_NOSIGNALgebruiken bij desend()-aanroep is een goede gewoonte. - Berichtgrenzen: TCP is een bytestroom zonder begrip van "berichten". In je eigen protocol moet je de berichtlengte als prefix versturen of een scheidingsteken gebruiken.
Veelgestelde vragen
Moet ik TCP of UDP gebruiken?
Gebruik TCP wanneer data volledig en in volgorde moet aankomen: login/authenticatie, chat, inventarisbewerkingen. Als het verliezen van een paar pakketten acceptabel is maar lage latentie kritiek (spelersposities, bewegingsupdates), dan past UDP beter. Veel games gebruiken beide samen.
Werkt dezelfde code op Windows?
De logica is hetzelfde, maar Windows heeft de Winsock-bibliotheek nodig: je roept aan het begin WSAStartup() aan, het sockettype is SOCKET in plaats van int, en je gebruikt closesocket() in plaats van close(). Een cross-platform laag schrijven om deze verschillen te abstraheren is een gangbare aanpak.
Waarom geeft recv() minder data terug dan ik verwachtte?
Omdat TCP een stroom is, geen pakketten. De kernel geeft je wat er op dat moment in zijn buffer zit. Om een volledig bericht te lezen, moet je recv() in een lus opnieuw aanroepen totdat je het verwachte aantal bytes hebt bereikt.
Hulp nodig bij low-level netwerkprogrammeren? Als je ondersteuning wilt bij hoogperformante game- en chatservers in C++, maatwerkprotocollen en op epoll gebaseerde architecturen, neem dan contact met me op — laten we jouw project samen op een solide basis bouwen.