Den här artikeln handlar om Boost.Asio, biblioteket för nätverk och asynkron I/O i C++, och jag
fokuserar på den enklaste formen, nämligen synkrona anrop. Jag går igenom den mentala modellen som all Asio-kod
bygger på: io_context, I/O-objekt, executors, buffertar och de två sätten att hantera fel. Sedan skriver vi
fyra små program: en echo-server, en daytime-server med tillhörande klient, och två versioner av en chattserver.
Den första versionen klarar en klient i taget, och den andra löser det med en tråd per klient. Den fungerar, men
vi ska se exakt vad den kostar i systemresurser, för det är den kostnaden som motiverar den asynkrona modellen i
nästa artikel.
Jag förutsätter att du kan grunderna i nätverksprogrammering (sockets, TCP, portar) och att du har sett trådar och mutex i C++ tidigare, eller i alla fall har ett hum om dessa ting.
Det här är min tredje artikel om Boost. I Kom igång med Boost gick jag igenom installation, CMake och skillnaden mellan header-only och kompilerade bibliotek, och i Väderdata med Boost hämtade vi ett års väderdata över HTTPS med Boost.Beast. Beast sköter HTTP, men all nätverkstrafik går genom Asio. Nu öppnar vi motorhuven. Miljön är densamma som tidigare: Boost 1.92, GCC 16.x med C++26, CMake och Ninja. Alla program finns i ett eget GitHub-repo, se länkarna sist i artikeln. Artikeln visar de rader som bär poängen, och repot har resten.
Artikeln är också den första av fem. Målet med serien är att visa Asio från grunden till produktionsnära användning, och det gör vi genom att bygga på ett och samma program hela vägen: en chattserver. Den börjar med ett blygsamt antal kodrader, som ekar text till en enda klient, och slutar med TLS, UDP-discovery, graceful shutdown och ett admin-UI i webbläsaren som pratar WebSocket med servern. Varje del tar ett steg i den riktningen, och kan läsas för sig.
| Del | Innehåll | Chattservern |
|---|---|---|
| 1 | Grunderna och synkron I/O (denna artikel) | En klient i taget, sedan en tråd per klient |
| 2 | Asynkront med callbacks | En tråd utan lås, sedan N trådar med strands |
| 3 | Asynkront med coroutines | Samma server med co_await, plus en egen C++-klient |
| 4 | TLS, UDP och graceful shutdown | Krypterad trafik, servern hittas utan adress, snygg nedstängning |
| 5 | Admin-UI med Boost.Beast och WebSocket | HTTPS och WSS, klientlista och statistik live i webbläsaren |
Vad är Asio?
Asio är ett bibliotek för asynkron I/O, skrivet av Christopher Kohlhoff och med i Boost sedan version 1.35 år 2008.
Namnet är en förkortning av asynchronous input/output, och det säger vad biblioteket är: ett tunt, portabelt
lager ovanpå operativsystemets mekanismer för att vänta på många I/O-händelser samtidigt. På Linux är det epoll,
på macOS och BSD kqueue, på Windows I/O Completion Ports, och på nyare Linux-kärnor kan man välja io_uring.
Du skriver samma kod oavsett, och Asio väljer backend vid kompilering.
Asio är också det bibliotek som legat till grund för C++-kommitténs försök att få in nätverk i standarden. Det
blev en teknisk specifikation, Networking TS, som i princip är Asio med std:: framför. Den kom aldrig in i
C++20, inte i C++23, och inte heller i C++26. I stället fick C++26 ett generellt ramverk för asynkrona
operationer, std::execution, byggt på idén om senders och receivers. Tanken är att nätverk ska byggas
ovanpå det senare, men någon tidsplan finns inte. Så i praktiken: vill du ha portabel asynkron I/O i C++ idag, så
är det Asio som gäller, och så lär det förbli ett bra tag till.
Asio finns i två utgåvor. Boost.Asio är den som följer med Boost, i namnrymden
boost::asio, och använderboost::system::error_code. Fristående Asio är samma kod utan Boost-beroenden, i namnrymdenasiooch medstd::error_code. De släpps i takt och skiljer sig bara i namnrymder och feltyper. I den här serien använder jag Boost-varianten, eftersom Beast i del 5 kräver den. Med aliasetnamespace asio = boost::asio;ser koden i stort sett likadan ut i båda.
Centrala begrepp
Fyra begrepp räcker för att läsa Asio-kod: io_context, I/O-objekt, executors och buffertar. Resten är
variationer på dem.
io_context är navet. Det är den klass som äger kontakten med operativsystemets I/O-mekanism och som
kör de callbacks (completion handlers) som asynkrona operationer resulterar i. Metoden run() är själva
eventloopen. I den här artikeln kommer vi aldrig att anropa run(), eftersom synkrona operationer blockerar
tråden själva och inte behöver någon loop. Men objektet måste ändå finnas, för alla I/O-objekt skapas med en
referens till det.
I/O-objekt är de klasser som representerar något man kan läsa från eller skriva till: tcp::socket,
tcp::acceptor, tcp::resolver, udp::socket, steady_timer, signal_set, serial_port och så vidare.
Varje sådant objekt skapas med en io_context (eller en executor) som första argument, och det är den
bindningen som avgör var dess asynkrona operationer kommer att köras. Namnen följer ett mönster: read_some()
är en synkron operation, async_read_some() är dess asynkrona tvilling, och samma mönster gäller alla operationer.
Executors är det som binder ihop de två. En executor är ett lättviktigt handtag som beskriver var en
funktion ska köras. io_context har en, och det är den som I/O-objekten lagrar. I del 2 blir executors viktiga,
när vi kör run() i flera trådar och behöver strands för att serialisera åtkomsten till en session. I
den här delen räcker det att veta att de finns.
Buffertar är den sista biten, och den som brukar förvåna. Asio har ingen egen buffertklass som man måste
använda. I stället finns asio::buffer(), som tar nästan vad som helst som har en adress och en längd
(std::array, std::vector, std::string, en rå pekare plus storlek) och returnerar en liten vy av typen
mutable_buffer eller const_buffer. Vyn äger inget, så det är ditt ansvar att minnet lever tills operationen
är klar. Synkront är det trivialt. Asynkront är det huvudvärken i del 2.
Utöver vyn finns asio::dynamic_buffer(str), som låter en std::string eller std::vector växa vid behov.
Den behövs när man inte vet hur mycket data som kommer, som när man läser en rad text.
Till sist en distinktion som avgör hur mycket data du faktiskt får. Medlemsfunktionerna read_some() och
write_some() på en socket gör ett systemanrop och returnerar så fort något har lästs eller skrivits. Det kan vara färre byte
än du bad om. De fria funktionerna asio::read(), asio::write() och asio::read_until() är så kallade
composed operations som loopar tills hela bufferten är fylld, hela bufferten är skickad eller avgränsaren har
hittats. I praktiken använder man nästan alltid de fria funktionerna, med read_some() som undantag när man
faktiskt vill ha det som råkar finnas.
Felhantering
Varje operation i Asio finns i två överlagringar. Den ena kastar boost::system::system_error vid fel. Den andra
tar en boost::system::error_code& som sista argument och fyller i den i stället för att kasta.
auto n = sock.read_some(asio::buffer(buf)); // kastar vid fel
auto ec = boost::system::error_code{};
auto n = sock.read_some(asio::buffer(buf), ec); // sätter ec vid fel
if (ec == asio::error::eof) { /* klienten stängde */ }
Min tumregel genom hela serien: exceptions som default, och error_code där ett "fel" är normalt flöde. Det
tydligaste exemplet är eof. Att klienten kopplar ned är inte något som gått snett, utan den förväntade
avslutningen på ett samtal, och det förtjänar en if-sats i stället för ett catch-block. Felet
connection refused när ingen server lyssnar är däremot ett riktigt fel, och det får gärna bubbla upp till ett
try/catch i main.
Ett felmeddelande från Asio ser som default ut så här i sin helhet:
fel: connect: Connection refused [system:111 at /home/jensr/Libs/Boost/latest/include/boost/asio/detail/reactive_socket_service.hpp:590:5 in function 'boost::system::error_code boost::asio::detail::reactive_socket_service::connect(implementation_type&, const endpoint_type&, boost::system::error_code&)'] Sökvägen, radnumret och funktionen pekar in i Asios egna headers, och är sällan något du vill visa en användare. Med
BOOST_ASIO_DISABLE_ERROR_LOCATIONdefinierad blir det baraconnect: Connection refused [system:111]. I demoprogrammen sätter jag den i CMake-filen.
Synkront eller asynkront
Hela serien kretsar kring skillnaden mellan de två sätten att göra I/O, så här är den i en tabell.
| Synkront | Asynkront | |
|---|---|---|
| Anrop | sock.read_some(buf) |
sock.async_read_some(buf, handler) |
| Returnerar | när operationen är klar | omedelbart |
| Resultatet | returvärde eller exception | levereras till handler senare |
| Vem kör koden efteråt | den anropande tråden | tråden som kör io_context::run() |
| Tråd per anslutning | ja, i praktiken | nej, en tråd räcker för tusentals |
| Timeout och avbrott | besvärligt | inbyggt (timers, cancellation) |
| Svårighet | lätt att skriva och läsa | livslängd och ägarskap kräver omsorg |
Synkront betyder att tråden står stilla i anropet tills data finns. Det är precis som read() i C, bara med
bättre typer. Asynkront betyder att anropet bara startar operationen och sedan returnerar. När data finns kör
Asio din handler, och det sker inne i io_context::run(). Samma I/O-objekt, samma buffertar, samma
felkoder. Skillnaden är var och när koden efter operationen körs.
Vad Asio täcker, och inte
Asio är ett transportlager. Det ger dig sockets, timers och ett par andra saker som operativsystemet kan vänta på, men inga applikationsprotokoll.
| Asio har | Asio har inte |
|---|---|
TCP, UDP och ICMP (ip::tcp, ip::udp, ip::icmp) |
HTTP och WebSocket, som finns i Boost.Beast |
Unix domain sockets (local::stream_protocol) |
HTTP/2 och HTTP/3 |
TLS via OpenSSL (asio::ssl) |
FTP, SMTP, DNS utöver vanlig namnuppslagning |
Namnuppslagning (ip::tcp::resolver) |
Färdiga klienter för HTTP, databaser eller meddelandeköer |
Signaler (signal_set) |
|
| Timers (steady_timer, system_timer) | |
Serieportar (serial_port) |
|
Pipes och filer (readable_pipe, stream_file, random_access_file) |
|
C++20-coroutines (awaitable, co_spawn) |
Behöver du HTTP, så bygger du det ovanpå Asio, eller tar Beast som redan gjort det. Det är precis vad som händer i del 5.
Echo-server
Det enklaste tänkbara serverprogrammet: lyssna på en port, ta emot en klient, och skicka tillbaka allt klienten
skickar tills den kopplar ned. Klienten är nc (netcat).
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
void echo(tcp::socket& sock) {
auto buf = std::array<char, 1024>{};
for (;;) {
auto ec = boost::system::error_code{};
auto n = sock.read_some(asio::buffer(buf), ec);
if (ec == asio::error::eof) return;
if (ec) throw boost::system::system_error{ec};
asio::write(sock, asio::buffer(buf, n));
}
}
int main(int argc, char** argv) try {
auto port = static_cast<unsigned short>(argc > 1 ? std::stoi(argv[1]) : 7000);
auto ioc = asio::io_context{};
auto acceptor = tcp::acceptor{ioc, tcp::endpoint{tcp::v4(), port}};
std::println("echo-server lyssnar på port {}", port);
for (;;) {
auto sock = acceptor.accept();
auto peer = to_string(sock.remote_endpoint());
std::println("ansluten: {}", peer);
try {
echo(sock);
std::println("nedkopplad: {}", peer);
} catch (std::exception const& e) {
std::println("fel mot {}: {}", peer, e.what());
}
}
} catch (std::exception const& e) {
std::println(stderr, "fel: {}", e.what());
return 1;
}
Här finns alla de fyra begreppen. ioc är vår io_context. acceptor är ett I/O-objekt, som i ett enda
konstruktoranrop öppnar en socket, sätter SO_REUSEADDR, binder till porten och börjar lyssna. accept()
blockerar tills en klient ansluter och returnerar en ny tcp::socket. Och asio::buffer(buf) är vyn över
vår std::array. Hjälpfunktionen to_string() formaterar en endpoint som adress:port, och finns i repot.
Läsningen använder read_some(), för en echo-server vill ha det som råkar komma, inte en viss mängd. Skrivningen
använder asio::write(), som garanterar att alla n byte går iväg även om kärnan bara tar emot en del i taget.
Felhanteringen följer tumregeln: eof är ett returvärde, allt annat kastas.
$ ./build/release/echoserver/echoserver
echo-server lyssnar på port 7000
ansluten: 127.0.0.1:58120
nedkopplad: 127.0.0.1:58120
$ nc localhost 7000
hej asio
hej asio
hallå igen
hallå igen
^C
Varje rad kommer tillbaka direkt. Hela programmet är femtio rader, och det är en fullt fungerande TCP-server. Den här echo-servern är den röda tråden genom serien: i del 2 skrivs den om med callbacks, och i del 3 med coroutines, så att du kan jämföra samma program i tre stilar.
Daytime-server och daytime-klient
Echo-servern hade nc som klient. Nu skriver vi båda sidorna själva, med ett protokoll som är så enkelt att det
knappt förtjänar namnet: daytime enligt RFC 867. Klienten ansluter, servern skriver aktuell tid som en textrad
och stänger. Det ger oss anledning att titta på resolver, endpoints och asio::connect().
Servern är echo-servern men utan den inre loopen:
for (;;) {
auto sock = acceptor.accept();
auto msg = now_as_text();
auto ec = boost::system::error_code{};
asio::write(sock, asio::buffer(msg), ec);
if (ec) {
std::println("fel mot {}: {}", to_string(sock.remote_endpoint()), ec.message());
} else {
std::print("skickade till {}: {}", to_string(sock.remote_endpoint()), msg);
}
// sock stängs här, när den går ur scope. Klienten ser det som eof.
}
Socketen är en lokal variabel i loopen, så den stängs automatiskt vid slutet av varje varv. Ingen explicit
close(), för RAII sköter det. Tidsfunktionen använder std::chrono::zoned_time och std::format, som sedan
C++20 klarar tidszoner utan hjälp av Boost.Date_Time.
Klienten är mer intressant:
auto host = std::string{argc > 1 ? argv[1] : "localhost"};
auto port = std::string{argc > 2 ? argv[2] : "1313"};
auto ioc = asio::io_context{};
auto resolver = tcp::resolver{ioc};
auto endpoints = resolver.resolve(host, port);
auto sock = tcp::socket{ioc};
auto ep = asio::connect(sock, endpoints);
std::println("ansluten till {}", to_string(ep));
auto reply = std::string{};
auto ec = boost::system::error_code{};
asio::read(sock, asio::dynamic_buffer(reply), ec);
if (ec && ec != asio::error::eof) throw boost::system::system_error{ec};
std::print("svar: {}", reply);
resolver.resolve() slår upp värdnamn och port, och returnerar inte en adress utan en lista av endpoints.
För localhost är det typiskt både ::1 och 127.0.0.1. Den fria funktionen asio::connect() provar dem i tur
och ordning tills en lyckas, och returnerar den endpoint som fungerade. Det är därför klienten fungerar mot vår
IPv4-server trots att det första försöket, mot IPv6-adressen, misslyckas tyst.
Läsningen använder asio::read() med en dynamic_buffer, vilket betyder "läs tills det tar slut". Och det tar
slut när servern stänger, det vill säga vid eof. Så här är eof inte bara normalt flöde utan det enda sättet
operationen kan lyckas. Därför filtrerar if-satsen bort just den koden och kastar alla andra.
$ ./build/release/daytime/daytime-client
ansluten till 127.0.0.1:1313
svar: 2026-11-02 14:33:29 CET
$ ./build/release/daytime/daytime-client localhost 1314
fel: connect: Connection refused [system:111]
Det andra anropet visar exception-vägen: ingen lyssnar på port 1314, asio::connect() kastar, och main fångar.
Eftersom servern bara skriver text fungerar även nc localhost 1313 som klient.
Chattserver, steg 1a: en klient i taget
Nu till det återkommande projektet. Protokollet är det enklaste tänkbara, och förblir så genom hela serien: varje rad en klient skickar ska skickas vidare till alla andra anslutna klienter. Inga kommandon, inga användarnamn, inga rum. Allt fokus ska ligga på Asio.
Det första steget är enklare än så. Servern tar emot en klient, läser rad för rad, och skickar tillbaka raden
med prefixet [du] så att det syns att den arbetar. Skillnaden mot echo-servern är att vi nu läser rader,
inte godtyckliga block, och det kräver read_until():
void chat(tcp::socket& sock, std::string const& peer) {
auto buf = std::string{};
for (;;) {
auto ec = boost::system::error_code{};
auto n = asio::read_until(sock, asio::dynamic_buffer(buf), '\n', ec);
if (ec == asio::error::eof) return;
if (ec) throw boost::system::system_error{ec};
// read_until kan ha läst förbi radslutet. Raden är de första n byten,
// resten tillhör nästa rad och ska ligga kvar i buf.
auto line = std::string_view{buf}.substr(0, n);
std::println("{}: {}", peer, strip_eol(line));
asio::write(sock, asio::buffer(std::format("[du] {}", line)));
buf.erase(0, n);
}
}
read_until() läser tills avgränsaren finns i bufferten och returnerar antalet byte fram till och med den. Här
finns en klassisk fallgrop: funktionen kan mycket väl ha läst förbi avgränsaren, om klienten skickade två rader
i samma paket. Bufferten innehåller då början på nästa rad också. Därför tar vi bara de n första byten som
raden, och raderar bara dem. Raderar man hela strängen, så försvinner början av nästa rad, och det är ett fel som
bara visar sig under last.
I äldre exempel ser man ofta
asio::streambufi stället fördynamic_buffer, kombinerat med enstd::istreamochstd::getline()för att plocka ut raden. Det fungerar fortfarande, mendynamic_bufferovanpå en vanligstd::stringär enklare, och det är den modell som Asios asynkrona operationer och coroutine-stöd är byggda kring.
Huvudprogrammet är identiskt med echo-serverns, bara med chat() i stället för echo(). Och det är där
problemet sitter. Starta servern och anslut med nc:
$ nc localhost 4711
hej
[du] hej
någon där?
[du] någon där?
Öppna sedan en andra terminal och anslut med en till nc. Skriv något. Ingenting händer. Serverns tråd står i
read_until() för den första klienten, och den andra ligger i kärnans accept-kö och väntar. Först när den första
kopplar ned kommer accept() tillbaka, och då får den andra sitt svar:
$ nc localhost 4711
jag vill också vara med
# väntar ... tills den första klienten avslutar
[du] jag vill också vara med
Serverloggen visar förloppet tydligt. Den andra klienten "ansluter" först när den första är borta:
chattserver (en klient i taget) lyssnar på port 4711
ansluten: 127.0.0.1:46882
127.0.0.1:46882: hej
127.0.0.1:46882: någon där?
nedkopplad: 127.0.0.1:46882
ansluten: 127.0.0.1:46896
127.0.0.1:46896: jag vill också vara med
nedkopplad: 127.0.0.1:46896
En chatt med en deltagare i taget är inte mycket till chatt. Synkrona anrop blockerar tråden, och vi har bara en. Den klassiska lösningen är att skaffa fler.
Chattserver, steg 1b: en tråd per klient
Det här är designen varje Java-programmerare har skrivit minst en gång: en läs-tråd per ansluten klient, och en broadcast-tråd som delar ut meddelandena. De två möts i en trådsäker kö.
läs-tråd (en per klient): read_until en rad --> push Message i kön
broadcast-tråd (en): pop Message --> asio::write till alla utom avsändaren
Tre små byggstenar behövs. Först en session, som är en socket plus ett namn, ägd via shared_ptr:
struct Session {
tcp::socket socket;
std::string name;
explicit Session(tcp::socket sock)
: socket{std::move(sock)}, name{to_string(socket.remote_endpoint())} {}
};
using SessionPtr = std::shared_ptr<Session>;
Sedan en lista över sessionerna, skyddad av en mutex. Det viktiga är snapshot(), som returnerar en kopia:
class ClientList {
public:
void add(SessionPtr session);
void remove(SessionPtr const& session);
auto snapshot() -> std::vector<SessionPtr>; // kopia, så att låset inte hålls under asio::write
auto size() -> std::size_t;
private:
std::vector<SessionPtr> sessions;
std::mutex mtx;
};
Och till sist kön. Här visar jag bara gränssnittet, och implementationen, som är en std::deque plus en mutex
och en std::condition_variable_any, finns i repot:
template <typename T>
class BlockingQueue {
public:
void push(T item);
auto pop(std::stop_token stop) -> std::optional<T>; // nullopt när stopp begärts
...
};
Det intressanta i gränssnittet är std::stop_token. Broadcast-tråden är en std::jthread, och när den
destrueras begär den stopp via sin stop_source. Kön väntar i cv.wait(lock, stop, predicate), som är den
överlagring av wait() som avbryts när stoppet begärs. Då returnerar pop() ett tomt optional, och
broadcast-loopen avslutas. Utan det hade tråden hängt i wait() för evigt och main aldrig kunnat avslutas
snyggt. Observera att det kräver condition_variable_any, inte den vanliga condition_variable.
Med byggstenarna på plats är de två trådfunktionerna korta. Läs-tråden är chat() från steg 1a, fast
den lägger raden i kön i stället för att skriva tillbaka den:
void reader(SessionPtr session, ClientList& clients, BlockingQueue<Message>& queue) {
auto buf = std::string{};
for (;;) {
auto ec = boost::system::error_code{};
auto n = asio::read_until(session->socket, asio::dynamic_buffer(buf), '\n', ec);
if (ec) break; // eof eller annat fel: klienten är borta, oavsett vilket
auto line = strip_eol(std::string_view{buf}.substr(0, n));
queue.push({session, std::format("[{}] {}\n", session->name, line)});
buf.erase(0, n);
}
clients.remove(session);
queue.push({session, std::format("*** {} kopplade ned\n", session->name)});
std::println("nedkopplad: {} ({} kvar)", session->name, clients.size());
}
Broadcast-tråden tar ett meddelande i taget, tar en kopia av klientlistan, och skriver till alla utom avsändaren:
void broadcaster(std::stop_token stop, ClientList& clients, BlockingQueue<Message>& queue) {
while (auto msg = queue.pop(stop)) {
auto others = clients.snapshot()
| std::views::filter([&](auto const& s) { return s != msg->sender; });
for (auto const& s : others) {
auto ec = boost::system::error_code{};
asio::write(s->socket, asio::buffer(msg->text), ec);
}
}
}
Skrivfel ignoreras avsiktligt. Om en klient försvunnit så upptäcker dess egen läs-tråd det, och plockar bort den ur listan. Broadcast-tråden behöver inte bry sig.
Och så main, som startar broadcast-tråden, och sedan en läs-tråd per accepterad klient:
auto clients = ClientList{};
auto queue = BlockingQueue<Message>{};
auto broadcast_thread = std::jthread{broadcaster, std::ref(clients), std::ref(queue)};
auto ioc = asio::io_context{};
auto acceptor = tcp::acceptor{ioc, tcp::endpoint{tcp::v4(), port}};
for (;;) {
auto session = std::make_shared<Session>(acceptor.accept());
clients.add(session);
queue.push({session, std::format("*** {} anslöt\n", session->name)});
std::thread{reader, session, std::ref(clients), std::ref(queue)}.detach();
}
Notera att jthread skickar in stop_token som första argument till broadcaster av sig själv. Det är en av
de små finesser som gör jthread trevlig. Läs-trådarna däremot är vanliga std::thread med detach(). En
jthread hade behövt sparas någonstans för att joinas, och en lista med trådar som dött skulle växa tills någon
städade. Läs-tråden städar efter sig själv, så ingen behöver vänta in den.
Varför det här är korrekt
Två saker brukar oroa den som ser koden första gången. Den första är att läs-tråden läser och broadcast-tråden
skriver på samma socket, samtidigt. Asios dokumentation för basic_stream_socket säger "Distinct objects:
Safe. Shared objects: Unsafe", och det låter som ett förbud. Men läser man vidare står det också att de
synkrona operationerna send, receive, connect och shutdown är trådsäkra i förhållande till varandra,
förutsatt att operativsystemets anrop är det, vilket de är på Linux, macOS och Windows. Så en tråd som
läser och en annan som skriver på samma socket är helt i sin ordning. Det som inte är tillåtet är att två trådar
skriver samtidigt (meddelandena skulle flätas ihop) eller att någon anropar close() medan en annan tråd
använder socketen.
Den andra oron är just close(). När läs-tråden ser eof och tar bort sessionen ur listan, vad händer om
broadcast-tråden samtidigt håller på att skriva till den? Svaret är shared_ptr. Sessionen ägs av läs-tråden,
av klientlistan, och tillfälligt av broadcast-trådens kopia. Socketen stängs i Session-destruktorn, det vill
säga när den sista ägaren släpper. Så länge broadcast-tråden håller sin kopia lever socketen, och close()
kan aldrig krocka med en skrivning. Kopian i snapshot() har alltså två jobb: den gör att låset inte hålls medan
asio::write() blockerar, och den håller sessionerna vid liv under hela broadcasten.
Nu chattar det
Två nc i varsin terminal:
$ nc localhost 4711
*** 127.0.0.1:60260 anslöt
hej alla
[127.0.0.1:60260] hej själv
hallå?
*** 127.0.0.1:60260 kopplade ned
$ nc localhost 4711
[127.0.0.1:60244] hej alla
hej själv
[127.0.0.1:60244] hallå?
^C
Varje rad från den ena dyker upp hos den andra, med avsändaren som prefix. Servern loggar anslutningar och nedkopplingar, med antalet klienter:
chattserver (tråd per klient) lyssnar på port 4711
ansluten: 127.0.0.1:60244 (1 totalt)
ansluten: 127.0.0.1:60260 (2 totalt)
nedkopplad: 127.0.0.1:60260 (1 kvar)
nedkopplad: 127.0.0.1:60244 (0 kvar)
Det fungerar. Det är en riktig chattserver, i under tvåhundra rader, och den klarar så många klienter som man orkar öppna terminaler för. Så var är problemet?
Kostnaden
För att se problemet behöver vi fler klienter än terminaler. I repot finns en liten klientsimulator i Python,
chat-sim.py, som startar N klienter som ansluter, skickar några rader med slumpmässiga mellanrum och kopplar
ned. Den läser och slänger allt som kommer in, så att den beter sig som en riktig klient. Jag går inte igenom den,
men den är ett fyrtiotal rader asyncio utan beroenden.
$ client-simulator/chat-sim.py --clients 200 --messages 5
Trådar
Medan simulatorn kör, räknar vi trådar i serverprocessen:
$ ps -T -p $(pgrep -f chatserver-multi-threaded) | tail -n +2 | wc -l
202
Tvåhundra klienter ger 202 trådar: en per klient, en för broadcast och huvudtråden i accept(). Det är
operativsystemstrådar, med varsin stack på typiskt 8 MB virtuellt minne, och varsin plats i kärnans schemaläggare.
Det fysiska minnet är inte så farligt, eftersom stackarna bara används till ett par kilobyte var (hela processen
låg på drygt 6 MB), men varje rad som kommer in väcker en tråd, och varje tråd som vaknar är ett kontextbyte.
Med tusen klienter börjar det märkas, med tio tusen är det ohållbart. Och det värsta är att nästan alla trådar
gör ingenting. De står i read_until() och väntar.
Head-of-line blocking
Det andra problemet är allvarligare, och det ser man inte på ps. asio::write() blockerar tills kärnan tagit
emot all data. Normalt går det fort, för kärnans sändbuffert är tom och klienten läser i andra änden. Men vad
händer om en klient slutar läsa, utan att koppla ned? Ett program som hängt sig, en mobil som tappat täckning,
en nc som någon pausat med Ctrl-Z. Kärnan buffrar ett tag, men när både klientens mottagningsbuffert och
serverns sändbuffert är fulla så blockerar write().
Och det är broadcast-tråden som blockerar. Den enda vi har. Alla meddelanden till alla klienter går genom den, så när den fastnar på en klient får ingen annan något heller.
Jag provade det: en klient som ansluter och sedan bara sover, en vanlig nc som läser, och en tredje som
skickar 40 000 rader på 200 tecken så fort den kan. Den läsande klienten fick 3,4 MB, och sedan stod allt stilla.
Inte förrän den sovande klienten kopplade ned lossnade det, och resten av de 8,8 MB kom fram. På loopback krävs det
några MB för att fylla buffertarna, men över ett riktigt nätverk med en långsam mobilklient i andra änden räcker
det med betydligt mindre.
Det här fenomenet kallas head-of-line blocking: ett element först i kön blockerar alla bakom sig, trots att de inte har något med det att göra. Det är samma problem som gjorde att HTTP/2 inte löste allt som det lovade, och en av anledningarna till att HTTP/3 bytte från TCP till QUIC.
Man kan lappa. En skriv-tråd per klient, med en egen kö, så att en långsam klient bara blockerar sin egen tråd.
Men då är vi uppe i 2N + 2 trådar, och har flyttat problemet från broadcast-tråden till minnet, för kön hos den
långsamma klienten växer obegränsat. Timeouts på skrivningar går också, men synkrona anrop har ingen inbyggd
timeout, så det blir ännu en tråd eller ett SO_SNDTIMEO som Asio inte exponerar direkt.
Det är här den synkrona modellen tar slut. Varje blockerande anrop låser en tråd, och antalet trådar är den knappa resursen. Den asynkrona modellen vänder på det: en tråd, som aldrig blockerar, och som i stället får veta när en socket är redo att läsas eller skrivas. Då blir en långsam klient bara en socket som inte är redo, och alla andra fortsätter som vanligt. Det är ämnet för nästa del.
Sammanfattning
Asio är ett tunt lager ovanpå operativsystemets I/O-mekanismer, och biblioteket bakom både Boost.Beast och
C++-kommitténs Networking TS. Fyra begrepp räcker för att läsa koden: io_context är navet, I/O-objekten
(socket, acceptor, resolver, timers) representerar det man kan vänta på, executors säger var handlers körs,
och buffertar är vyer över minne du själv äger. Varje operation finns i en kastande variant och en med
error_code, och tumregeln är exceptions som default och error_code där felet är normalt flöde, som eof.
Synkrona anrop är lätta att skriva, och vi skrev fyra program med dem. Echo-servern visade acceptor, read_some
och asio::write. Daytime-klienten visade resolver, endpoint-listan och asio::connect, och att eof kan vara
det enda sättet en läsning lyckas på. Chattservern i steg 1a visade read_until med dynamic_buffer, fallgropen
med att läsa förbi avgränsaren, och varför en tråd inte räcker. Steg 1b löste det med en tråd per klient och en
broadcast-tråd, med shared_ptr-ägda sessioner och en kö som stoppas via stop_token. Det fungerar, och koden
är korrekt, eftersom Asios synkrona läs- och skrivoperationer är trådsäkra i förhållande till varandra.
Men det kostar. Tvåhundra klienter gav 202 trådar, och en enda klient som slutade läsa stoppade broadcast för alla andra. Det är synkron I/O:s inbyggda begränsning: varje blockerande anrop binder en tråd, och det finns inget sätt att vänta på flera saker samtidigt. I nästa del skriver vi om echo-servern och chattservern med Asios asynkrona operationer och callbacks. En tråd, ingen mutex, och en skrivkö per klient så att den långsamma bara sinkar sig själv.
Länkar
- Källkod till artikeln – alla programmen plus klientsimulatorn
- Boost.Asio – dokumentation (1.92)
- Boost.Asio – trådsäkerhet för basic_stream_socket
- Fristående Asio (utan Boost)
- Networking TS (N4771)
- RFC 867 – Daytime Protocol
- Förra artikeln – Väderdata med Boost
- Kom igång med Boost
- Asynkrona operationer med coroutines
Senaste Artiklarna
-
Bygg en chattserver med Boost.Asio, del 1 - Grunderna och synkron I/O
4 november 2026 -
Väderdata med Boost
15 oktober 2026 -
Kom igång med Boost
2 oktober 2026 -
C++26 ORM via Reflection
29 september 2026 -
C++26 Reflection
18 september 2026 -
C++26 Contracts
11 september 2026 -
Cargo – konvention framför konfiguration
4 september 2026 -
Referenser & pekare i Rust jämfört med C++
28 augusti 2026