DrillLab

带并发上限的异步任务调度器(Q2)Async task scheduler with a concurrency limit (Q2)

JavaScript困难 · Hard约 45 分钟~45 min浏览器里能跑Runs in the browser
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

只给类型定义和三条要求。自己写出 runTasks, 并自己写一个验证台来证明它对。

You get only the type definitions and three requirements. Write runTasks yourself, and write your own check harness to show that it is right.

验收标准Acceptance criteria
  • runTasks(tasks, limit) 接收一个「函数数组」,每个函数被调用后返回 PromiserunTasks(tasks, limit) takes an array of functions, and each function returns a Promise when it is called
  • 同一时刻最多 limit 个任务在运行;某个结束后立刻启动下一个At most limit tasks run at the same time; as soon as one finishes, start the next
  • 任何任务失败都不能让 runTasks 抛错No failing task may make runTasks throw
  • 返回数组顺序必须与 tasks 一致The order of the returned array must match tasks
  • 成功写 { status: "fulfilled", value },失败写 { status: "rejected", reason }On success write { status: "fulfilled", value }; on failure write { status: "rejected", reason }
  • 自己写一个 demo:6 个任务(其中至少 1 个 reject)、limit = 2,打印实时并发数与最终结果Write your own demo: 6 tasks (at least 1 of which rejects), limit = 2, printing how many run at each moment and the final results

预计 45 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 45 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区Workspace

工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.

需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.

2 个起始文件 · 目标 6 passed2 starter files · target 6 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《实现:worker pool(工人池)》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “实现:worker pool(工人池)” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《实现:worker pool(工人池)》(6 段 · 约 16 分钟)Expand “实现:worker pool(工人池)” (6 sections · ~16 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 实现:worker pool(工人池)

§01

先排除一个直觉上的错解:分批First rule out the answer that feels obvious: fixed batches

「6 个任务、上限 2,那就切成 3 批」—— 这个想法能跑,但不对。Six tasks, a limit of 2, so cut them into 3 batches. That idea runs, but it is wrong.

分批的写法是:取前 2 个跑完,再取接下来 2 个,再取最后 2 个。 并发数确实不会超过 2。

问题在于它浪费时间。每一批都要等那一批里最慢的结束, 才能开始下一批。用 demo 里的真实耗时算一下:

分批要 650ms,worker pool 只要 450ms。 更重要的是它违反了题目原文:A new task may only start after one of the running tasks finishes —— 「其中一个结束就能开下一个」, 不是「这一批都结束」。

所以要的不是分批,是「谁空了谁接着干」

Batching means: take the first 2 and wait for both, then the next 2, then the last 2. Concurrency really never goes above 2.

The problem is that it wastes time. Every batch has to wait for the slowest task in that batch before the next batch can start. Do the arithmetic with the real durations from the demo:

Batching needs 650ms; the worker pool needs only 450ms. More importantly it breaks the wording of the brief: A new task may only start after one of the running tasks finishes — “one of them finishing lets the next one start”, not “the whole batch finishes”.

So what you want is not batching, it is “whoever is free takes the next job”.

Text两种思路的耗时对比How long the two approaches take示意Illustrative
1任务耗时:1→300ms 2→100ms 3→200ms 4→100ms 5→150ms 6→100ms
2
3✗ 分批(每批等最慢的)
4 批1: [1(300), 2(100)] → 等 300ms
5 批2: [3(200), 4(100)] → 等 200ms
6 批3: [5(150), 6(100)] → 等 150ms
7 总计 650ms,而且中间有大量空闲槽位
8
9✓ worker pool(谁空了谁补上)
10 t=0 开 1、2
11 t=100 2 完 → 立刻开 3
12 t=300 1 完 → 立刻开 4 3 还在跑
13 t=300 3 完 → 立刻开 5
14 t=400 4 完 → 立刻开 6
15 t=450 5、6 陆续完
16 总计 ~450ms,槽位几乎没空过
1Task durations: 1→300ms 2→100ms 3→200ms 4→100ms 5→150ms 6→100ms
2
3✗ Batching (each batch waits for its slowest task)
4 batch 1: [1(300), 2(100)] → wait 300ms
5 batch 2: [3(200), 4(100)] → wait 200ms
6 batch 3: [5(150), 6(100)] → wait 150ms
7 650ms in total, and many slots sit idle in between
8
9✓ worker pool (whoever is free takes the next job)
10 t=0 start 1 and 2
11 t=100 2 done → start 3 at once
12 t=300 1 done → start 4 at once 3 still running
13 t=300 3 done → start 5 at once
14 t=400 4 done → start 6 at once
15 t=450 5 and 6 finish one after the other
16 ~450ms in total, and a slot is almost never idle
§02

worker pool 的四个零件The four parts of a worker pool

拆开看,一共只有四样东西。Taken apart, there are only four things.

  1. 一个预分配的结果数组 new Array(tasks.length)。 长度一开始就定好,之后按下标填 ——这就是顺序保证的全部秘密, 不需要任何排序。
  2. 一个共享游标 let nextIndex = 0。 它记录「下一个该做的是第几号任务」。 所有 worker 共用这一个变量(靠闭包共享)。
  3. 一个 worker 函数。 它是个循环:抢一个任务号 → 游标 +1 → 跑它 → 结果写回对应下标 → 回到循环开头再抢。 队列空了就退出。
  4. 启动 limit 个 worker, 然后 await Promise.all 等它们全收工。同时存在的 worker 只有 limit 个, 所以同时在跑的任务也只有 limit 个 —— 并发上限是这么来的,不需要任何计数器。

这两个「天然保证」是这个解法漂亮的地方:并发上限来自「worker 的个数」, 顺序来自「按下标写回」。 两件难事都不需要额外代码。

  1. A pre-allocated result array new Array(tasks.length). The length is fixed up front and you fill it by index afterwards —that is the entire secret of the ordering guarantee, no sorting anywhere.
  2. A shared cursor let nextIndex = 0. It records which task number comes next. Every worker shares this one variable, through the closure.
  3. A worker function. It is a loop: grab a task number → bump the cursor → run the task → write the result back at that index → loop around and grab again. When the queue is empty it exits.
  4. Start limit workers, then await Promise.all until they all clock out.Only limit workers exist at a time, so only limit tasks run at a time — that is where the concurrency cap comes from, and it needs no counter at all.

Those two free guarantees are what makes this solution pretty: the concurrency cap comes from the number of workers, the ordering comes from writing back by index. Neither hard part needs extra code.

§03

游标不会被抢乱吗Can two workers grab the same cursor value?

不会。JavaScript 是单线程的。No. JavaScript runs on one thread.

看这两行:

在多线程语言里,两个线程可能同时读到 nextIndex = 3, 然后都去做第 3 号任务 —— 这叫竞态条件,得加锁。

JavaScript 是单线程的。 只有遇到 await 时才会把控制权交出去。const i = nextIndex; nextIndex++; 这两行中间没有 await,所以它们是一口气执行完的, 不可能被打断。

所以不需要锁。这是 JavaScript 并发模型的一个 实实在在的好处,也是这道题能写得这么短的原因。

Look at these two lines:

In a multi-threaded language two threads could read nextIndex = 3 at the same moment and both go do task 3 — that is a race condition, and it needs a lock.

But JavaScript is single-threaded. It only hands control away when it hits an await. There is no await between const i = nextIndex; nextIndex++;, so those two lines run in one breath and cannot be interrupted.

So no lock is needed. This is a real, concrete benefit of the JavaScript concurrency model, and the reason this task can be written so short.

TypeScript源项目From source
1const i = nextIndex; // 记下我抢到的号
2nextIndex++; // 立刻把游标推进,别人抢不到同一个
3 // ↑ 这两行之间没有 await,不会被打断
1const i = nextIndex; // remember the number I grabbed
2nextIndex++; // move the cursor at once so nobody grabs the same one
3 // ↑ no await between these two lines, so nothing interrupts them
Source: react-notes-app/q2/taskRunner.ts
§04

分步写出来Writing it one step at a time

第一步:结果数组和游标。

第二步:worker 的循环体。注意 await tasks[i]() —— 那对括号不能少,tasks[i] 是函数,要调用它才产生 Promise。try/catch 把失败接住写成 rejected然后循环继续 —— 这就是「NEVER throws」的实现方式。

第三步:启动 worker 并等待。Math.min(limit, tasks.length) 是个细节: 3 个任务、上限 10,只需要开 3 个 worker, 多开的会立刻发现队列空了然后退出 —— 没坏处,但没必要。

第四步:空数组早退。tasks.length === 0 时直接返回 []。 其实不加也对(0 个 worker,Promise.all([]) 立刻 resolve, 返回空数组),但显式写出来更清楚。

Step one: the result array and the cursor.

Step two: the body of the worker loop. Watch await tasks[i]() — that pair of parentheses is not optional. tasks[i] is a function; calling it is what produces a Promise. try/catch catches the failure and writes it as rejected, and then the loop keeps going — that is how “NEVER throws” gets implemented.

Step three: start the workers and wait.Math.min(limit, tasks.length) is a small detail: 3 tasks with a cap of 10 only needs 3 workers, and any extras would immediately find the queue empty and exit — harmless, but pointless.

Step four: the early return for an empty array. When tasks.length === 0, return [] straight away. Leaving it out is also correct (0 workers, Promise.all([]) resolves instantly, empty array back), but writing it out says so plainly.

TypeScript推导 · 第一步Working it out · step one示意Illustrative
1// 第一步
2const results: SettledResult<T>[] = new Array(tasks.length);
3let nextIndex = 0;
1// Step one
2const results: SettledResult<T>[] = new Array(tasks.length);
3let nextIndex = 0;
TypeScript推导 · 第二步Working it out · step two示意Illustrative
1// 第二步:一个 worker 不停地抢活
2const worker = async () => {
3 while (nextIndex < tasks.length) {
4 const i = nextIndex;
5 nextIndex++;
6
7 try {
8 const value = await tasks[i](); // ← 括号!调用它才开始跑
9 results[i] = { status: "fulfilled", value };
10 } catch (reason) {
11 results[i] = { status: "rejected", reason };
12 }
13 // catch 之后不 return,循环继续 → 一个失败不连累别人
14 }
15};
1// Step two: one worker keeps taking the next job
2const worker = async () => {
3 while (nextIndex < tasks.length) {
4 const i = nextIndex;
5 nextIndex++;
6
7 try {
8 const value = await tasks[i](); // ← the parentheses! calling it is what starts it
9 results[i] = { status: "fulfilled", value };
10 } catch (reason) {
11 results[i] = { status: "rejected", reason };
12 }
13 // no return after catch, the loop keeps going → one failure does not stop the others
14 }
15};
TypeScript推导 · 第三步Working it out · step three示意Illustrative
1// 第三步:开 limit 个 worker,等它们全收工
2const workerCount = Math.min(limit, tasks.length);
3const workers: Promise<void>[] = [];
4for (let w = 0; w < workerCount; w++) {
5 workers.push(worker()); // 这里的括号是「启动这个 worker」
6}
7await Promise.all(workers);
8return results;
1// Step three: start limit workers and wait for all of them
2const workerCount = Math.min(limit, tasks.length);
3const workers: Promise<void>[] = [];
4for (let w = 0; w < workerCount; w++) {
5 workers.push(worker()); // these parentheses mean "start this worker"
6}
7await Promise.all(workers);
8return results;
§05

完整答案The complete answer

这就是项目里的实现,已实测跑通。This is the implementation in the project, and it has been run and checked.

把四步拼起来。这份代码和 react-notes-app/q2/taskRunner.ts里的实现完全一致,npm run q2 实测通过三条验收标准。

Put the four steps together. This code is identical to the implementation in react-notes-app/q2/taskRunner.ts, and npm run q2 was run here and met all three acceptance criteria.

TypeScriptq2/taskRunner.ts(完整实现)q2/taskRunner.ts (the complete implementation)源项目From source
1export async function runTasks<T>(
2 tasks: Task<T>[],
3 limit: number,
4): Promise<SettledResult<T>[]> {
5 const results: SettledResult<T>[] = new Array(tasks.length);
6
7 let nextIndex = 0;
8
9 const worker = async () => {
10 while (nextIndex < tasks.length) {
11 const i = nextIndex;
12 nextIndex++;
13
14 try {
15 const value = await tasks[i]();
16 results[i] = { status: "fulfilled", value };
17 } catch (reason) {
18 results[i] = { status: "rejected", reason };
19 }
20 }
21 };
22 if (tasks.length === 0) return [];
23
24 const workerCount = Math.min(limit, tasks.length);
25 const workers: Promise<void>[] = [];
26 for (let w = 0; w < workerCount; w++) {
27 workers.push(worker());
28 }
29 await Promise.all(workers);
30 return results;
31}
Source: react-notes-app/q2/taskRunner.ts
§06

验证:读懂这段输出Checking your work: how to read this output

本机实测的完整输出。三条验收标准逐条对照:

  • 并发不超 2 —— 盯住 running now 那一列, 它在 1 和 2 之间来回,从来没到 3。✓
  • 顺序与输入一致 ——#1 是 task 1 的结果,尽管它耗时 300ms、 是第二个完成的。✓
  • 失败不连累别人 —— task 3 FAIL 之后,5、6 照常启动并完成,#3rejected,其余是 fulfilled。✓

另外注意 task 3 FAIL (running now: 1) 这一行: 失败也让槽位空了出来,紧接着 task 5 就启动了。失败和成功对调度器是一样的 —— 都只是「一个槽位空了」。

The full output as measured on this machine. Check the three criteria one by one:

  • Concurrency never above 2 — follow the running now column; it bounces between 1 and 2 and never reaches 3. ✓
  • Order matches the input#1 is task 1’s result even though it took 300ms and finished second. ✓
  • A failure does not drag the others down — after task 3 FAIL, 5 and 6 start and finish as usual,#3 is rejected and the rest are fulfilled. ✓

Also look at the line task 3 FAIL (running now: 1): a failure frees a slot too, and task 5 starts right after it. Failure and success look the same to the scheduler — both are just “a slot opened up”.

Text本机实测输出The real output from running it here源项目From source
1$ npm run q2
2
3task 1 START (running now: 1)
4task 2 START (running now: 2) ← 到上限,3 号排队
5task 2 DONE (running now: 1)
6task 3 START (running now: 2) ← 有槽位立刻补
7task 1 DONE (running now: 1)
8task 4 START (running now: 2)
9task 3 FAIL (running now: 1) ← 失败也只是空出槽位
10task 5 START (running now: 2)
11task 4 DONE (running now: 1)
12task 6 START (running now: 2)
13task 5 DONE (running now: 1)
14task 6 DONE (running now: 0)
15
16=== FINAL RESULTS (must be in original order) ===
17#1 { status: 'fulfilled', value: 'result of task 1' }
18#2 { status: 'fulfilled', value: 'result of task 2' }
19#3 {
20 status: 'rejected',
21 reason: Error: task 3 failed
22 at Timeout._onTimeout (.../q2/demo.ts:18:18)
23}
24#4 { status: 'fulfilled', value: 'result of task 4' }
25#5 { status: 'fulfilled', value: 'result of task 5' }
26#6 { status: 'fulfilled', value: 'result of task 6' }
1$ npm run q2
2
3task 1 START (running now: 1)
4task 2 START (running now: 2) ← at the limit, task 3 waits
5task 2 DONE (running now: 1)
6task 3 START (running now: 2) ← a slot opened, filled at once
7task 1 DONE (running now: 1)
8task 4 START (running now: 2)
9task 3 FAIL (running now: 1) ← a failure frees a slot too
10task 5 START (running now: 2)
11task 4 DONE (running now: 1)
12task 6 START (running now: 2)
13task 5 DONE (running now: 1)
14task 6 DONE (running now: 0)
15
16=== FINAL RESULTS (must be in original order) ===
17#1 { status: 'fulfilled', value: 'result of task 1' }
18#2 { status: 'fulfilled', value: 'result of task 2' }
19#3 {
20 status: 'rejected',
21 reason: Error: task 3 failed
22 at Timeout._onTimeout (.../q2/demo.ts:18:18)
23}
24#4 { status: 'fulfilled', value: 'result of task 4' }
25#5 { status: 'fulfilled', value: 'result of task 5' }
26#6 { status: 'fulfilled', value: 'result of task 6' }
Source: react-notes-app
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.