写一个可中断的异步任务队列,解决前端并发调度难题

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-12 10:51 ·5 浏览 ·0 回复

前端并发调度的难题,很多时候不是“怎么同时发更多请求”,而是“怎么在正确的时候停掉不该继续的任务”。用 `Promise.all` 一把梭,页面看起来很快,但搜索框连续输入三个关键词,三个请求乱序返回,用户看到的是旧结果;路由已经切走,上一个页面的请求还在跑;批量上传到一半,用户点了取消,请求却依然占着连接数。问题不在异步本身,而在缺少一个可中断、可调度的异步任务队列。

并发控制不等于 Promise.all

浏览器对同域请求有连接数限制,主线程也只有一条。无限制地并发,只会让关键任务排在后台任务后面,甚至造成内存和连接池浪费。队列的第一层价值是限制并发数,第二层价值是决定“谁先跑”,第三层价值是决定“谁该停”。如果只能排队不能取消,队列只是一个延迟执行的容器;只有把中断能力设计进去,它才真正解决前端并发调度难题。

把“中断”设计成一等公民

异步任务的中断不是线程 kill,而是协作式取消。浏览器里最合适的协议是 `AbortController` 和 `AbortSignal`:`fetch` 原生支持,自定义异步函数也可以接收 signal。任务函数统一写成 `(signal: AbortSignal) => Promise<T>`,在关键节点检查 `signal.aborted`,或者用 `signal.addEventListener('abort', ...)` 清理资源。取消后,队列要拒绝该任务的 Promise,并抛出统一的 `AbortError`,避免调用方把取消误判成业务失败。

一个最小可中断队列

下面是一个简化实现,重点看状态流转和取消传播:

type Task<T> = {
  id: number;
  run: (signal: AbortSignal) => Promise<T>;
  priority: number;
  resolve: (v: T) => void;
  reject: (e: unknown) => void;
  controller: AbortController;
};

class AsyncQueue {
  private queue: Task<any>[] = [];
  private running = 0;
  constructor(private concurrency = 3) {}

  add<T>(
    run: (signal: AbortSignal) => Promise<T>,
    opts: { priority?: number; signal?: AbortSignal } = {}
  ) {
    return new Promise<T>((resolve, reject) => {
      const controller = new AbortController();
      const task: Task<T> = {
        id: Date.now() + Math.random(),
        run,
        priority: opts.priority ?? 0,
        resolve,
        reject,
        controller,
      };

      const abort = () => {
        this.queue = this.queue.filter(t => t.id !== task.id);
        controller.abort();
        reject(new DOMException('Task aborted', 'AbortError'));
      };

      if (opts.signal) {
        if (opts.signal.aborted) return abort();
        opts.signal.addEventListener('abort', abort, { once: true });
      }

      this.queue.push(task);
      this.queue.sort((a, b) => b.priority - a.priority);
      this.next();
    });
  }

  private next() {
    while (this.running < this.concurrency && this.queue.length) {
      const task = this.queue.shift()!;
      this.running++;
      task.run(task.controller.signal)
        .then(task.resolve)
        .catch(task.reject)
        .finally(() => {
          this.running--;
          this.next();
        });
    }
  }
}

运行中的任务通过 `controller.abort()` 通知内部停止;排队中的任务直接移出队列。调用方可以传入自己的 signal,也可以用返回的 Promise 配合事件取消。真正的中断效果,取决于任务函数是否响应 signal。

调度策略决定体验

优先级要贴近用户意图:输入框的搜索建议优先于列表预取,当前路由的数据优先于埋点上报。超时可以用 `AbortController` 加定时器实现,超时即取消。重试也要可取消,否则用户离开后还在后台反复请求。为了避免低优先级任务饿死,可以给等待时间长的任务提升优先级。并发数最好可动态调整,比如首屏关键阶段降到 2,空闲阶段升到 5。

几个容易踩的坑

取消后仍然 resolve,会让调用方拿到过期数据;忘记移除 signal 监听,可能造成内存泄漏;任务内部不检查 `signal.aborted`,取消就只是表面功夫;队列无限增长,会吃掉内存。还有一点,取消不是万能的,写操作接口要保证幂等,避免撤销和重试造成重复提交。

可中断的异步任务队列,本质上是在调度层统一管理并发、优先级和生命周期。前端体验的稳定性,不只来自“跑得快”,更来自“该停的时候停得住”。当页面交互越来越复杂,把取消和调度收拢到一个队列里,往往比在每个组件里写防抖、节流和竞态判断更可靠。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-259.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~