Concurrency in Practice: Pools, Queues, Atomics and the Event Loop
The building blocks real systems use: sized thread pools, bounded queues, compare-and-swap, and when an event loop beats threads.
- 10xSpeedup cap at 90% parallel work
- 80 threads8 cores, 90 ms wait, 10 ms compute
- 1 threadServes thousands of idle connections
Show what to say
“Concurrency is structuring overlapping tasks, parallelism is running them at once, and the serial part caps the speedup. I use a bounded thread pool sized by cores for CPU work and by wait over compute for blocking work, a bounded queue for backpressure, atomics for single counters, and an event loop when the work mostly waits.”
Trade-offs
- Bounded vs unbounded queue: a bounded queue rejects or blocks under overload; an unbounded one hides it until memory runs out.
- Atomics vs locks: CAS never blocks others but retries under contention and protects one word; a lock covers many fields.
- Event loop vs threads: the loop holds many idle connections cheaply but is blocked by CPU work; threads use all cores.
Bring it up before they ask
Never block the event loop or a pool worker on work queued behind it; that is how a healthy service freezes under load.
Concurrency is structuring work as tasks that overlap; parallelism is running them at the same instant on several cores. Use a bounded pool to cap work, a bounded queue for , atomics for single counters, and an when the work mostly waits.
Concurrency is not parallelism
Concurrency is dealing with many things at once: one cook switching between pots. Parallelism is doing many at once: several cooks.
Read more · 83 words
More workers do not scale forever, because some work is serial. Amdahl's law: if a fraction p is parallel, n workers give a speedup of 1 / (1 - p + p/n).
At 90% parallel, 8 workers give 4.7x and no number of workers beats 10x.
- 99% parallel
- 90% parallel
- 50% parallel
Show data table
| Workers | 99% parallel (x) | 90% parallel (x) | 50% parallel (x) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1.98 | 1.82 | 1.33 |
| 4 | 3.88 | 3.08 | 1.6 |
| 8 | 7.48 | 4.71 | 1.78 |
| 16 | 13.91 | 6.4 | 1.88 |
| 32 | 24.43 | 7.8 | 1.94 |
| 64 | 39.26 | 8.77 | 1.97 |
// Amdahl's law: if a fraction p of the work can run in parallel, n workers give this speedup.
export const speedup = (p, n) => 1 / (1 - p + p / n);
// With unlimited workers only the serial part is left.
export const limit = (p) => 1 / (1 - p);Thread pools
A thread per task is slow and unbounded.
Read more · 113 words
A keeps a fixed set of workers and a queue of tasks.
Size it from the work: about one worker per core for CPU-bound tasks, and cores × (1 + wait / compute) for tasks that wait. Eight cores with 90 ms of waiting per 10 ms of compute allow about 80.
Bound the queue too. An unbounded one hides overload until memory runs out; a bounded one forces a choice: reject, block the caller, or run on the caller's thread. The same sizing applies to database connection pools.
// How many workers? CPU-bound work: about one per core. Work that waits: more, in
// proportion to wait time over compute time.
export const poolSize = (cores, waitMs, computeMs) => Math.round(cores * (1 + waitMs / computeMs));
// A fixed number of workers pulling from a bounded queue. When both are full,
// reject at once instead of letting the queue (and memory) grow without limit.
export class Pool {
#running = 0;
#queue = [];
constructor(size, maxQueue) {
this.size = size;
this.maxQueue = maxQueue;
}
submit(task) {
return new Promise((resolve, reject) => {
if (this.#running >= this.size && this.#queue.length >= this.maxQueue) {
reject(new Error('rejected: queue full'));
return;
}
this.#queue.push({ task, resolve, reject });
this.#next();
});
}
#next() {
while (this.#running < this.size && this.#queue.length > 0) {
const { task, resolve, reject } = this.#queue.shift();
this.#running += 1;
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
this.#running -= 1;
this.#next();
});
}
}
}Producer-consumer
Producers add work, take it, and a bounded buffer sits between them.
Read more · 173 words
A full buffer makes the producer wait (); an empty one makes the consumer wait.
Push the producer rate above the consumer rate and watch the backlog grow. Then add consumers.
In threaded code this is a plus two , or two . are the same idea across machines.
// Producer-consumer with a bounded buffer. A full buffer makes put() wait (backpressure);
// an empty one makes take() wait. Waiting hands the thread back instead of spinning.
export class BoundedQueue {
#items = [];
#takers = []; // consumers waiting for an item
#putters = []; // producers waiting for room, with the item they hold
constructor(capacity) {
this.capacity = capacity;
}
async put(item) {
if (this.#takers.length > 0) {
this.#takers.shift()(item); // a consumer is already waiting: hand it over
return;
}
if (this.#items.length < this.capacity) {
this.#items.push(item);
return;
}
await new Promise((resume) => this.#putters.push({ item, resume })); // full: wait for a take()
}
async take() {
if (this.#items.length > 0) {
const item = this.#items.shift();
const waiting = this.#putters.shift();
if (waiting) {
this.#items.push(waiting.item); // room appeared: the oldest blocked producer goes in
waiting.resume();
}
return item;
}
return new Promise((resolve) => this.#takers.push(resolve)); // empty: wait for a put()
}
}Atomics and compare-and-swap
For one shared number, a lock is heavy.
Read more · 103 words
An atomic operation runs as one indivisible step in hardware. The building block is compare-and-swap (): write a new value only if the cell still holds the value you read, otherwise retry.
The test runs four real threads doing 5,000 increments each and always ends at 20,000. This is lock-free: a stalled thread cannot block the others. The costs: heavy contention means many retries, and CAS protects one word, so two fields still need a lock.
// Compare-and-swap: write `next` only if the cell still holds `seen`. No lock, no waiting:
// if another thread got there first, read again and retry. `cells` is an Int32Array on a SharedArrayBuffer.
export function update(cells, i, fn) {
for (;;) {
const seen = Atomics.load(cells, i);
const next = fn(seen);
if (Atomics.compareExchange(cells, i, seen, next) === seen) return next; // nobody changed it
// someone else wrote in between: our `next` was based on stale data, so loop
}
}Async versus threads, and the event loop
An runs one thread that takes callbacks from a queue.
Read more · 70 words
While a request waits for the network, the loop runs something else, so one thread serves thousands of connections. Promise callbacks (microtasks) run before the next timer or I/O callback.
The price is that anything that blocks the thread blocks everyone. The test shows a 0 ms timer arriving 40 ms late because of one busy loop. Async is the right model for waiting, threads or processes for computing.
// Order of execution on a single-threaded event loop: the current code finishes first,
// then queued promise callbacks (microtasks), then the next timer or I/O callback.
export function order() {
return new Promise((done) => {
const log = [];
setTimeout(() => {
log.push('timer');
done(log);
}, 0);
Promise.resolve().then(() => log.push('promise'));
queueMicrotask(() => log.push('microtask'));
log.push('sync');
});
}
// Busy-waiting on the one thread stops everything else, including a timer due in 0 ms.
export function blockLoop(ms) {
const end = Date.now() + ms;
while (Date.now() < end);
}Is the work mostly waiting on the network or disk?
Memory visibility and ordering
Locks do more than exclude: they also make writes visible.
Read more · 116 words
Each core has its own , and compilers and CPUs reorder operations, so without synchronisation one thread may not see another's write, or may see writes in a different order.
- A memory model (Java's, C++'s) defines which writes a read must see. A lock release happens-before the next acquire.
volatilein Java guarantees visibility and ordering of one variable, not atomic read-modify-write.- False sharing: two threads write different variables on the same cache line, so the line bounces between cores. Padding fixes it.
- Double-checked locking without
volatileis the classic broken pattern.
Common failures
Read more · 127 words
- Unbounded queue. The service looks healthy until it runs out of memory under load.
- Blocking the . One synchronous parse of a large JSON body stalls every request.
- Swallowed exceptions. An error in a pool task vanishes unless you read the returned future.
- Thread leaks. Pools created per request and never shut down.
Follow-up questions
What is the difference between concurrency and parallelism?
Concurrency is structuring tasks so they can overlap in time, even on one core. Parallelism is running them simultaneously on several cores.
How would you size a thread pool?
CPU-bound: about the number of cores. Blocking I/O: cores × (1 + wait / compute). Bound the queue and measure.
What is backpressure?
A signal that slows a fast producer when the cannot keep up, such as a full bounded buffer. Without it queues grow until memory runs out.
What is compare-and-swap, and what is the ABA problem?
writes only if the value is still what you read. ABA is a value that changes A to B and back to A, so CAS succeeds although state changed. Version counters avoid it.
Check yourself
Question 1 of 7Score 2 out of 3 or better to mark this page complete.