Sökresultat för

Bygg en chattserver med Boost.Asio, del 1 - Grunderna och synkron I/O

19 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Bygg en chattserver med Boost.Asio, del 1 - Grunderna och synkron I/O

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änder boost::system::error_code. Fristående Asio är samma kod utan Boost-beroenden, i namnrymden asio och med std::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 aliaset namespace 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_LOCATION definierad blir det bara connect: 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::streambuf i stället för dynamic_buffer, kombinerat med en std::istream och std::getline() för att plocka ut raden. Det fungerar fortfarande, men dynamic_buffer ovanpå en vanlig std::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.