实现:worker pool(工人池)Building it: a worker pool, meaning a fixed number of workers sharing one queue
别想复杂了。就是「开 limit 个工人,一起从同一个待办队列里抢活」。Do not overthink it. You start limit workers, and they all take jobs from the same to-do queue.
这一页有什么On this page9
- 01 先排除一个直觉上的错解:分批First rule out the answer that feels obvious: fixed batches
- 02 worker pool 的四个零件The four parts of a worker pool
- 03 游标不会被抢乱吗Can two workers grab the same cursor value?
- 04 分步写出来Writing it one step at a time
- 05 完整答案The complete answer
- 06 验证:读懂这段输出Checking your work: how to read this output
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 独立实现 runTasks,并解释每一行为什么这么写Implement runTasks without help, and explain why each line is written that way
- 说清「共享游标」为什么天然保证了并发上限Explain why one shared cursor already guarantees the concurrency limit
- 说清「按下标写回」为什么天然保证了顺序Explain why writing results back by index already guarantees the order
- 会读 npm run q2 的输出并判断实现是否正确Read the output of npm run q2 and decide whether the implementation is correct
这是 Q2 的完整答案。而且 worker pool 是一个可迁移的模式 —— 任何「限制并发」的题都是这个骨架。This is the full answer to Q2. The worker pool is also a pattern you can carry to other problems: every question about limiting concurrency has this same skeleton.
react-notes-app/q2/taskRunner.ts要实现的 runTasksThe runTasks you have to write
提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.
react-notes-app/q2/taskRunner.ts先排除一个直觉上的错解:分批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”.
worker pool 的四个零件The four parts of a worker pool
拆开看,一共只有四样东西。Taken apart, there are only four things.
- 一个预分配的结果数组
new Array(tasks.length)。 长度一开始就定好,之后按下标填 ——这就是顺序保证的全部秘密, 不需要任何排序。 - 一个共享游标
let nextIndex = 0。 它记录「下一个该做的是第几号任务」。 所有 worker 共用这一个变量(靠闭包共享)。 - 一个 worker 函数。 它是个循环:抢一个任务号 → 游标 +1 → 跑它 → 结果写回对应下标 → 回到循环开头再抢。 队列空了就退出。
- 启动
limit个 worker, 然后await Promise.all等它们全收工。同时存在的 worker 只有 limit 个, 所以同时在跑的任务也只有 limit 个 —— 并发上限是这么来的,不需要任何计数器。
这两个「天然保证」是这个解法漂亮的地方:并发上限来自「worker 的个数」, 顺序来自「按下标写回」。 两件难事都不需要额外代码。
- 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. - A shared cursor
let nextIndex = 0. It records which task number comes next. Every worker shares this one variable, through the closure. - 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.
- Start
limitworkers, thenawait Promise.alluntil 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.
游标不会被抢乱吗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.
react-notes-app/q2/taskRunner.ts分步写出来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.
完整答案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.
react-notes-app/q2/taskRunner.ts验证:读懂这段输出Checking your work: how to read this output
本机实测的完整输出。三条验收标准逐条对照:
- 并发不超 2 —— 盯住
running now那一列, 它在 1 和 2 之间来回,从来没到 3。✓ - 顺序与输入一致 ——
#1是 task 1 的结果,尽管它耗时 300ms、 是第二个完成的。✓ - 失败不连累别人 —— task 3 FAIL 之后,5、6 照常启动并完成,
#3是rejected,其余是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 nowcolumn; it bounces between 1 and 2 and never reaches 3. ✓ - Order matches the input —
#1is 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,
#3isrejectedand the rest arefulfilled. ✓
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”.
react-notes-app动手做Get your hands on it
填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.
五个空。第 2 个和第 4 个是最容易写错的 —— 一个关系到「顺序」,一个关系到「任务到底有没有被启动」。
Five blanks. Numbers 2 and 4 are the ones most often written wrong: one decides the order, the other decides whether the task was started at all.
只给签名。这是 Q2 的完整答案,写对了这道题就通了。 写完可以在本机 npm run q2 验证。
You get only the signature. This is the complete answer to Q2: get it right and the question is done. When you finish you can check it here with npm run q2.
- 同一时刻最多 limit 个任务在运行At most limit tasks are running at the same time
- 某个任务结束后,立刻启动下一个(不是等一批都结束)As soon as one task finishes, start the next one (do not wait for a whole batch)
- 任何任务失败都不能让整体抛错No failing task may make the whole call 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 }
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.
跑 npm run q2,没有报错,但一行task N START 都没有,直接就出结果了 —— 而且结果里的 value 长得很奇怪。
You run npm run q2. Nothing reports an error, but not one task N START line appears; the results come out right away. And the value in each result looks strange.
初学者常见的几种写法错误Mistakes beginners actually make
下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.
push 按完成时间追加。 task 2(100ms)会排在 task 1(300ms)前面, 于是 #1 变成了 task 2 的结果。 违反「IN THE SAME ORDER as tasks」。正解是预分配数组 + 按原始下标
results[i] 写回。push appends in finish order. Task 2 (100ms) lands before task 1 (300ms), so #1 holds the result of task 2. That breaks the requirement to return results IN THE SAME ORDER as tasks.The right answer: create the array up front and write each result to its original index,
results[i].results 里留下 undefined 的洞。catch 里只记录,不中断循环。After task 3 fails, that worker exits. The remaining tasks are left to the other worker alone, so the real concurrency drops to 1. And if both workers hit a failure, the later tasks never run at all, which leaves
undefined holes in results.In the catch block, record what happened and let the loop continue.
游标必须在 worker 外面声明, 靠闭包被所有 worker 共享。With the cursor declared inside the worker, every worker gets its own copy, so every worker starts at 0 and runs all the tasks. With limit=2 each task runs twice, and the number running at once is double what it should be.
Declare the cursor outside the worker, so the closure shares one cursor between all workers.
换一道题也能用Works on other problems too
考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.
- worker pool 四个零件:预分配结果数组、共享游标、循环抢活的 worker、limit 个 worker + Promise.all。The four parts of a worker pool: a result array created up front, one shared cursor, a worker that loops and takes the next job, and limit workers run with Promise.all.
- 并发上限来自「worker 的个数」,顺序来自「按原始下标写回」—— 两件难事都不需要额外代码。The concurrency limit comes from how many workers you start. The order comes from writing each result to its original index. Neither needs extra code.
- JavaScript 单线程,游标那两行之间没有 await,所以不需要加锁。JavaScript runs on one thread, and there is no await between the two cursor lines, so no lock is needed.
- await tasks[i]() 的括号是关键;少了它任务根本不会被执行,而且不报错。The parentheses in await tasks[i]() matter. Without them the task never runs, and nothing reports an error.
- catch 里只记录不中断,这才叫「NEVER throws」。The catch block records and keeps going. That is what NEVER throws means.