Day 36 — Connection Pools
Learning Objectives
- Build a generic TCP connection pool — the foundation of your load balancer
- Implement idle timeout and max pool size
- Understand why connection pools dramatically reduce connection overhead
Key Concepts
Without pool: each request = DNS + TCP handshake + (TLS handshake) + request
latency: 50ms DNS + 1ms TCP + 5ms TLS = 56ms overhead per request
With pool: first request connects, subsequent requests reuse the connection
latency: ~0ms overhead for subsequent requests
trade-off: idle connections consume server-side resources
Pool design:
Get(addr): return idle connection or dial new one
Put(conn): return connection to pool for reuse
Close(): close all idle connections
Idle timer: close connections idle > threshold
Exercise: TCP Connection Pool
package main
import (
"fmt"
"net"
"sync"
"time"
)
type Pool struct {
mu sync.Mutex
idle map[string][]idleConn
maxPerAddr int
idleTimeout time.Duration
dialer net.Dialer
}
type idleConn struct {
conn net.Conn
idleSince time.Time
}
func NewPool(maxPerAddr int, idleTimeout time.Duration) *Pool {
p := &Pool{
idle: make(map[string][]idleConn),
maxPerAddr: maxPerAddr,
idleTimeout: idleTimeout,
dialer: net.Dialer{Timeout: 5 * time.Second},
}
go p.reaper()
return p
}
// Get returns an idle connection or dials a new one
func (p *Pool) Get(addr string) (net.Conn, error) {
p.mu.Lock()
idles := p.idle[addr]
for len(idles) > 0 {
ic := idles[len(idles)-1]
idles = idles[:len(idles)-1]
p.idle[addr] = idles
p.mu.Unlock()
// Test if connection is still alive (1-byte read with immediate timeout)
ic.conn.SetReadDeadline(time.Now())
buf := make([]byte, 1)
_, err := ic.conn.Read(buf)
ic.conn.SetReadDeadline(time.Time{}) // clear deadline
if err != nil && !isTimeout(err) {
ic.conn.Close() // stale connection
p.mu.Lock()
idles = p.idle[addr]
continue
}
return ic.conn, nil // healthy idle connection
}
p.mu.Unlock()
// No idle connection — dial new
return p.dialer.Dial("tcp", addr)
}
// Put returns a connection to the pool for reuse
func (p *Pool) Put(addr string, conn net.Conn) {
p.mu.Lock()
defer p.mu.Unlock()
idles := p.idle[addr]
if len(idles) >= p.maxPerAddr {
conn.Close() // pool full — close excess
return
}
p.idle[addr] = append(idles, idleConn{conn: conn, idleSince: time.Now()})
}
// reaper closes connections that have been idle too long
func (p *Pool) reaper() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
p.mu.Lock()
cutoff := time.Now().Add(-p.idleTimeout)
for addr, idles := range p.idle {
var live []idleConn
for _, ic := range idles {
if ic.idleSince.After(cutoff) {
live = append(live, ic)
} else {
ic.conn.Close()
}
}
p.idle[addr] = live
}
p.mu.Unlock()
}
}
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, idles := range p.idle {
for _, ic := range idles {
ic.conn.Close()
}
}
p.idle = make(map[string][]idleConn)
}
func isTimeout(err error) bool {
ne, ok := err.(net.Error)
return ok && ne.Timeout()
}
func main() {
pool := NewPool(10, 30*time.Second)
defer pool.Close()
// Simulate 50 concurrent requests to the same backend
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// In a real proxy, replace with your actual backend addr
conn, err := pool.Get("example.com:80")
if err != nil {
fmt.Printf("[%d] dial error: %v\n", id, err)
return
}
// Use connection...
pool.Put("example.com:80", conn) // return to pool
}(i)
}
wg.Wait()
fmt.Println("Done")
}
Real-World Context
http.Transport in Go's standard library is a connection pool. When you configure MaxIdleConnsPerHost: 100, that's the pool size. Your reverse proxy (Week 9) uses httputil.ReverseProxy which uses http.Transport under the hood. Building a pool from scratch shows you why those config options exist.