videojs v10 源代码系列解读:30 · `SerialRunner` 与 `ConcurrentRunner`

原语阶梯第二级------两个 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 的双重角色:

  1. L224 的读 :this.#chain.then(...) 把新任务挂到当前链尾 ------前一个任务 settle 后这个 then 回调才执行,此时才真正 task.run()(L228)。
  2. 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 排队尚未开跑 的(#pending Set),不碰正在飞的 #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 都建立在它上面。

相关推荐
liangshanbo12151 小时前
面试题:页面需要发起大量 Ajax / HTTP 请求时,如何优化请求性能?
前端·http·ajax
lyz2468592 小时前
vue3页面因参数未声明而报错的常犯错误
前端·javascript·vue.js
摸爬滚打的小李2 小时前
流程图同步AI生成
前端
Hilaku2 小时前
我重写了整个项目,但没人感谢我!
前端·javascript·程序员
zach3 小时前
Vue/React SPA 打包部署后,子路由刷新 401 未登录问题彻底解决
前端·nginx·next.js
旋生万物3 小时前
素数螺旋映射 $z_n=n^{1+i}$ 的角分布统计检验与零模型对比
大数据·前端·人工智能·算法·云原生·螺旋生成论·螺旋相位
亿元程序员3 小时前
游戏引擎都没用!纯AI又上线了一款蚂蚁搬家小游戏!
前端
福兮说3 小时前
JS 正则的六个坑:带 g 的 test() 一真一假、空匹配死循环、replace 里的 $
开发语言·前端·javascript·正则表达式
码艺-Alimjan3 小时前
Web 网站打包桌面应用的另一种方式,超级简单(C# exe 33Kb)
开发语言·前端·c#