Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Go 1.27.1, Java 25 and Clang 21.0.0
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: Why batches of k waste the limit · Twenty downloads, five ways, on a fake clock · The pool and the semaphore in code · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

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

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:

Two timelines of six downloads with 3 slots: batches of 3 finish at 14 seconds, a pool of 3 workers at 9 seconds.Batches of 3: done at 14 sslot 1r1r4slot 2r2r5slot 3r3r602468101214A pool of 3 workers: done at 9 sworker 1r1worker 2r2r4worker 3r3r5r602468101214

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.

Twenty downloads with a limit of 3, five ways

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))]

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:

The promise pool in JavaScript

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`;
  });
}
Three ways to keep at most k tasks running
Criterion Batches of kSemaphore of kPool of k workers
Tasks created up front k, batch by batchAll of themk workers
Slots left idle Until the slowest task of each batch endsNoneNone
Memory for a million tasks k tasks at a timeA million waiting tasksk workers and one index
Results in input order YesYes, from gatherYes, stored by index
When to choose Only when each batch must end before the next startsWhen the tasks already exist and only one step inside them is limitedLong 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.all rejects with the first rejection.
  • Collect every outcome runs every task and returns a result or an error for each index, as Promise.allSettled does.
  • Cancel the rest also stops the tasks already running, which asyncio.TaskGroup does 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 k tasks are running at any moment, and k of them are running whenever k or 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 i starts before task i + 1;
  • the results come back in input order, although the tasks finish in any order;
  • a k below 1 raises ValueError (in JavaScript, the promise rejects with a RangeError), 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.

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.

JavaScript & TypeScript Online Compiler Paste pool.mjs and its two helpers, then change the limit or the download times and watch the finish times move.

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.

  1. Question 1 of 4 Batches of k and a pool of k workers both keep at most k tasks running. Why do batches usually finish later?

    Choose one answer.

    Show the answer to question 1

    Answer: Each batch waits for its slowest task, so the slots whose tasks ended early stay empty until then

    A batch is awaited as a whole with gather or Promise.all, so the next batch starts only when the slowest task of this one ends. Every slot whose task finished early sits idle until then. A pool hands a free slot the next task at once: in the lesson's run, the same 20 downloads took 37 seconds in batches of 3 and 25 seconds in a pool of 3.

  2. Question 2 of 4 Eight tasks take 4, 1, 1, 1, 1, 4, 1 and 1 seconds, in that order. A pool of 2 workers runs them, each worker taking the next task the moment it is free. When does the last task end?

    Type a number.

    Show the answer to question 2

    Answer: 8 seconds

    Worker 1 runs the first task from 0 to 4. Worker 2 runs the four 1-second tasks from 0 to 4. At 4 both are free: one takes the 4-second task (4 to 8), the other the last two 1-second tasks (4 to 6). The last task ends at 8 seconds. Batches of 2 would take 4 + 1 + 4 + 1 = 10 seconds.

  3. Question 3 of 4 Three of these workers run at once in asyncio. What goes wrong?

    Read the code, then choose one answer.

    async def worker():
        global next_index
        while next_index < len(tasks):
            i = next_index
            await log(f"taking task {i}")
            next_index = i + 1
            results[i] = await tasks[i]()
    Show the answer to question 3

    Answer: Two workers can take the same task, because other workers run during the await between reading next_index and moving it on

    asyncio switches tasks only at an await, and here there is one between the read of next_index and the write. While the first worker waits for log(), the other two read the same next_index, and all three run task i. Move the index on before any await (i = next_index; next_index += 1), and the read and the write happen with no other worker in between.

  4. Question 4 of 4 You have a million tasks and k = 50. Why is a pool of 50 workers usually better than a semaphore around each task?

    Choose one answer.

    Show the answer to question 4

    Answer: The semaphore version creates a million tasks up front, which all wait in memory; the pool creates 50

    With a semaphore, gather starts a task for every item at once, and each one waits at the semaphore: a million task objects, each with its coroutine frame. Both designs keep at most 50 running and both keep input order; the pool simply never creates more than 50 workers and an index.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.