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.
Course examples on GitHub
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.
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¶
Terminal A:
It prints listening 127.0.0.1:9003. Within 30 seconds, run in terminal B:
Expected client output:
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:
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.
- Read readiness: make at most one nonblocking
recv()for that client in this pass. - Write readiness: send only the remaining
used - sentbytes. A short send advancessent; 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:
- Build the
pollfdarray and calculate the next deadline. - Process existing clients, preserving their unsent bytes.
- 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | |
To compile the pair without CMake:
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:
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:
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.