Borrow, Block, Return: A Connection Pool with a Condvar

A connection pool with callers parked on a condition variable until a connection is released

A connection pool exists because the thing it pools is costly to create. Opening a TCP connection, doing a TLS handshake, authenticating to a database: you do not want to pay that on every query. So you pre-open a fixed number of connections, hand them out, and take them back when callers are done. Reuse instead of re-create.

The easy 90% is a list of idle connections you pop from and push to. The interesting 10%, the part that separates a real pool from a toy, is the moment the pool is empty and a caller wants a connection. You cannot return an error (the pool is healthy, just busy) and you cannot busy-loop (that burns a core). You need the caller to sleep until a connection comes back, and wake up the instant one does. That is exactly what a condition variable is for.

The shape

struct State<C> { conns: Vec<C>, closed: bool }

struct Inner<C> {
    state: Mutex<State<C>>, // the idle list, guarded
    cv: Condvar,            // "a connection became available"
    capacity: usize,
}

pub struct Pool<C> { inner: Arc<Inner<C>> }

The Mutex protects the list of idle connections. The Condvar is the doorbell: callers wait on it, and a release rings it. The whole thing lives behind an Arc so it can be shared across threads, and cloning the Pool just clones the Arc, so every clone is a handle to the same pool.

Acquire: pop, or wait and try again

pub fn acquire(&self) -> Result<C, PoolError> {
    let mut st = self.inner.state.lock().unwrap();
    loop {
        if st.closed { return Err(PoolError::Closed); }
        if let Some(c) = st.conns.pop() { return Ok(c); } // got one
        st = self.inner.cv.wait(st).unwrap();             // sleep until notified
    }
}

The magic is in cv.wait(st). It does three things atomically: it releases the mutex (so other threads, including the one about to release a connection, can make progress), it parks the thread, and when woken it reacquires the mutex and hands the guard back. While you sleep you hold no lock, which is the whole point. You are not spinning, you are not blocking anyone, you are just off the CPU until there is news.

And note the loop. When you wake, you do not assume a connection is waiting. You go back to the top and check again, because a condition variable can wake you spuriously, or another thread may have grabbed the connection first. "Wait in a loop, recheck the condition" is the rule for every condvar in every language. Bugs live in the version that uses if instead of while.

Release rings the bell

pub fn release(&self, conn: C) {
    let mut st = self.inner.state.lock().unwrap();
    if st.closed { drop(st); conn.close(); return; }
    if st.conns.len() < self.inner.capacity {
        st.conns.push(conn);
        drop(st);
        self.inner.cv.notify_one(); // wake exactly one waiter
    } else {
        drop(st);
        conn.close(); // overflow, do not grow past capacity
    }
}

Push the connection back, drop the lock, then notify_one to wake a single waiting acquirer. Waking one rather than all (notify_all) avoids the thundering herd of every waiter racing for one connection only for all but one to go back to sleep.

A timed variant, acquire_timeout, is the same loop with cv.wait_timeout and a deadline, so a caller can say "I will wait up to 200ms, then fail with Timeout." That is what you actually want at a request boundary: bounded waiting, not infinite.

The Rust angle

Pooling is one of those patterns that is trivial until it is contended, and the contended case is a tiny, beautiful use of a condition variable: sleep with the lock released, wake on a signal, recheck in a loop.


Build it yourself. Solve the Connection Pool challenge on barehands and get graded on correctness and speed. No crates, just the standard library.