Skip to content

Multiple TCP Connections with poll in C++

One quiet client should not stop another client from receiving a reply. Build a single-threaded TCP echo server that watches several sockets and works only on connections that are ready.

Before you start

Complete Disconnection and Timeouts. Run these commands on the Pi in two terminals, both in examples/network.

Outcome: complete an echo on one connection while another remains idle, and explain why each connection needs its own buffer, send offset and deadline.

This is a small Linux loopback experiment, not a public server. No root access, extra hardware or firewall changes are needed.

Watch readiness instead of waiting on one client

The first TCP server accepted one connection and stayed inside its receive loop. If that client sent nothing, the server waited there until the receive timeout.

Here, poll() watches the listener and the accepted connections. A quiet connection does not make the server call a blocking receive on it.

sequenceDiagram accTitle: An active client finishes while an idle stream stays open accDescr: The demo opens one idle connection and a second active connection. The server accepts both. The active client sends hello and finishes sending. The server echoes all queued bytes and closes that connection. The idle stream is still open when the echo finishes. participant I as Idle stream participant S as Server event loop participant A as Active stream I->>S: Connect, send nothing S->>S: Accept idle connection A->>S: Connect S->>S: Accept active connection Note over S: poll watches listener and clients A->>S: hello, then shutdown write side S->>S: Read ready active socket S->>A: Echo bytes S-->>A: Close after pending echo is sent Note over A: Read complete echo and EOF Note over I: Still open, still no data

The two streams come from one demo process. They are separate TCP connections with separate descriptors. The diagram shows application operations, not individual TCP packets.

Concurrency is not parallel execution: this server interleaves small operations in one thread. A long computation or a blocking call inside the loop would still delay every client.

Build and observe two connections

cmake --preset native-debug
cmake --build --preset native-debug --parallel 2

Terminal A:

./build/native/debug/tcp_multi_server 9003

It prints listening 127.0.0.1:9003. Within 30 seconds, run in terminal B:

./build/native/debug/tcp_multi_client 9003

Expected client output:

1
2
3
idle connection opened
active reply bytes=5 data=hello
idle connection still open

The client opens the idle stream first, then the active stream. It sends hello only on the second connection, finishes that stream's sending direction, reads the complete echo and checks that the idle stream has not closed. It then closes both descriptors.

The server keeps accepting clients until its 30-second run budget expires, then prints stopped and closes its descriptors. Restart it for another run. If port 9003 is occupied, use another unprivileged port in both commands.

You can also use the earlier echo client against this server:

./build/native/debug/tcp_echo_client 9003 hello

Each connection owns its progress

The server keeps four plain Client records. These records do not wrap socket calls or perform automatic cleanup.

Field Meaning
fd This connection's raw descriptor, or -1 for a free slot.
buffer Up to 256 bytes waiting to be echoed.
used Number of valid bytes in that buffer.
sent Number already accepted by send().
total Bytes received over this connection, capped at 1024.
deadline Three seconds after acceptance, never renewed by progress.

Sharing a single send offset across all clients would mix their state. Storing the buffer on the stack for one receive call would lose unsent bytes when the loop moves to another client.

Switch between POLLIN and POLLOUT

The server uses one small buffer per connection. It pauses reads for that connection while the buffer still has unsent bytes.

flowchart LR accTitle: Keep unsent bytes and change the watched event accDescr: Watch POLLIN when the buffer is empty. A positive receive fills the buffer and changes interest to POLLOUT. Partial sends retain their offset. When all bytes are sent, watch POLLIN again. EOF, a terminal error or a deadline closes that client. R["POLLIN<br/>Buffer empty"] -->|"recv returns bytes"| W["POLLOUT<br/>Unsent bytes remain"] W -->|"Partial send"| W W -->|"Buffer fully sent"| R R -->|"EOF, error or deadline"| X["Close this client"] W -->|"Error or deadline"| X
  • Read readiness: make at most one nonblocking recv() for that client in this pass.
  • Write readiness: send only the remaining used - sent bytes. A short send advances sent; it does not discard the rest.
  • Would-block or interrupted call: keep the connection's state and return to the event loop.
  • EOF or terminal error: close only that connection, leaving the others available.

POLLOUT means some writing can proceed without blocking, not that an entire reply fits. Watching it permanently on idle sockets can wake the loop repeatedly with no useful work.

This small buffer is also a form of backpressure: when it is full of unsent echo bytes, the application stops reading that connection for now. It does not grow an unbounded output queue. Kernel socket buffers are separate.

A peer's write-side EOF does not discard its pending echo. The server finishes sending each buffered chunk before looking for more input, so it sees EOF only after its buffered echo has been submitted. POLLHUP or POLLERR still leads to a nonblocking socket operation rather than assuming all queued data has vanished.

Read the standalone server

Complete file: app/tcp-multi/server.cpp. The listener uses SOCK_NONBLOCK; each Linux accept4() explicitly sets that flag on the accepted descriptor too.

Read the loop in three passes:

  1. Build the pollfd array and calculate the next deadline.
  2. Process existing clients, preserving their unsent bytes.
  3. Accept at most one new connection and place it in a free slot.

Client events are handled before new sockets occupy freed slots. This avoids accidentally applying an old readiness result to a newly accepted descriptor. Each pass makes at most one read or write per existing client; a busy client does not get an unbounded drain loop.

#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <poll.h>
#include <stdexcept>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

using Clock = std::chrono::steady_clock;

// Plain state only: socket calls and cleanup stay in main.
struct Client {
    int fd = -1;
    std::array<char, 256> buffer{};
    std::size_t used = 0;
    std::size_t sent = 0;
    std::size_t total = 0;
    Clock::time_point deadline{};
};

int main(int argc, char** argv) {
    int listener = -1;
    int status = 0;
    std::array<Client, 4> clients{};
    try {
        if (argc < 2 || argc > 3) {
            throw std::invalid_argument(
                "usage: tcp_multi_server port [run_ms]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port > 65535) {
            throw std::invalid_argument("port must be 0..65535");
        }
        int run_ms = 30000;
        if (argc == 3) {
            const std::string_view budget = argv[2];
            const auto [last, invalid] = std::from_chars(
                budget.data(), budget.data() + budget.size(), run_ms);
            if (invalid != std::errc{} ||
                last != budget.data() + budget.size() || run_ms < 300 ||
                run_ms > 30000) {
                throw std::invalid_argument("run_ms must be 300..30000");
            }
        }
        listener =
            ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (listener < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const int reuse = 1;
        if (::setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse,
                         sizeof(reuse)) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "setsockopt");
        }
        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_port = htons(static_cast<std::uint16_t>(port));
        local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::bind(listener, reinterpret_cast<const sockaddr*>(&local),
                   sizeof(local)) < 0 ||
            ::listen(listener, 4) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "bind/listen");
        }
        socklen_t size = sizeof(local);
        if (::getsockname(listener, reinterpret_cast<sockaddr*>(&local),
                          &size) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "getsockname");
        }
        const auto stop_at = Clock::now() + std::chrono::milliseconds(run_ms);
        std::cout << "listening 127.0.0.1:" << ntohs(local.sin_port)
                  << std::endl;
        while (Clock::now() < stop_at) {
            std::array<pollfd, 5> watched{};
            watched[0] = {listener, POLLIN, 0};
            auto next_deadline = stop_at;
            for (std::size_t i = 0; i < clients.size(); ++i) {
                auto& client = clients[i];
                if (client.fd >= 0 && Clock::now() >= client.deadline) {
                    ::close(client.fd);
                    client.fd = -1;
                    std::cout << "expired slot=" << i + 1 << '\n';
                }
                const short events =
                    client.sent < client.used ? POLLOUT : POLLIN;
                watched[i + 1] = {client.fd, events, 0};
                if (client.fd >= 0) {
                    next_deadline = std::min(next_deadline, client.deadline);
                }
            }
            const auto remaining = std::chrono::ceil<std::chrono::milliseconds>(
                                       next_deadline - Clock::now())
                                       .count();
            const int timeout =
                static_cast<int>(std::max<std::int64_t>(0, remaining));
            const int ready = ::poll(watched.data(), watched.size(), timeout);
            if (ready < 0) {
                if (errno == EINTR) {
                    continue;
                }
                throw std::system_error(errno, std::generic_category(), "poll");
            }
            if (ready == 0 || Clock::now() >= stop_at) {
                continue;
            }
            // Service existing clients before accepting into an empty slot.
            for (std::size_t i = 0; i < clients.size(); ++i) {
                auto& client = clients[i];
                const auto events = watched[i + 1].revents;
                if (client.fd < 0 || events == 0 ||
                    Clock::now() >= client.deadline) {
                    continue;
                }
                bool drop = (events & POLLNVAL) != 0;
                if (!drop && client.sent < client.used) {
                    const auto sent =
                        ::send(client.fd, client.buffer.data() + client.sent,
                               client.used - client.sent, MSG_NOSIGNAL);
                    if (sent > 0) {
                        client.sent += static_cast<std::size_t>(sent);
                        if (client.sent == client.used) {
                            client.used = client.sent = 0;
                        }
                    } else if (sent == 0 ||
                               (errno != EAGAIN && errno != EWOULDBLOCK &&
                                errno != EINTR)) {
                        drop = true;
                    }
                } else if (!drop) {
                    // HUP may accompany unread data: recv decides bytes or EOF.
                    const auto received =
                        ::recv(client.fd, client.buffer.data(),
                               client.buffer.size(), 0);
                    if (received > 0) {
                        client.used = static_cast<std::size_t>(received);
                        client.total += client.used;
                        drop = client.total > 1024;
                    } else if (received == 0 ||
                               (errno != EAGAIN && errno != EWOULDBLOCK &&
                                errno != EINTR)) {
                        drop = true;
                    }
                }
                if (drop) {
                    ::close(client.fd);
                    client.fd = -1;
                    std::cout << "closed slot=" << i + 1 << '\n';
                }
            }
            if (watched[0].revents & (POLLERR | POLLHUP | POLLNVAL)) {
                throw std::runtime_error("listener failed");
            }
            if (watched[0].revents & POLLIN) {
                const int connection = ::accept4(listener, nullptr, nullptr,
                                                 SOCK_NONBLOCK | SOCK_CLOEXEC);
                if (connection < 0) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK ||
                        errno == EINTR) {
                        continue;
                    }
                    throw std::system_error(errno, std::generic_category(),
                                            "accept4");
                }
                const auto slot = std::find_if(
                    clients.begin(), clients.end(),
                    [](const Client& client) { return client.fd < 0; });
                if (slot == clients.end()) {
                    ::close(connection);
                    std::cout << "capacity reached\n";
                } else {
                    *slot = Client{};
                    slot->fd = connection;
                    slot->deadline = Clock::now() + std::chrono::seconds(3);
                    std::cout << "accepted slot=" << slot - clients.begin() + 1
                              << '\n';
                }
            }
        }
        std::cout << "stopped\n";
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    for (auto& client : clients) {
        if (client.fd >= 0) {
            ::close(client.fd);
        }
    }
    if (listener >= 0) {
        ::close(listener);
    }
    return status;
}

poll() ignores entries whose descriptor is negative. The array therefore always has five entries: one listener and four connection slots.

The plain record's fd is closed explicitly on EOF, error, expiry or capacity rejection, and again checked during final cleanup. No course-specific header or socket class is needed.

Inspect the two-stream demo client

The demo client uses blocking calls with two-second Linux per-call timeouts. That keeps the experiment simple; it is not the server's event loop and does not define a whole-request deadline.

After the active stream finishes, a nonblocking receive on the idle stream must report EAGAIN or EWOULDBLOCK, not EOF. The client also rejects a wrong echo.

Full standalone demo: app/tcp-multi/client.cpp
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <sys/time.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    std::array<int, 2> sockets{-1, -1};
    int status = 0;
    try {
        if (argc != 2) {
            throw std::invalid_argument("usage: tcp_multi_client port");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        for (auto& fd : sockets) {
            fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
            if (fd < 0) {
                throw std::system_error(errno, std::generic_category(),
                                        "socket");
            }
            const timeval timeout{2, 0};
            if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout,
                             sizeof(timeout)) < 0 ||
                ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout,
                             sizeof(timeout)) < 0) {
                throw std::system_error(errno, std::generic_category(),
                                        "setsockopt");
            }
            if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                          sizeof(server)) < 0) {
                throw std::system_error(errno, std::generic_category(),
                                        "connect");
            }
        }
        std::cout << "idle connection opened\n";
        const int active = sockets[1];
        const std::string_view message = "hello";
        std::size_t offset = 0;
        while (offset < message.size()) {
            const auto sent = ::send(active, message.data() + offset,
                                     message.size() - offset, MSG_NOSIGNAL);
            if (sent < 0) {
                throw std::system_error(errno, std::generic_category(), "send");
            }
            if (sent == 0) {
                throw std::runtime_error("send made no progress");
            }
            offset += static_cast<std::size_t>(sent);
        }
        if (::shutdown(active, SHUT_WR) < 0) {
            throw std::system_error(errno, std::generic_category(), "shutdown");
        }
        std::array<char, 128> buffer{};
        std::string reply;
        while (true) {
            const auto received =
                ::recv(active, buffer.data(), buffer.size(), 0);
            if (received < 0) {
                throw std::system_error(errno, std::generic_category(),
                                        "recv active");
            }
            if (received == 0) {
                break;
            }
            reply.append(buffer.data(), static_cast<std::size_t>(received));
            if (reply.size() > message.size()) {
                throw std::runtime_error("reply too large");
            }
        }
        if (reply != message) {
            throw std::runtime_error("echo mismatch");
        }
        std::cout << "active reply bytes=" << reply.size() << " data=" << reply
                  << '\n';
        // Would-block, rather than EOF, proves the first stream is still open.
        char byte = 0;
        const auto idle = ::recv(sockets[0], &byte, 1, MSG_DONTWAIT);
        if (idle != -1 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
            throw std::runtime_error("idle connection did not remain open");
        }
        std::cout << "idle connection still open\n";
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    for (const int fd : sockets) {
        if (fd >= 0) {
            ::close(fd);
        }
    }
    return status;
}

To compile the pair without CMake:

1
2
3
4
5
mkdir -p build/direct
c++ -std=c++20 -Wall -Wextra -Wpedantic \
    app/tcp-multi/server.cpp -o build/direct/tcp_multi_server
c++ -std=c++20 -Wall -Wextra -Wpedantic \
    app/tcp-multi/client.cpp -o build/direct/tcp_multi_client

Run the resulting pair with the same port arguments. No thread library or additional include path is required.

Understand this server's limits

Limit Policy in this example
Simultaneous accepted clients Four. A fifth accepted socket is closed immediately.
Application buffer 256 bytes per client, not counting kernel buffers or other state.
Total input per connection At most 1024 bytes. Oversized input closes that connection.
Connection lifetime Three seconds from acceptance, even if data keeps arriving.
Server lifetime 30000 ms by default, configurable from 300 to 30000 ms.

The listen(listener, 4) backlog controls pending connection queuing; the four Client records enforce the application's accepted-connection limit. A successful client-side connect() does not promise that an application slot will be available.

The three-second connection budget is an absolute lifetime, not an inactivity timeout. Like the preceding lesson's receive deadline, it never restarts on progress. This teaching policy can cut off unfinished work. A real service needs a deliberate policy for request deadlines, output draining and shutdown.

The poll() timeout uses the earliest active deadline, including the server's own stop time. This allows quiet connections to expire without a busy-wait loop. Clock checks after waking keep the original budgets; scheduling still prevents a hard real-time guarantee.

A connection reset or oversized request removes only the affected slot. Failure of the listener or poll() itself ends the server and triggers explicit cleanup. This example does not cover descriptor exhaustion recovery, TLS, throughput tuning or CPU-heavy request work.

Try the bounded run and checks

To shorten the server's lifetime to five seconds, restart it with:

./build/native/debug/tcp_multi_server 9003 5000

The optional number is milliseconds, not a client count. Use the demo client before the server stops. To check more than the two-stream demonstration, run:

ctest --preset native-debug -R '^network[.]multi[.]' --output-on-failure

The 16 multi-client checks cover four simultaneous streams with distinct binary payloads, slot reuse, capacity rejection, reset isolation, EOF after queued data, input limits, deadlines and the demo client's validation. They use temporary ports and reap their own processes.

The 1024-byte echo checks require multiple 256-byte receive chunks. They do not guarantee a short kernel send() result or claim to exhaust kernel send buffers; the code retains offsets for those cases without a throughput claim.

Troubleshooting

  • Connection refused: start the server first, check both ports and make sure its run budget has not expired.
  • Idle stream did not remain open: use a fresh server with free slots; the demo assumes it completes before the first connection's three-second lifetime ends.
  • A fifth client closes: expected when all four slots are occupied. A reset or EOF can both occur depending on queued data; do not depend on one particular rejection signal.
  • Echo stops after three seconds: the connection lifetime expired. This example is deliberately bounded, not a production session policy.
  • CPU-heavy work delays all clients: nonblocking sockets do not make application computations nonblocking. Keep such work out of this small event loop.

Check your understanding

A client has 200 pending echo bytes, and a nonblocking send() accepts only 80. Should the server discard the other 120 bytes or keep sending in a tight loop?

Compare your reasoning

Neither. Keep the buffer, advance the send offset by 80, and continue watching that connection for write readiness. Return to the event loop so other clients can make progress. Switch back to read readiness after all 200 bytes have been sent.

Continue the course

Previous: Disconnection and Timeouts · Next: Your First HTTP Request · Course overview

References: poll readiness, accept and nonblocking descriptors, partial sends, receive results.

Donate