跳到正文
前端知识库
算法

异步调度:红绿灯循环与受限并发

把循环异步任务和 Promise 并发限制建模为可停止、可测试的调度器,明确启动时机、失败策略、结果顺序与资源清理。

6 分钟算法 · Promise · 异步 · 并发控制 · 调度器 · AbortSignal · 面试

异步调度:红绿灯循环与受限并发

高频真题解析与9月考点预测中.pdf 的“红绿灯”和“有并行限制的 Promise 调度器”考查的不是某个 API,而是状态、队列、退出条件和资源释放。回答时先说清契约,再写最小实现:任务何时启动、最多同时几个、结果是否保持输入顺序、首错是否终止、如何取消。

1. 通用延迟:计时器必须可取消

红绿灯和超时控制都依赖延迟。只调用 setTimeout 而不清理,在页面卸载或任务取消后仍会保留计时器。下面的 delay 同时处理计时器和 AbortSignal,并保证只结算一次:

function abortError() {
  if (typeof DOMException === 'function') {
    return new DOMException('The operation was aborted', 'AbortError')
  }
  const error = new Error('The operation was aborted')
  error.name = 'AbortError'
  return error
}

function delay(milliseconds, signal) {
  if (!Number.isFinite(milliseconds) || milliseconds < 0) {
    return Promise.reject(new RangeError('delay must be non-negative'))
  }

  return new Promise((resolve, reject) => {
    let settled = false
    let timer

    const cleanup = () => {
      clearTimeout(timer)
      signal?.removeEventListener('abort', onAbort)
    }
    const finish = (settle, value) => {
      if (settled) return
      settled = true
      cleanup()
      settle(value)
    }
    const onAbort = () => finish(reject, abortError())

    if (signal?.aborted) {
      onAbort()
      return
    }

    timer = setTimeout(() => finish(resolve), milliseconds)
    signal?.addEventListener('abort', onAbort, { once: true })
  })
}

onAbortcleanup 定义后才会被调用,因此闭包引用是安全的;如果调用方传入已取消的信号,函数不会再注册监听器。Node.js 没有 DOMException 的环境也能得到带 name = 'AbortError' 的普通错误。

2. 红绿灯:有限状态循环

2.1 题面与状态

给定若干阶段(例如红、黄、绿)及持续时间,按顺序点亮并循环。每个阶段的状态转移是:

未开始 -> 通知当前阶段 -> 等待持续时间 -> 下一个阶段
                                      \-> 取消/结束

cycles 让测试可以在有限轮次后结束;生产调用可以保持默认的无限循环,并通过 AbortController 停止。循环不变量是:每次等待期间至多有一个阶段计时器,进入下一阶段前当前阶段的计时器已结算。

async function runTrafficLight(
  phases,
  { signal, cycles = Infinity, onPhase = () => {} } = {}
) {
  if (!Array.isArray(phases) || phases.length === 0) {
    throw new RangeError('at least one phase is required')
  }
  if (!(cycles === Infinity || Number.isInteger(cycles)) || cycles < 0) {
    throw new RangeError('cycles must be a non-negative integer or Infinity')
  }

  for (const phase of phases) {
    if (!phase || typeof phase.name !== 'string') {
      throw new TypeError('phase.name must be a string')
    }
    if (!Number.isFinite(phase.duration) || phase.duration < 0) {
      throw new RangeError(`invalid duration for ${phase.name}`)
    }
  }

  let completedCycles = 0
  while (!signal?.aborted && completedCycles < cycles) {
    for (const phase of phases) {
      if (signal?.aborted) return { completedCycles, stopped: true }
      try {
        await onPhase(phase.name)
        await delay(phase.duration, signal)
      } catch (error) {
        // 取消是正常结束分支;业务回调的其他异常继续向调用方抛出。
        if (error?.name === 'AbortError') {
          return { completedCycles, stopped: true }
        }
        throw error
      }
    }
    completedCycles += 1
  }

  return { completedCycles, stopped: Boolean(signal?.aborted) }
}

示例:

const controller = new AbortController()
const phases = [
  { name: 'red', duration: 3000 },
  { name: 'yellow', duration: 1000 },
  { name: 'green', duration: 3000 },
]

runTrafficLight(phases, {
  signal: controller.signal,
  cycles: 1,
  onPhase: (name) => console.log(name),
})
// 需要停止无限循环时:controller.abort()

如果题目规定“亮灯后立即打印,再等待,再切换”,上面的顺序符合;如果规定等待结束才通知下一状态,应把 onPhase 放在对应转移点。这里约定 AbortSignal 取消时返回 { stopped: true },其他错误才 reject。不要用没有退出条件的递归 lightStep().finally(lightStep):它无法自然取消,且很容易遗留定时器和监听器。while + await 每轮都会让出调用栈,循环次数为 cycles * phases.length,额外栈空间为 O(1)

3. 受限并发 Promise 调度器

3.1 题面与不变量

输入是一组惰性任务工厂 () => Promise<T> 和并发上限 limit,要求同时运行的任务不超过上限,并返回与输入相同顺序的结果。任务工厂必须延迟到获得槽位时才调用;如果传入已经执行的 Promise,调度器无法阻止它们提前开始。

核心不变量:

  • 0 <= active <= limit
  • cursor 之前的任务都已启动,且每个索引只启动一次;
  • results[i] 只由第 i 个任务写入,因此完成顺序不会改变返回顺序;
  • 任务无论成功或失败都要结束自己的槽位,首错策略下不再启动排队任务。
async function runWithConcurrency(taskFactories, limit, { signal } = {}) {
  if (!Array.isArray(taskFactories)) {
    throw new TypeError('taskFactories must be an array')
  }
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit must be a positive integer')
  }
  if (taskFactories.length === 0) return []

  const results = new Array(taskFactories.length)
  let cursor = 0
  let stopped = false

  async function worker() {
    while (!stopped) {
      const index = cursor
      cursor += 1
      if (index >= taskFactories.length) return
      if (signal?.aborted) {
        stopped = true
        throw abortError()
      }

      try {
        if (typeof taskFactories[index] !== 'function') {
          throw new TypeError(`task ${index} is not a function`)
        }
        // 通过微任务调用工厂,也能捕获同步 throw。
        results[index] = await Promise.resolve().then(() =>
          taskFactories[index](signal)
        )
      } catch (error) {
        stopped = true
        throw error
      }
    }
  }

  const workerCount = Math.min(limit, taskFactories.length)
  await Promise.all(
    Array.from({ length: workerCount }, () => worker())
  )
  return results
}

调度器的控制开销是 O(n),结果数组空间是 O(n),工作协程和游标只占 O(limit)。总耗时由任务本身决定;最理想情况下接近“任务总时长除以并发数”,但任务时长不均匀时不能给出固定倍数。Promise.all 只负责等待已经创建的 Promise,不提供并发上限、排队、取消或超时,所以不能替代这里的队列。

3.2 首错与收集全部结果

上面的契约是“首个失败即 reject”,但已在运行的任务不会被强行中断;调用方需要用 AbortController 将信号传给可取消的任务。若业务要求尽可能完成全部任务,可把每项结果包装成 settled 状态:

async function runAllSettledWithConcurrency(taskFactories, limit, options = {}) {
  const wrapped = taskFactories.map((factory) => async (signal) => {
    try {
      return { status: 'fulfilled', value: await factory(signal) }
    } catch (reason) {
      return { status: 'rejected', reason }
    }
  })
  return runWithConcurrency(wrapped, limit, options)
}

重试、超时和优先级不是调度器自动拥有的能力:只对幂等任务重试,为每次尝试设置独立超时,并明确 FIFO 是否会被高优先级任务打破。生产任务还应记录排队时长、执行时长、取消数、失败类型和当前队列长度。

4. 常见追问与边界

Q: 为什么任务要传函数,而不是直接传 Promise?

A: Promise 在创建时就开始执行,调度器拿到它时已经失去“何时启动”的控制;函数工厂可以等槽位空闲后再调用,从而真正限制并发。(深入阅读:算法面试真题补充 - 受限并发 Scheduler

Q: 某任务失败后,其他任务怎么办?

A: 先声明策略。首错策略只停止排队任务,已启动任务仍需在 finally 中释放资源;全量策略返回每项 fulfilled/rejected,不能吞掉异常或把失败当成成功值。

Q: 并发上限为 0 怎么处理?

A: 这是无意义或会永久排队的配置,直接拒绝;空任务列表可以在校验上限后约定返回 [],并把这个顺序写进契约。

Q: 无限红绿灯如何测试?

A: 注入计时器或使用 cycles/AbortSignal 让测试可终止;断言每次只进入一个阶段、顺序循环正确、abort 后不再安排新的计时器。

Q: 如何保证公平性?

A: 基础实现是 FIFO 游标;加入优先级后要防止低优先级任务饿死,可为优先级队列设置配额或老化(aging),并记录等待时间验证效果。

5. 最小验证清单

  • 任务数小于、等于和大于 limit;任务同步返回、异步延迟、同步抛错和 Promise reject。
  • 任务完成顺序与输入顺序不同,结果仍按索引排列。
  • limit0、小数、NaN,空任务列表和非法工厂。
  • 红绿灯零时长、单阶段、多轮、半途 abort、abort 与定时器同时发生。
  • 组件卸载后没有遗留 timer/listener;首错和全量收集策略的行为符合文档。

来源:高频真题解析与9月考点预测中.pdf 的“红绿灯”“有并行限制的 Promise 调度器”;与 前端高频算法原题解析.pdf 中复杂度和边界分析方法结合整理。

相关专题:Promise 与 all/race事件循环算法面试真题补充