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

4 november 2026 | https://www.ribomation.se/blog/2026/chattserver-boost-asio-del-1/

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.

```cpp
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:
> 
> ```text
> 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).

```cpp
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.

```text
$ ./build/release/echoserver/echoserver
echo-server lyssnar på port 7000
ansluten: 127.0.0.1:58120
nedkopplad: 127.0.0.1:58120
```

```text
$ 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:

```cpp
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:

```cpp
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.

```text
$ ./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()`:

```cpp
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`:

```text
$ 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:

```text
$ 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:

```text
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ö.

```text
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`:

```cpp
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_:

```cpp
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:

```cpp
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:

```cpp
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:

```cpp
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:

```cpp
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:

```text
$ 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
```

```text
$ 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:

```text
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.

```text
$ client-simulator/chat-sim.py --clients 200 --messages 5
```

### Trådar

Medan simulatorn kör, räknar vi trådar i serverprocessen:

```text
$ 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.
