25. Network & Systems Mathematics
Level: Expert You'll use this for: subnet design, capacity planning, latency budgets, and understanding TCP behavior under load.
Subnetting / CIDR math
An IPv4 address is 32 bits. A /24 CIDR block means the first 24 bits are
the fixed network portion, leaving 32 - 24 = 8 bits for host addresses:
2^8 = 256 addresses (254 usable, since the first is the network address
and the last is the broadcast address).
def subnet_info(cidr_bits):
total_addresses = 2 ** (32 - cidr_bits)
usable_hosts = total_addresses - 2 # minus network + broadcast
return total_addresses, usable_hosts
print(subnet_info(24)) # (256, 254)
print(subnet_info(16)) # (65536, 65534)
# How many /24s fit inside a /16?
def subnets_within(outer_cidr, inner_cidr):
return 2 ** (inner_cidr - outer_cidr)
print(subnets_within(16, 24)) # 256 — a /16 contains 256 /24 networks
Subnet masking itself is literal bitwise AND (Ch. 3): an IP is "in" a
subnet if ip & mask == network_address & mask.
import ipaddress
net = ipaddress.ip_network("10.0.0.0/24")
print(net.num_addresses, list(net.hosts())[:3])
ip = ipaddress.ip_address("10.0.0.55")
print(ip in net) # True
Checking 10.0.0.55 against 10.0.0.0/24 bit by bit — convert both
to binary, apply the mask (Ch. 1, 3), and compare:
IP: 10.0.0.55 = 00001010.00000000.00000000.00110111
Mask: /24 = 11111111.11111111.11111111.00000000 <- 24 ones, then 8 zeros
IP AND Mask: 00001010.00000000.00000000.00000000 = 10.0.0.0
Network address: 10.0.0.0
They match -> 10.0.0.55 IS inside the 10.0.0.0/24 network.
The AND mask zeroes out the last 8 bits (the host portion) on both sides —
if what's left over matches the network address, the IP belongs to that
subnet. The broadcast address is the same network bits with every
host bit forced to 1 instead of 0: 10.0.0.0 with the last 8 bits set
gives 10.0.0.255 — the top of the range, and the address the code's
net.num_addresses (256) and usable-host count (254) are built from.
Bandwidth-delay product — why "just add more bandwidth" isn't the whole story
BDP = bandwidth × round-trip-time (RTT)
This tells you how many bytes can be "in flight" on the link at once — and
therefore the minimum TCP window size needed to fully utilize the
link. A 1 Gbps link with 100ms RTT has a BDP of 1,000,000,000 × 0.1 / 8 = 12.5 MB. If your TCP window is smaller than this (the classic default
64KB window is 200× too small here), you cannot use the full bandwidth
no matter how fast the link is — this is the actual mathematical
reason "long fat networks" (high bandwidth, high latency, e.g.,
transcontinental links) need TCP window scaling enabled.
def bandwidth_delay_product_bytes(bandwidth_bps, rtt_seconds):
return (bandwidth_bps * rtt_seconds) / 8
bdp = bandwidth_delay_product_bytes(1_000_000_000, 0.1)
print(f"{bdp/1_000_000:.1f} MB needed in flight to saturate the link")
Little's Law and queueing theory (M/M/1)
Little's Law, astonishingly general (applies to any stable queueing
system): L = λ · W
(average number of items in the system = arrival rate × average time each
item spends in the system). This applies to network packet queues, web
server request queues, and literally any waiting line.
The M/M/1 queue (Poisson arrivals, exponential service time, 1 server)
gives a closed-form for average wait time as a function of utilization
ρ = λ/μ (arrival rate ÷ service rate):
Average time in system: W = 1 / (μ - λ) (only valid when λ < μ)
def mm1_avg_wait(arrival_rate, service_rate):
if arrival_rate >= service_rate:
return float('inf') # unstable queue: grows without bound
return 1 / (service_rate - arrival_rate)
for util in [0.5, 0.7, 0.9, 0.95, 0.99]:
mu = 100 # server handles 100 req/sec
lam = util * mu
print(f"utilization={util:.0%} avg wait={mm1_avg_wait(lam, mu)*1000:.1f}ms")
This is the single most important, most underused piece of math in capacity planning: wait time doesn't grow linearly with utilization — it grows hyperbolically, exploding as utilization approaches 100%. Going from 90% to 95% utilization doesn't add 5% more latency, it can double it. This is the rigorous justification for the common SRE rule of thumb "never run production at >70-80% sustained utilization."
TCP congestion control — AIMD (Additive Increase, Multiplicative Decrease)
TCP's congestion window cwnd grows additively (+1 MSS per RTT) when
things are going well, and shrinks multiplicatively (halved) on packet
loss:
def aimd_simulation(rounds, loss_at_round):
cwnd = 1
history = []
for r in range(rounds):
if r == loss_at_round:
cwnd = cwnd / 2 # multiplicative decrease
else:
cwnd += 1 # additive increase
history.append(cwnd)
return history
print(aimd_simulation(20, loss_at_round=10))
This produces the classic loss-based TCP "sawtooth" throughput graph. The asymmetry (slow linear growth, fast halving) is deliberately conservative — a direct, real-world application of control theory (Ch. 29) tuned to avoid congestion collapse across the shared, uncoordinated internet.
Deriving the M/M/1 formulas (instead of trusting them)
| Symbol | Meaning |
|---|---|
λ |
arrival rate (Poisson arrivals, Ch. 14) |
μ |
service rate (exponential service times) |
ρ = λ/μ |
utilization, the fraction of time the server is busy. Must be < 1 |
πₙ |
long-run probability that n requests are in the system |
The number of requests in the system is a Markov chain (Ch. 11): it goes up
by 1 at rate λ and down by 1 at rate μ. In steady state, the flow across
each boundary between n and n+1 must balance:
λ · πₙ = μ · πₙ₊₁ -> πₙ₊₁ = ρ · πₙ -> πₙ = ρⁿ · π₀
The probabilities sum to 1, and Σ ρⁿ = 1/(1 - ρ) is a geometric series
(Ch. 6), so π₀ = 1 - ρ:
πₙ = (1 - ρ) · ρⁿ (geometric distribution)
L = Σ n · πₙ = ρ / (1 - ρ) (average number in system)
W = L / λ = 1 / (μ - λ) (Little's Law, below)
Example: at ρ = 0.9 the average queue holds 0.9/0.1 = 9 requests.
Buffer overflow probability falls out for free: P(n ≥ k) = ρᵏ. A router
port at 90% utilization exceeds 20 queued packets 0.9²⁰ ≈ 12% of the time and
50 packets 0.9⁵⁰ ≈ 0.5% of the time. Read it backwards to size a buffer for a
target drop rate: k = ln(target) / ln(ρ).
Why Little's Law holds for any system
L = λ · W holds whatever the arrival or service distributions. The proof is a
counting argument. Plot N(t), the number of requests in the system over a
long window of length T, and compute the area under that curve two ways:
area = ∫₀ᵀ N(t) dt = L · T (average height × width)
area = Σ over requests of (time each one spent) = (λ·T) · W (count × average stay)
L · T = λ · T · W -> L = λ · W
Each request contributes a horizontal strip as tall as one request and as long as its stay, so both sides measure the same area (an integral, Ch. 12). That's why Little's Law applies to a CPU run queue, a Kafka consumer group, a connection pool or a coffee shop.
Example: a service handles 2,000 req/s with an average latency of 50 ms,
so L = 2000 · 0.05 = 100 requests are in flight on average. You need at
least 100 concurrent connections or worker threads, plus headroom for the
variance shown above.
Multiple servers: Erlang C, and why pooling wins
For c identical servers sharing one queue (M/M/c), with offered load
a = λ/μ (in Erlangs), the probability that an arriving request has to wait is
the Erlang C formula:
(aᶜ / c!) · c/(c - a)
P_wait = ────────────────────────────────────── W_q = P_wait / (c·μ - λ)
Σₖ₌₀^(c-1) aᵏ/k! + (aᶜ / c!) · c/(c - a)
| Symbol | Meaning |
|---|---|
c |
number of servers (workers, threads, agents) |
a = λ/μ |
offered load in Erlangs: how many servers would be busy on average |
P_wait |
the chance a request finds every server busy |
W_q |
average time spent waiting before service starts |
Example: sizing a worker pool. λ = 90 jobs/s and each worker completes
μ = 10 jobs/s, so a = 9 Erlangs. Nine workers is the bare minimum, but:
Workers c |
Utilization | P(wait) | Avg queue wait |
|---|---|---|---|
| 10 | 90% | 67% | 66.9 ms |
| 11 | 82% | 43% | 21.5 ms |
| 12 | 75% | 27% | 8.9 ms |
| 14 | 64% | 9% | 1.8 ms |
Going from 10 to 12 workers (+20% cost) cuts queueing delay 7.5×. That's the same hyperbolic curve as M/M/1, now with a precise number for the decision.
Pooling. Compare two separate M/M/1 servers, each getting 45 req/s with
μ = 50, against one shared queue feeding both (M/M/2, 90 req/s):
separate queues: W = 1/(50 - 45) = 200 ms
one pooled queue: W = W_q + 1/μ (Erlang C, c = 2) ≈ 105 ms
The same hardware and the same load give half the latency, just by sharing the queue. A request never waits behind a busy server while the other one is idle. That's why one shared work queue beats per-worker queues, why a single supermarket line feeding many tills is faster than one line per till, and why work stealing (Go's scheduler, Tokio, Java's ForkJoinPool) exists. It recovers most of the pooling benefit while keeping per-worker queues for cache locality.
Variability is the enemy: Kingman's formula
Real traffic isn't Poisson and real service times aren't exponential. For a general single-server queue (G/G/1), Kingman's approximation gives the waiting time:
W_q ≈ ( ρ / (1 - ρ) ) · ( (c_a² + c_s²) / 2 ) · τ
utilization variability service time
| Symbol | Meaning |
|---|---|
c_a |
coefficient of variation (std ÷ mean, Ch. 9) of inter-arrival times. Poisson gives 1 |
c_s |
coefficient of variation of service times. Exponential gives 1, constant gives 0 |
τ |
mean service time |
At ρ = 0.8 and τ = 10 ms:
| Arrivals / service | c_a², c_s² |
Wait |
|---|---|---|
| Poisson / exponential (M/M/1) | 1, 1 | 40 ms |
| Poisson / constant service time | 1, 0 | 20 ms |
| Bursty arrivals, heavy-tailed service | 4, 4 | 160 ms |
The three factors each say something different. Utilization explodes near
- Variability multiplies the wait linearly. Service time scales it. So cutting tail variance (timeouts, request size limits, splitting the slow endpoints onto their own pool) can matter as much as adding capacity. It's the reason "head-of-line blocking" by one huge request hurts everyone behind it.
Why AIMD converges to fairness
Two flows share a link of capacity 16, starting very unfairly at
(x₁, x₂) = (10, 2). Each round, both add 1 if total ≤ 16, or both halve if
the link overflows:
(10, 2) → (11, 3) → (12, 4) → (13, 5) diff 8 (increase keeps the difference)
cut → (6.5, 2.5) diff 4 (halving halves the difference)
... → (10.5, 6.5) → cut → (5.25, 3.25) diff 2
... → (9.25, 7.25) → cut → (4.6, 3.6) diff 1
Additive increase preserves the gap between the flows. Multiplicative
decrease halves it. After k congestion events the unfairness has shrunk by
2ᵏ, so the flows converge to an equal share whatever their starting point.
Chiu and Jain (1989) proved that among linear controls, AIMD is the one that
converges to both efficiency and fairness. AIAD keeps the unfairness forever,
and MIMD keeps the ratio forever. That paper is why TCP behaves the way it
does.
How big should a router buffer be?
The classic rule was "buffer = one bandwidth-delay product", so a single TCP
flow's sawtooth never empties the queue. For a 10 Gbit/s link with 250 ms
RTT that's 10¹⁰ · 0.25 / 8 = 312.5 MB of fast memory, which is expensive.
Appenzeller, Keslassy and McKeown (2004) showed that with n desynchronized
flows the sawtooths average out (the CLT again, Ch. 14: the total window's
fluctuations grow like √n while the mean grows like n), so you need only:
buffer ≈ BDP / √n
With 10,000 flows, √n = 100 and the buffer drops to 3.1 MB. That's the
difference between off-chip DRAM and on-chip SRAM. Oversized buffers cause
bufferbloat: full queues that add seconds of latency without adding
throughput. CoDel and FQ-CoDel (Linux's default qdisc on many distributions)
attack it by managing the time packets spend queued rather than the queue
length, which is Little's Law applied as a control signal.
Real-life engineering ties
- Network: subnetting/CIDR is daily-driver math for any network engineer; BDP explains "why is my transcontinental transfer slow despite huge bandwidth"; queueing theory is the rigorous basis for capacity planning and SLA design.
- SWE/SRE: Little's Law applies directly to any request queue, worker pool, or connection pool sizing decision.
- Security: queueing math explains and helps mitigate SYN flood /
slow-loris style DoS: an attacker's goal is to push
ρ → 1(or past it) deliberately, and understanding the hyperbolic wait-time curve explains why even modest additional load near saturation can cause disproportionate service degradation.
More real-life examples:
- Google's BBR congestion-control algorithm (deployed widely since 2016, including at YouTube) replaced classic loss-based AIMD/CUBIC with a model that directly estimates the bandwidth-delay product from this chapter, rather than waiting for packet loss as its congestion signal.
- CDN capacity planning (Netflix's Open Connect, Akamai, Cloudflare) applies Little's Law and queueing theory at enormous scale to decide how many edge servers and how much cache capacity a region needs to keep p99 latency low during peak load.
- Cloud auto-scaling policies (AWS Target Tracking, Kubernetes HPA) are configured with a target utilization — commonly 60-70%, not 100% — a direct, practiced application of the M/M/1 hyperbolic wait-time curve.
- Home routers and ISPs implementing Smart Queue Management (e.g.,
cake,fq_codel) apply Active Queue Management theory to reduce "bufferbloat," trading a little throughput for a large drop in latency.
Common mistake
Sizing infrastructure off average utilization instead of the queueing math above — average utilization looks "fine" (e.g., 60%) while p99 latency is already terrible, because traffic isn't smooth; it's Poisson (Ch. 14), so instantaneous utilization spikes well above the average regularly.