Skip to main content

tcp_pool/
tcp_pool.rs

1//! The pool doing its day job: pooling **upstream TCP client connections**.
2//!
3//! `echo.rs` pools plain buffers to show the mechanics. This example shows the
4//! canonical bb8 use — a bounded, reused set of connections to a backend. The
5//! front is a thread-per-core `SO_REUSEPORT` TCP server; for each client request
6//! it leases an upstream [`TcpStream`] from the pool, round-trips the bytes, and
7//! returns the lease so the next request can reuse the same upstream. A handful
8//! of upstreams per worker therefore serves many client connections.
9//!
10//! The upstream is just an echo backend, so from a client's point of view the
11//! front echoes too — drive it with `examples/load.rs` and you measure the pool,
12//! the forwarding, and the round-trip to the backend together.
13//!
14//! ```text
15//! # 1. a backend to pool connections to (any echo server on one address):
16//! cargo run --release --example echo -- 127.0.0.1:7001
17//!
18//! # 2. the pooling front:
19//! UPSTREAM=127.0.0.1:7001 \
20//! cargo run --release --example tcp_pool -- [ADDR] [CAPACITY]
21//!   ADDR       front bind address              default 0.0.0.0:7000
22//!   CAPACITY   upstream connections per worker default 64
23//!
24//! # 3. load the front as if it were the echo server:
25//! cargo run --release --example load -- 127.0.0.1:7000 --conns 64 --seconds 5
26//! ```
27
28use std::{io, net::SocketAddr, sync::LazyLock, thread};
29
30use compio::{
31    BufResult,
32    io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
33    net::{TcpListener, TcpStream},
34    runtime::Runtime,
35};
36use compio_pool::{LocalPool, ManageConnection, Pool, bind_reuseport, cpu};
37
38/// The backend every worker's pool connects to.
39static UPSTREAM: LazyLock<SocketAddr> = LazyLock::new(|| {
40    std::env::var("UPSTREAM")
41        .unwrap_or_else(|_| "127.0.0.1:7001".into())
42        .parse()
43        .expect("UPSTREAM must be host:port")
44});
45
46/// One pooled upstream connection, plus a flag the handler trips when a round
47/// trip fails so the pool discards it instead of handing out a dead socket.
48struct Upstream {
49    stream: TcpStream,
50    healthy: bool,
51}
52
53/// Opens and vets upstream connections for the pool. This — not the buffer in
54/// `echo.rs` — is what a real pool manages: a Redis, Postgres or HTTP backend
55/// would connect and ping here exactly the same way.
56struct Backend;
57
58impl ManageConnection for Backend {
59    type Connection = Upstream;
60    type Error = io::Error;
61
62    async fn connect(&self) -> io::Result<Upstream> {
63        let stream = TcpStream::connect(*UPSTREAM).await?;
64        stream.set_nodelay(true)?;
65        Ok(Upstream {
66            stream,
67            healthy: true,
68        })
69    }
70
71    /// Checked out of the idle list when `test_on_check_out` is on. A real
72    /// backend would send a cheap health probe (Redis `PING`, SQL `SELECT 1`)
73    /// here; an opaque echo backend has no such probe, so we trust the
74    /// `has_broken` flag instead.
75    async fn is_valid(&self, conn: &mut Upstream) -> io::Result<()> {
76        if conn.healthy {
77            Ok(())
78        } else {
79            Err(io::Error::new(io::ErrorKind::BrokenPipe, "upstream marked dead"))
80        }
81    }
82
83    fn has_broken(&self, conn: &mut Upstream) -> bool {
84        !conn.healthy
85    }
86}
87
88fn main() -> io::Result<()> {
89    let addr: SocketAddr = std::env::args()
90        .nth(1)
91        .unwrap_or_else(|| "0.0.0.0:7000".into())
92        .parse()
93        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("bad address: {e}")))?;
94    let capacity: u32 = std::env::args()
95        .nth(2)
96        .and_then(|s| s.parse().ok())
97        .unwrap_or(64);
98
99    // Build the pool once; every worker gets a clone of the `Send` handle.
100    let pool = Pool::builder().max_size(capacity).build(Backend);
101
102    let cores = cpu::cores();
103    if cores.is_empty() {
104        return Err(io::Error::new(io::ErrorKind::Unsupported, "no cores to run on"));
105    }
106    println!(
107        "tcp_pool on {addr} -> upstream {}: {} workers, {capacity} upstreams/worker",
108        *UPSTREAM,
109        cores.len()
110    );
111
112    let mut handles = Vec::new();
113    for (index, core) in cores.into_iter().enumerate() {
114        let pool = pool.clone();
115        let handle = thread::Builder::new()
116            .name(format!("worker/{index}"))
117            .spawn(move || worker(core, addr, pool))?;
118        handles.push(handle);
119    }
120    for handle in handles {
121        let _ = handle.join();
122    }
123    Ok(())
124}
125
126/// One pinned thread: its own runtime, its own `SO_REUSEPORT` listener, and its
127/// own [`LocalPool`] of upstream connections carved from the shared [`Pool`].
128fn worker(core: cpu::CoreId, addr: SocketAddr, pool: Pool<Backend>) {
129    cpu::pin_current(core);
130
131    let runtime = Runtime::builder().build().expect("build compio runtime");
132    runtime.block_on(async move {
133        let local = pool.local();
134
135        let std_listener = bind_reuseport(addr, 1024, None).expect("bind SO_REUSEPORT");
136        let listener = TcpListener::from_std(std_listener).expect("wrap listener in ring");
137
138        loop {
139            let Ok((stream, _peer)) = listener.accept().await else {
140                continue;
141            };
142            compio::runtime::spawn(serve(local.clone(), stream)).detach();
143        }
144    });
145}
146
147/// Forward each client request through a freshly leased upstream and hand the
148/// upstream straight back, so a small pool fans out across many clients.
149async fn serve(local: LocalPool<Backend>, mut client: TcpStream) {
150    let mut buf = Vec::with_capacity(16 * 1024);
151    loop {
152        buf.clear();
153        let BufResult(read, b) = client.read(buf).await;
154        buf = b;
155        let n = match read {
156            Ok(0) | Err(_) => return,
157            Ok(n) => n,
158        };
159
160        // Lease one upstream for just this round trip.
161        let mut up = match local.get().await {
162            Ok(up) => up,
163            Err(_) => return, // pool exhausted past the timeout, or connect failed
164        };
165
166        // Request out to the upstream.
167        let BufResult(written, b) = up.stream.write_all(buf).await;
168        buf = b;
169        if written.is_err() {
170            up.healthy = false;
171            return;
172        }
173
174        // Echo backend replies with exactly what it got, so read n back.
175        let BufResult(got, reply) = up.stream.read_exact(Vec::with_capacity(n)).await;
176        if got.is_err() {
177            up.healthy = false;
178            return;
179        }
180        drop(up); // return the upstream to the pool before touching the client
181
182        let BufResult(written, _) = client.write_all(reply).await;
183        if written.is_err() {
184            return;
185        }
186    }
187}