原语阶梯第二级------两个 Runner。它们是「调度策略」:SerialRunner 用 promise 链实现串行(无需显式队列),ConcurrentRunner 按 id 去重并发。两个 Runner 都有一个精巧的「过时回调丢弃」机制------不用 generation token,靠 promise 引用身份比对。这是我读 SPF 时觉得最漂亮的两段代码。
ConcurrentRunner:按 id 去重(L126-193)
先看简单的。JSDoc(L119-125)说明了用途:同 id 任务在飞时,后续 schedule() 被静默忽略 (返回已在飞的那个 promise);保留任务引用使 abortAll() 能取消一切。
schedule()(L132-158)
ts
schedule(task) {
if (this.#destroyed) return Promise.resolve(); // L133
const existing = this.#pending.get(task.id);
if (existing) return existing.promise; // L134-135 --- 去重的核心两行!
if (this.#pending.size === 0) {
this.#settled = new Promise((resolve) => { // L137-141 --- 0→1 时开新批次
this.#resolveSettled = resolve;
});
}
const promise = task.run();
promise.catch(() => {}); // L145 --- 吞 rejection 防 unhandled
const cleanup = () => {
this.#pending.delete(task.id);
if (this.#pending.size === 0) {
this.#resolveSettled?.(); // L150 --- 归零时 settle 当前批次
this.#resolveSettled = null;
}
};
promise.then(cleanup, cleanup); // L154 --- 成败都清理
this.#pending.set(task.id, { task, promise });
return promise;
}
L134-135 是去重的全部:map 里已有同 id 任务,直接返回它的 promise。调用方拿到的是「同一个在飞操作」------不管你 schedule 几次,实际只跑一次。
#settled(L137-141)是「批次」概念:从 0 个任务变 1 个时创建新 promise,归零时 resolve。whenSettled 就挂在这个 promise 上。
L145 的 promise.catch(() => {}) 值得一提------吞掉 rejection ,防止调用方忽略返回值时触发 unhandled rejection 崩溃。返回给调用方的 promise 保留原始 rejection(L157 返回的是 task.run() 的原 promise,不是吞过的)。
whenSettled():引用身份丢弃机制(L166-176)
ts
whenSettled(callback) {
if (this.#pending.size === 0) return; // L167 --- 已空闲,永不回调
const captured = this.#settled; // L168 --- 捕获当前批次引用
captured.then(() => {
if (this.#settled !== captured) return; // L172 --- 引用不等 → 已被取代,丢弃!
callback();
}, () => {});
}
这是我最欣赏的一段。问题:注册「批次完成后回调」后,如果期间发生了 abortAll 或新批次,回调不该触发(它说的是「那批」的事)。传统解法是 generation token(调用方持有代数号,回调时比对)。
这里的解法:捕获 this.#settled 的引用身份,回调触发时比对 this.#settled !== captured ------不一致说明批次已被取代,静默丢弃。JSDoc(L160-165)明说:"no stale callbacks, no generation token required by the caller"------generation token 的角色由内部引用比较承担,调用方零负担。
abortAll()(L178-187)
ts
abortAll() {
for (const { task } of this.#pending.values()) task.abort(); // L179
this.#pending.clear(); // L180
this.#resolveSettled?.(); // L184 --- 先 resolve 旧 settled
this.#resolveSettled = null;
this.#settled = Promise.resolve(); // L186 --- 再替换引用!
}
L186 替换 #settled 引用是丢弃机制生效的原因。注释(L181-183)解释了顺序:先 resolve 再换引用 ------让挂在旧 settled 上的 .then 排队(它们会比对引用然后被丢弃)。
注意 ConcurrentRunner 没有 abortPending------并发模型里没有「排队 vs 在飞」的区分,要停就全停。
SerialRunner:promise 链串行化(L213-287)
SerialRunner 处理「必须一次一个」的操作------SourceBuffer 的 append/remove 就是典型(MSE 不允许并发操作同一 SourceBuffer)。
schedule():无队列的串行(L219-241)
JSDoc(L204-207)有一句关键说明:串行化通过把每个任务的 run() 链到共享 promise 链尾部实现------无需显式队列或 drain 循环。
ts
schedule(task) {
if (this.#destroyed) return Promise.resolve();
this.#pending.add(t); // L222
const result = this.#chain // L224 --- 挂到链尾!
.then(() => {
this.#pending.delete(t); // L226
this.#current = t; // L227
return task.run(); // L228 --- 链到才真正跑
})
.finally(() => {
this.#current = null; // L231
});
this.#chain = result.then(() => {}, () => {}); // L235-238 --- 链推进的关键
return result;
}
读懂这段要抓住 this.#chain 的双重角色:
- L224 的读 :
this.#chain.then(...)把新任务挂到当前链尾 ------前一个任务 settle 后这个 then 回调才执行,此时才真正task.run()(L228)。 - L235 的写 :
this.#chain = result.then(noop, noop)------把链推进为「本任务已吞错完成」。无论成败,链都被替换为 resolved promise ------单个任务失败不阻断后续任务。返回给调用方的result保留原始 rejection。
没有队列数组、没有 drain 循环、没有「检查是否空闲」------promise 链本身就是队列。这个手法我认为是 JS 异步编程的必修课。
注意与 ConcurrentRunner 不同:SerialRunner 没有 id 去重(用 Set 存对象身份,重复 schedule 同一对象只跑一次是 Set 语义的副作用)。
settled getter:这就是 generation token(L243-251)
ts
get settled(): Promise<void> {
return this.#chain as Promise<void>; // L250
}
JSDoc(L243-248)明说这就是 generation token 的用法:调度一批任务后捕获 settled,resolution 回调里检查身份是否仍是当前 #chain 。因为每次 schedule() 都替换 #chain(L235),身份比对即代际比对。
whenSettled()(L253-270)内部化了同样的比对------和 ConcurrentRunner 相同的机制,只是「取代」来源是新 schedule 替换链而非 abortAll。
abortPending vs abortAll(L272-281)
这是 SerialRunner 独有的区分:
ts
/** Aborts and clears queued tasks without touching the in-flight task. */
abortPending(): void {
for (const task of this.#pending) task.abort(); // L274 --- 只 abort 排队中的
this.#pending.clear();
}
abortAll(): void {
this.abortPending();
this.#current?.abort(); // L280 --- 连在飞的一起
}
abortPending():只 abort 排队尚未开跑 的(#pendingSet),不碰正在飞的#current------当前任务允许自然完成。abortAll():全停。
为什么需要这个区分?场景:SourceBuffer 正在 append 一个分段,此时轨道切换。你希望取消排队中的旧分段 (它们属于旧轨道),但让当前 append 完成 (中途 abort MSE 操作可能让 SourceBuffer 进入坏状态)。abortPending 精确表达了「清队列、放行当前」。
类 JSDoc(L209-211)还诚实说明了边界:abortAll 之后排队任务仍会短暂 run(链上的 then 照常执行 task.run()),但它们收到已 abort 的信号,被期望立即退出。
两个 Runner 的对比总结
| ConcurrentRunner | SerialRunner | |
|---|---|---|
| 调度 | 立即全跑 | promise 链排队 |
| 去重 | 按 id(L134-135) | 无(Set 身份副作用) |
| 失败隔离 | 天然(各自 promise) | 链吞错(L235-238) |
| 停止 | 只有 abortAll | abortPending(放行在飞)+ abortAll |
| 过时回调丢弃 | settled 引用比对(L172) | chain 引用比对(L266) |
| 典型用途 | 并行 fetch(同 URL 去重) | SourceBuffer append/remove |
小结
- promise 链即队列------SerialRunner 无显式队列、无 drain 循环。
- 链吞错推进 ------
result.then(noop, noop)保证单任务失败不阻断。 - id 去重两行核心 ------
#pending.get(id)命中即返回在飞 promise。 - 引用身份 = generation token------过时回调丢弃零调用方负担。
- abortPending/abortAll 的语义区分------「清队列放行当前」vs「全停」,为 MSE 场景量身定制。
下一篇进入机器原语------Actor,消息驱动的有限状态机。SegmentLoaderActor 和 SourceBufferActor 都建立在它上面。