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 都建立在它上面。

相关推荐
RobinDevNotes33 分钟前
从 Electron 到 GPUI,9 个跨平台桌面方案该怎么选
前端·javascript·rust·electron·openresty·前端开发工具
jay神2 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神2 小时前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat2 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss2 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan3 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真3 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment3 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan3 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么3 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模