Coding Interview Patterns Module 31 – Concurrency coding problems
Running tasks with a concurrency limit
Run many async tasks with at most k at once: a pool of k workers or a semaphore, results kept in input order, and why batches of k finish later.
What you will learn
- Run at most k tasks at a time with a pool of workers or a semaphore
- Store results by input index so the output keeps input order
- Explain with measured finish times why batches of k leave slots idle
- Handle a failing task without losing a worker or a permit
- Choose k from a rate limit and a latency with Little's law
Before you start
What you’ll learn · Before you start
What you will learn
- Run at most k tasks at a time with a pool of workers or a semaphore
- Store results by input index so the output keeps input order
- Explain with measured finish times why batches of k leave slots idle
- Handle a failing task without losing a worker or a permit
- Choose k from a rate limit and a latency with Little's law
Before you start
On this page
Running tasks with a concurrency limit means starting many independent jobs, such as downloads, API calls or image resizes, while never letting more than k of them run at the same moment. The limit protects what the tasks share: a partner API that accepts 20 requests in flight, a pool of 10 database connections, or the memory that each running task holds. Two designs keep it. In a pool, k workers repeat one loop: take the index of the next task that nobody has started, run it, and store its result at that index. With a semaphore, every task takes one of k permits before it runs and gives it back when it ends. Both store results by input index, so the output keeps input order while the tasks finish in any order. Both also beat the usual first attempt, batches of k, which leaves slots idle while each batch waits for its slowest task.
Why batches of k waste the limit
The first attempt usually splits the tasks into groups of k and awaits each group with asyncio.gather or
Promise.all before it starts the next. It never runs more than k tasks, but a batch lasts as long as its slowest
task, and every slot whose task ended early stays empty until then. A pool fills a slot the moment it frees up:
Six downloads of 8, 3, 3, 6, 1 and 5 seconds, at most 3 at a time
Text description of the diagram
Two timelines of the same six downloads, r1 to r6, which take 8, 3, 3, 6, 1 and 5 seconds. Each has three lanes, one per slot, and a time axis from 0 to 14 seconds.
Batches of 3: r1, r2 and r3 start at 0. r2 and r3 end at 3 seconds, but their slots stay empty until r1 ends at 8, because the next batch starts only when the whole batch has finished. Then r4, r5 and r6 start at 8 and the last of them, r4, ends at 14 seconds.
A pool of 3 workers: r1, r2 and r3 start at 0. When r2 and r3 end at 3 seconds, their workers start r4 and r5 at once; r5 ends at 4 and its worker starts r6. r1 ends at 8, and r4 and r6 both end at 9 seconds. The same six downloads finish 5 seconds sooner, with no slot left empty while a download was waiting.
The same six downloads take 14 seconds in batches and 9 seconds in a pool, with the same limit of 3. Starting every
task at once, the other common mistake, would be faster still, but it ignores the limit: Promise.all(urls.map(fetch))
starts every request before the first one has answered.
Twenty downloads, five ways, on a fake clock
The program below downloads 20 synthetic reports that take between 1 and 8 seconds each, and runs them five ways with a limit of 3. The seconds are simulated by a small fake clock, which moves time forward only when every task is waiting, so the program finishes at once and prints the same times on every computer.
pool/pool.py
"""Twenty report downloads, at most 3 at a time: five ways to run them, timed on a fake clock."""
import asyncio
from fake_clock import FakeClock
from reports import download_tasks
async def run_limited(tasks, k):
"""Runs every task, at most k at a time, and returns the results in input order."""
results = [None] * len(tasks)
next_index = 0
async def worker():
nonlocal next_index
while next_index < len(tasks):
i = next_index # take the next task: no await between this
next_index += 1 # read and this write, so no other worker gets i
results[i] = await tasks[i]()
await asyncio.gather(*(worker() for _ in range(min(k, len(tasks)))))
return results
async def with_semaphore(tasks, k):
"""Starts a task for every download at once; each waits for one of k permits."""
permits = asyncio.Semaphore(k)
async def limited(task):
async with permits: # released even when the task raises
return await task()
return await asyncio.gather(*(limited(t) for t in tasks))
async def one_at_a_time(tasks, k):
return [await t() for t in tasks]
async def all_at_once(tasks, k):
return await asyncio.gather(*(t() for t in tasks))
async def in_batches(tasks, k):
results = []
for start in range(0, len(tasks), k):
results += await asyncio.gather(*(t() for t in tasks[start:start + k]))
return results
async def main():
expected = [f"report-{i:02}.csv" for i in range(1, 21)]
print("way to run them finish most at once results in input order")
for name, run in [("one at a time", one_at_a_time), ("all at once", all_at_once),
("batches of 3", in_batches), ("pool of 3 workers", run_limited),
("semaphore of 3", with_semaphore)]:
clock = FakeClock()
tasks = download_tasks(clock)
results = await clock.run(run(tasks, 3))
print(f"{name:20} {clock.now:6} s {clock.most:14} {'yes' if results == expected else 'no'}")
asyncio.run(main()) Output
way to run them finish most at once results in input order one at a time 66 s 1 yes all at once 8 s 20 yes batches of 3 37 s 3 yes pool of 3 workers 25 s 3 yes semaphore of 3 25 s 3 yes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool.py
pool/fake_clock.py
"""A fake clock for asyncio: simulated seconds that pass only while every task waits.
Tasks call `await clock.sleep(seconds)` where real code would wait for a download.
`await clock.run(main())` runs a program: each time every task is waiting, it moves the
time on to the earliest wake-up and wakes that sleeper. Nothing really waits, so the
times a program prints are the same in every run and on every computer.
"""
import asyncio
import heapq
import itertools
# Turns of the event loop that let every task run on until it waits again. The programs
# of this lesson need at most 6; 12 leave room for a longer chain of awaits.
SETTLE_TURNS = 12
class FakeClock:
def __init__(self):
self.now = 0
self._sleepers = [] # (wake-up time, order, future), earliest first
self._order = itertools.count()
async def sleep(self, seconds):
wake = asyncio.get_running_loop().create_future()
heapq.heappush(self._sleepers, (self.now + seconds, next(self._order), wake))
await wake
async def run(self, main):
"""Runs the coroutine main to its end, and returns its result or raises its error."""
outcome = {}
async def capture():
try:
outcome["result"] = await main
except Exception as error: # handed to the caller below
outcome["error"] = error
task = asyncio.ensure_future(capture())
while True:
for _ in range(SETTLE_TURNS):
await asyncio.sleep(0)
if task.done():
if "error" in outcome:
raise outcome["error"]
return outcome["result"]
if not self._sleepers:
task.cancel()
raise RuntimeError(f"stuck at {self.now} s: every task waits, and none of them for the clock")
self.now, _, wake = heapq.heappop(self._sleepers)
wake.set_result(None) pool/reports.py
"""Twenty synthetic report downloads: report i takes SECONDS[i] simulated seconds."""
SECONDS = [8, 3, 3, 6, 1, 5, 2, 1, 2, 1, 3, 5, 2, 4, 6, 1, 8, 2, 1, 2]
def download_tasks(clock, seconds=SECONDS):
"""One task per report: a function that starts the download when called. Each task
records how many downloads are running at once, in clock.running and clock.most."""
clock.running = clock.most = 0
def task(i):
async def download():
clock.running += 1
clock.most = max(clock.most, clock.running)
await clock.sleep(seconds[i])
clock.running -= 1
return f"report-{i + 1:02}.csv"
return download
return [task(i) for i in range(len(seconds))] Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
One at a time takes 66 seconds, the sum of all the downloads. All at once takes 8 seconds, the longest download, with 20 in flight against a limit of 3. Batches of 3 take 37 seconds, and the pool and the semaphore take 25. No schedule with 3 slots can beat 22 seconds, the 66 seconds of work shared by 3, so the pool comes within 3 seconds of the best possible, while batches take half as long again as the pool.
The pool and the semaphore in code
In run_limited, each worker reads next_index, moves it on, and only then awaits its task. asyncio runs one task at
a time and switches only at an await, so no two workers can take the same index: nothing awaits between the read
and the write. The result goes to results[i], its input slot, and asyncio.gather waits for every worker.
with_semaphore creates a task for every download at once, and each one waits at async with permits until one of
the 3 permits is free: asyncio.Semaphore keeps a counter that acquire lowers and release raises, and it blocks
at zero.
The same pool in JavaScript is the promise pool that front-end interviews ask for. JavaScript also runs one piece of
code at a time and switches only at an await, so next++ hands every index to exactly one worker:
pool/pool.mjs
// Twenty report downloads, at most 3 at a time: four ways to run them, timed on a fake clock.
import { FakeClock } from './fake_clock.mjs';
import { downloadTasks } from './reports.mjs';
// Runs every task, at most k at a time, and resolves to the results in input order.
export async function runLimited(tasks, k) {
const results = new Array(tasks.length);
let next = 0;
async function worker() {
while (next < tasks.length) {
const i = next++; // read and increment with no await between: no other worker gets i
results[i] = await tasks[i]();
}
}
const workers = Array.from({ length: Math.min(k, tasks.length) }, worker);
await Promise.all(workers);
return results;
}
async function oneAtATime(tasks) {
const results = [];
for (const t of tasks) results.push(await t());
return results;
}
const allAtOnce = (tasks) => Promise.all(tasks.map((t) => t()));
async function inBatches(tasks, k) {
const results = [];
for (let start = 0; start < tasks.length; start += k) {
results.push(...(await Promise.all(tasks.slice(start, start + k).map((t) => t()))));
}
return results;
}
const expected = Array.from({ length: 20 }, (_, i) => `report-${String(i + 1).padStart(2, '0')}.csv`);
console.log('way to run them finish most at once results in input order');
for (const [name, run] of [['one at a time', oneAtATime], ['all at once', allAtOnce], ['batches of 3', inBatches], ['pool of 3 workers', runLimited]]) {
const clock = new FakeClock();
const tasks = downloadTasks(clock);
const results = await clock.run(run(tasks, 3));
const same = results.length === expected.length && results.every((r, i) => r === expected[i]);
console.log(`${name.padEnd(20)} ${String(clock.now).padStart(6)} s ${String(clock.most).padStart(14)} ${same ? 'yes' : 'no'}`);
} Output
way to run them finish most at once results in input order one at a time 66 s 1 yes all at once 8 s 20 yes batches of 3 37 s 3 yes pool of 3 workers 25 s 3 yes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pool.mjs
pool/fake_clock.mjs
// A fake clock for promises: simulated seconds that pass only while every task waits.
//
// Tasks call `await clock.sleep(seconds)` where real code would wait for a download.
// `await clock.run(promise)` runs a program: each time every task is waiting (no promise
// job is left to run), it moves the time on to the earliest wake-up and wakes that
// sleeper. Nothing really waits, so the times a program prints are the same in every run.
export class FakeClock {
now = 0;
#sleepers = []; // { at, order, wake }, kept earliest first
#order = 0;
sleep(seconds) {
return new Promise((wake) => {
this.#sleepers.push({ at: this.now + seconds, order: this.#order++, wake });
this.#sleepers.sort((a, b) => a.at - b.at || a.order - b.order);
});
}
async run(promise) {
let done = false;
promise.then(() => { done = true; }, () => { done = true; });
for (;;) {
// A timer fires only once every promise job has run: every task now waits.
await new Promise((settled) => setTimeout(settled, 0));
if (done) return promise;
const next = this.#sleepers.shift();
if (!next) throw new Error(`stuck at ${this.now} s: every task waits, and none of them for the clock`);
this.now = next.at;
next.wake();
}
}
} pool/reports.mjs
// Twenty synthetic report downloads: report i takes SECONDS[i] simulated seconds.
export const SECONDS = [8, 3, 3, 6, 1, 5, 2, 1, 2, 1, 3, 5, 2, 4, 6, 1, 8, 2, 1, 2];
// One task per report: a function that starts the download when called. Each task
// records how many downloads are running at once, in clock.running and clock.most.
export function downloadTasks(clock, seconds = SECONDS) {
clock.running = 0;
clock.most = 0;
return seconds.map((s, i) => async () => {
clock.running += 1;
clock.most = Math.max(clock.most, clock.running);
await clock.sleep(s);
clock.running -= 1;
return `report-${String(i + 1).padStart(2, '0')}.csv`;
});
} Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
| Criterion | Batches of k | Semaphore of k | Pool of k workers |
|---|---|---|---|
| Tasks created up front | k, batch by batch | All of them | k workers |
| Slots left idle | Until the slowest task of each batch ends | None | None |
| Memory for a million tasks | k tasks at a time | A million waiting tasks | k workers and one index |
| Results in input order | Yes | Yes, from gather | Yes, stored by index |
| When to choose | Only when each batch must end before the next starts | When the tasks already exist and only one step inside them is limited | Long task lists, and most interview answers |
Failures, sizing and threads
A working pool still has to decide what a failed task means, how large k should be, and how the design changes with real threads:
- Fail fast stops handing out tasks after the first error and reports that error, as
Promise.allrejects with the first rejection. - Collect every outcome runs every task and returns a result or an error for each index, as
Promise.allSettleddoes. - Cancel the rest also stops the tasks already running, which
asyncio.TaskGroupdoes when one of its tasks fails. - Little’s law says that the average number of tasks in flight equals the rate at which they start times the time each one takes.
- With threads, in Go, Java or C++, taking the next index needs an atomic or a lock, because two workers really can read it at the same moment.
Practise: run_limited
Write the pool yourself, in Python or JavaScript. The sample tests check the order of the results and the limit, and that a free slot is filled at once, which batches fail.
Exercise · Medium · Python, JavaScript
Run every task, at most k at a time, in input order
Write run_limited(tasks, k) (runLimited(tasks, k) in JavaScript). tasks is a list of functions that take no arguments and return an awaitable: calling tasks[i]() starts task i. Run every task and return the list of their results, so that:
- no more than
ktasks are running at any moment, andkof them are running wheneverkor more are left to start, so a task starts as soon as a slot frees up, not when a whole batch has finished; - tasks start in input order: task
istarts before taski + 1; - the results come back in input order, although the tasks finish in any order;
- a
kbelow 1 raisesValueError(in JavaScript, the promise rejects with aRangeError), and an empty list gives an empty list.
No task raises in this exercise: failures are a topic of their own.
results = await run_limited([fetch_a, fetch_b, fetch_c, fetch_d], 2)
# fetch_a and fetch_b start at once; fetch_c starts the moment either of them finishes
The tasks in the tests wait for a number of turns of the event loop and record how many of them are running, so the tests can see whether the limit holds and whether a free slot is filled at once. Run the sample tests when you are done.
Python · Starter code · limited.py
async def run_limited(tasks, k):
"""Runs every task, at most k at a time, and returns their results in input order.
tasks is a list of functions that take no arguments; calling tasks[i]() starts task i
and gives an awaitable. Fix it: this version runs one task at a time.
"""
results = []
for task in tasks:
results.append(await task())
return results The sample tests · test_limited.py
import asyncio
import random
from limited import run_limited
class Probe:
"""Makes tasks that wait a number of turns of the event loop, and records what happens:
how many run at once, and the order in which tasks start and end."""
def __init__(self):
self.running = 0
self.most = 0
self.events = []
def task(self, i, turns):
async def run():
self.events.append(("start", i))
self.running += 1
self.most = max(self.most, self.running)
for _ in range(turns):
await asyncio.sleep(0)
self.running -= 1
self.events.append(("end", i))
return f"result {i}"
return run
def starts(self):
return [i for kind, i in self.events if kind == "start"]
async def test_results_in_input_order():
"""the results keep input order, although later tasks finish first"""
probe = Probe()
tasks = [probe.task(i, turns) for i, turns in enumerate([6, 1, 4, 1, 3, 2])]
assert await run_limited(tasks, 2) == [f"result {i}" for i in range(6)]
async def test_never_more_than_k_and_k_at_once():
"""30 tasks with k = 4: at most 4 run at once, and 4 do run at once"""
probe = Probe()
tasks = [probe.task(i, (i * 7) % 5 + 1) for i in range(30)]
await run_limited(tasks, 4)
assert probe.most == 4
async def test_a_free_slot_is_filled_at_once():
"""k = 2, one long task and four short ones: the short ones all run while the long one does"""
probe = Probe()
tasks = [probe.task(0, 20)] + [probe.task(i, 1) for i in range(1, 5)]
await run_limited(tasks, 2)
assert probe.events.index(("start", 4)) < probe.events.index(("end", 0))
async def test_tasks_start_in_input_order():
"""task i starts before task i + 1"""
probe = Probe()
tasks = [probe.task(i, (i * 3) % 4 + 1) for i in range(12)]
await run_limited(tasks, 3)
assert probe.starts() == list(range(12))
async def test_k_of_one_runs_one_at_a_time():
"""k = 1 runs the tasks one after another"""
probe = Probe()
tasks = [probe.task(i, 2) for i in range(5)]
assert await run_limited(tasks, 1) == [f"result {i}" for i in range(5)]
assert probe.most == 1
async def test_k_larger_than_the_number_of_tasks():
"""k = 10 and 3 tasks: all 3 run at once"""
probe = Probe()
tasks = [probe.task(i, 3) for i in range(3)]
assert await run_limited(tasks, 10) == ["result 0", "result 1", "result 2"]
assert probe.most == 3
async def test_no_tasks():
"""an empty list gives an empty list"""
assert await run_limited([], 3) == []
async def test_k_below_one():
"""k = 0 is an error"""
try:
await run_limited([Probe().task(0, 1)], 0)
except ValueError:
return
raise AssertionError("run_limited(tasks, 0) should raise ValueError")
async def test_random_rounds():
"""200 random rounds: right results, at most k at once, and min(k, n) at once"""
rng = random.Random(7)
for _ in range(200):
n = rng.randint(1, 15)
k = rng.randint(1, 6)
probe = Probe()
tasks = [probe.task(i, rng.randint(0, 5)) for i in range(n)]
assert await run_limited(tasks, k) == [f"result {i}" for i in range(n)]
assert probe.most <= k
assert probe.starts() == list(range(n)) JavaScript · Starter code · limited.mjs
/**
* Runs every task, at most k at a time, and resolves to their results in input order.
*
* tasks is an array of functions that take no arguments; calling tasks[i]() starts task i
* and gives a promise. Fix it: this version runs one task at a time.
*/
export async function runLimited(tasks, k) {
const results = [];
for (const task of tasks) results.push(await task());
return results;
} The sample tests · limited.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { runLimited } from './limited.mjs';
// Makes tasks that wait a number of turns (promise jobs), and records what happens: how
// many run at once, and the order in which tasks start and end.
function probe() {
const p = { running: 0, most: 0, events: [] };
p.task = (i, turns) => async () => {
p.events.push(`start ${i}`);
p.running += 1;
p.most = Math.max(p.most, p.running);
for (let t = 0; t < turns; t++) await null;
p.running -= 1;
p.events.push(`end ${i}`);
return `result ${i}`;
};
p.starts = () => p.events.filter((e) => e.startsWith('start')).map((e) => Number(e.slice(6)));
return p;
}
const expected = (n) => Array.from({ length: n }, (_, i) => `result ${i}`);
const range = (n) => Array.from({ length: n }, (_, i) => i);
test('the results keep input order, although later tasks finish first', async () => {
const p = probe();
const tasks = [6, 1, 4, 1, 3, 2].map((turns, i) => p.task(i, turns));
assert.deepEqual(await runLimited(tasks, 2), expected(6));
});
test('30 tasks with k = 4: at most 4 run at once, and 4 do run at once', async () => {
const p = probe();
await runLimited(range(30).map((i) => p.task(i, ((i * 7) % 5) + 1)), 4);
assert.equal(p.most, 4);
});
test('k = 2, one long task and four short ones: the short ones all run while the long one does', async () => {
const p = probe();
await runLimited([p.task(0, 20), ...[1, 2, 3, 4].map((i) => p.task(i, 1))], 2);
assert.ok(p.events.indexOf('start 4') < p.events.indexOf('end 0'), p.events.join(', '));
});
test('task i starts before task i + 1', async () => {
const p = probe();
await runLimited(range(12).map((i) => p.task(i, ((i * 3) % 4) + 1)), 3);
assert.deepEqual(p.starts(), range(12));
});
test('k = 1 runs the tasks one after another', async () => {
const p = probe();
assert.deepEqual(await runLimited(range(5).map((i) => p.task(i, 2)), 1), expected(5));
assert.equal(p.most, 1);
});
test('k = 10 and 3 tasks: all 3 run at once', async () => {
const p = probe();
assert.deepEqual(await runLimited(range(3).map((i) => p.task(i, 3)), 10), expected(3));
assert.equal(p.most, 3);
});
test('an empty list gives an empty list', async () => {
assert.deepEqual(await runLimited([], 3), []);
});
test('k = 0 is an error', async () => {
await assert.rejects(() => runLimited([probe().task(0, 1)], 0), RangeError);
});
test('200 random rounds: right results, at most k at once, and min(k, n) at once', async () => {
let seed = 7;
const next = (lo, hi) => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return lo + (seed % (hi - lo + 1));
};
for (let round = 0; round < 200; round++) {
const n = next(1, 15);
const k = next(1, 6);
const p = probe();
const tasks = range(n).map((i) => p.task(i, next(0, 5)));
assert.deepEqual(await runLimited(tasks, k), expected(n));
assert.ok(p.most <= k, `most ${p.most} > k ${k}`);
assert.deepEqual(p.starts(), range(n));
}
}); A hint
Start k workers (or fewer, when there are fewer tasks) and let each one loop: take the index of the next task that nobody has started, run that task, store its result at that index, and take the next one, until no task is left. Taking an index is safe without a lock as long as nothing awaits between reading the shared index and moving it on: asyncio and JavaScript switch to another task only at an await. Wait for all the workers together with asyncio.gather or Promise.all.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Failures without lost workers or permits, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
A pool that works while every task succeeds can fail badly on the first task that does not. This part reproduces two leaks that lose work without a word, then runs the three failure policies on the same failing downloads, in Python and in JavaScript.
In this part:
- Two leaks reproduced: lost workers and lost permits
- Fail fast, collect every outcome or cancel the rest, measured
- The promise pool, failing fast and settling every task
3 runnable examples (fake_clock.mjs, fake_clock.py, leaks.py, policies.py, reports.mjs, reports.py, settled.mjs) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing k from a rate limit and a latency, with numbers Pro: opens with a Pro, Premium or Ultimate pass
The limit is a number you can work out before you run anything. This part derives it from Little’s law, checks the arithmetic on the fake clock, and shows when a concurrency limit is not enough on its own.
In this part:
- Little’s law, worked for a partner API
- Measured: finish time against k for the 20 reports
- A concurrency limit is not a rate limit, measured
2 runnable examples (fake_clock.py, rate.py, reports.py, sweep.py) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
The pool with real threads in Go, Java and C++, under race detectors Pro: opens with a Pro, Premium or Ultimate pass
With threads, two workers really can read the shared index at the same moment, so taking a task needs an atomic, a lock or a channel. This part writes both designs in Go, Java and C++, runs them under Go’s race detector and ThreadSanitizer, and compares what each language makes easy.
In this part:
- The same twenty downloads in three languages, run
- Go: worker goroutines, or a channel of tokens
- Java: a fixed pool, or virtual threads with a Semaphore
- C++: an atomic index and a counting semaphore
- Choosing k and the design, waiting work against busy work
2 runnable examples (C++, Go, Java, pool_session.py, session.py) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
How does a concurrency limit differ from a rate limit, and when do you need both?
A concurrency limit caps how many tasks are in flight at the same moment, such as 20 open requests. A rate limit caps how many start in a period of time, such as 30 requests a second. Little's law links them: k tasks in flight that each take W seconds finish at most k ÷ W a second. With 20 in flight and calls of 400 ms that is 50 a second, so a partner that also accepts only 30 a second still refuses part of the traffic. Fast calls break a rate limit while a concurrency limit holds; slow calls reach the concurrency limit long before the rate limit. You need both when the other side states both, or when calls can finish faster than the rate allows: keep k workers for the cap on calls in flight, and space out the starts for the rate.
-
Mid to senior
A nightly job looks up 50,000 orders in a partner's API, which allows 20 requests in flight and answers in about 400 ms. How long will the job take, how do you write the loop, and what do you do when some calls fail?
-
Fresher to mid
A web page uploads 10,000 photos with Promise.all(photos.map(upload)). Users report frozen tabs and failed uploads. Rewrite it so that at most 6 uploads run at once, the results keep their order, and one failed photo does not lose the others.
-
Senior to staff
A crawler runs 8 workers that take URLs from a shared queue, and every page they fetch can add new links to it. How do you know when the crawl has finished, and how do you keep its memory bounded?
Key takeaways
- A concurrency limit keeps at most k tasks running: k workers that share an index, or a semaphore of k permits.
- Store each result at its input index, and the output keeps input order whatever order the tasks finish in.
- Batches of k waste the limit: in the recorded run they took 37 seconds where a pool took 25.
- In asyncio and JavaScript, taking the next index is safe because nothing awaits between its read and its write.
- A semaphore creates every task up front; a pool creates k workers.
References
- asyncio: Coroutines and Tasks (gather, TaskGroup) (Python Software Foundation)
- asyncio: Synchronization Primitives (Semaphore) (Python Software Foundation)
- ECMAScript Language Specification: Promise.all and Promise.allSettled (Ecma International (TC39))
- Fetch Standard: the fetch() method and its signal (WHATWG)
- DOM Standard: Interface AbortController (WHATWG)
- A Proof for the Queuing Formula: L = λW (J. D. C. Little, Operations Research 9(3), 1961) (INFORMS)
- Bounds for Certain Multiprocessing Anomalies (R. L. Graham, Bell System Technical Journal 45(9), 1966) (Nokia Bell Labs (Wiley))
- Bounds on Multiprocessing Timing Anomalies (R. L. Graham, SIAM Journal on Applied Mathematics 17(2), 1969) (Society for Industrial and Applied Mathematics)
- Package sync (WaitGroup.Go) (The Go Authors)
- The Go Programming Language Specification: Channel types and close (The Go Authors)
- Java SE 25 API: ExecutorService (Oracle)
- Java SE 25 API: Semaphore (Oracle)
- JEP 444: Virtual Threads (OpenJDK)
- C++ working draft N4950: Counting semaphores [thread.sema.cnt] (ISO/IEC JTC1/SC22/WG21 (HTML rendering of the working draft))
- ThreadSanitizer (The Clang Team (LLVM Project))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress