Operating systems and concurrency · Lesson 4 of 12 · 25 min

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.

✓ Size a thread pool from the workload✓ Explain backpressure with a bounded producer-consumer queue✓ Choose between async, threads and processes
Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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.

Speedup by number of workers
  • 99% parallel
  • 90% parallel
  • 50% parallel
010203040x1248163264Workers99% parallel90% parallel50% parallel
Show data table
Speedup by number of workers
Workers99% parallel (x)90% parallel (x)50% parallel (x)
1111
21.981.821.33
43.883.081.6
87.484.711.78
1613.916.41.88
3224.437.81.94
6439.268.771.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.

Interactive

Queue simulator

When producers outpace consumers the backlog grows without limit. Add workers or make jobs faster, then hit the spike.

Producer30/s0 waiting● emptybacklog,last 60 s0/4 workers busytop 10
Backlog
0 jobs
steady
Arrivals / capacity
30 / 40
jobs/s · 4 × 1000 ÷ 100
Utilisation
0%
0 of 4 workers
Wait (Little's law)
0 ms
backlog ÷ throughput

Queue empty: 4 workers finish up to 40 jobs/s and 30 arrive, so utilisation is 0% and jobs barely wait.

Chart: queue depth over time
Queue depth over time
00.30.50.81jobs0Time (s)
Show data table
Queue depth over time
Time (s)Queue depth (jobs)
00
// 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);
}
Async, threads or processes?yesnoyesyesnonoA bounded thread poolAsync I/O on an eventloopDoes a library you needblock the thread?A bounded thread poolSeparate processes orworkersDoes the work need toshare memory?Is the work mostlywaiting on the networkor disk?

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.
  • volatile in 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 volatile is 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 7

Score 2 out of 3 or better to mark this page complete.

1. A job is 90% parallel. What is the best possible speedup with unlimited workers?

Next: Next, networking: what happens between typing a URL and the first byte coming back.