JavaScript 并发任务控制

285 阅读2分钟

并发任务控制

前言

我们在日常开发中,如果遇到有大量的任务需要同时进行时,就可以使用 并发任务控制 来控制任务的并发执行。

拟定并发任务实现

首先,我们拟定一个构造函数,用来实现 并发任务控制

/**
 * 构造函数,异步任务的调度器
 * 这是一个通用型的函数。像并发请求之类的东西,都可以使用它进行完成。
 */
class SuperTask {
  // ...待实现
}

// 这里创建了一个 SuperTask 的构造函数的实例
const superTask = new SuperTask()

然后,我们就要使用该构造函数的实例对象来实现并发任务的控制。

假设我们已经有两个辅助函数,分别是 timeoutaddTasktimeout 这个辅助函数非常简单,就是模拟异步操作,等待一段时间后 Promise 完成, addTask 来帮助我们使用 SuperTask 的实例对象来进行添加任务。

当然,这两个辅助函数不是必要的,我们直接在业务中使用 SuperTask 也可以,这里只是为了方便:

/**
 * 延迟函数
 * 返回一个 Promise,然后在指定时间到达后,这个 Promise 完成。
 * @param {*} time 
 * @returns 
 */
function timeout(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, time)
  })
}

/**
 * 添加任务
 * @param { Number } time 延时时间
 * @param { String | Number } name 任务名称
 */
function addTask(time, name) {
  superTask
    // 利用实例提供的方法添加任务
    // 这个 add 本身应该也得返回一个 Promise,
    // 当这个 Promise 完成后就会执行 then 方法。
    .add(() => timeout(time))
    .then(() => {
      console.log(`任务${name}完成`)
    })
}

最后,我们想要实现的 并发任务 的执行结果:

addTask(10000, 1) // 10000ms 后输出:任务1完成
addTask(5000, 2) // 5000ms 后输出:任务2完成
addTask(3000, 3) // 8000ms 后输出:任务3完成
addTask(4000, 4) // 12000ms 后输出:任务4完成
addTask(5000, 5) // 15000ms 后输出:任务5完成

通过以上调用的结果可以发现,同一个时刻最多只能同时进行两个任务。为什么这么说呢?

比方说:

  1. 任务1 -> 10s 完成。
  2. 任务2 -> 5s 完成。
  3. 任务3 -> 给定 3s,由于此时 任务1任务2 还在执行过程中,所以先等待,当 任务2 完成后再继续执行。所以 任务3 总耗时 8s
  4. 任务4 -> 等待。
  5. ...

这其实就是一个 异步任务的调度器。这是一个 通用型的函数。像并发请求之类的东西,都可以使用它进行完成。

实现 SuperTask 构造函数

确定实例属性

接下来我们就要来具体实现一下这个 SuperTask 构造函数。

首先,在实现这个构造函数的时候,它有几个 属性 需要确定:

  • 最大并发数量。
  • 队列,这个队列就是一个数组。
  • 目前正在处理的任务数量。

按照以上几个属性,我们可以给构造函数添加对应的实例属性:

/**
 * 构造函数,异步任务的调度器
 * 这是一个通用型的函数。像并发请求之类的东西,都可以使用它进行完成。
 */
class SuperTask {
  // paralleCount 最大并发数量,可以在实例化对象时传递进来,默认为 2
  constructor(paralleCount = 2) {
    this.paralleCount = paralleCount// 最大并发数量
    this.tasks = [] // 任务队列
    this.runningCount = 0 // 正在运行的任务数量
  }
}

编写 add 函数来添加任务

接下来要实现实例方法 add,用于添加任务到队列,这个 add 方法会返回一个 Promise

class SuperTask {
  // paralleCount 最大并发数量,可以在实例化对象时传递进来,默认为 2
  constructor(paralleCount = 2) {
    this.paralleCount = paralleCount// 最大并发数量
    this.tasks = [] // 任务队列
    this.runningCount = 0 // 正在运行的任务数量
  }

  // 添加任务到队列
  add(task) {
    return new Promise((resolve, reject) => {
	  // ...待实现
    })
  }
}

现在有一个问题,这个 Promise 里边是不是直接运行参数里接收的这个 task 任务?那可不一定。你就想象我们去银行里边办理业务,银行的流程是先取号,取号的动作是什么?就是排队。

所以,在这个 Promise 里,要先做的是就是排队,而不是直接执行,排队是后续执行的前提条件,也就是说我们需要把接收的 task 任务添加到队列里:

class SuperTask {
  // paralleCount 最大并发数量,可以在实例化对象时传递进来,默认为 2
  constructor(paralleCount = 2) {
    this.paralleCount = paralleCount// 最大并发数量
    this.tasks = [] // 任务队列
    this.runningCount = 0 // 正在运行的任务数量
  }

  // 添加任务到队列
  add(task) {
    return new Promise((resolve, reject) => {
      // 把任务添加到队列
      this.tasks.push(task)
    })
  }
}

添加 _run 辅助函数来执行任务

把任务添加到队列之后,我们又想到一个问题,这个任务什么时候执行呢?还是回到银行办理业务的场景,什么时候叫执行?就是当叫号的时候。

那么什么时候会叫号呢?

比方说此时银行空无一人,这个时候你去取号,取完号之后就会触发银行是否要叫号,能叫号的话就把这个号显示在屏幕上,让客户过去办理业务。

  • 也就是说在我们这个代码中,每次任务进来的时候就会触发要不要叫号,以及叫哪个号这样的功能。
  • 我们把这个功能叫做 _run(),我们写一个 _run() 的辅助函数。这个 _run() 函数就是把一个个的任务执行。
  • 但是它有一个前提条件,就是执行完一个再执行一个。所以,它里边的代码会涉及到循环。循环的条件是什么呢?也就是什么情况下银行会开始叫号呢?就是当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候。
class SuperTask {
  // paralleCount 最大并发数量,可以在实例化对象时传递进来,默认为 2
  constructor(paralleCount = 2) {
    this.paralleCount = paralleCount// 最大并发数量
    this.tasks = [] // 任务队列
    this.runningCount = 0 // 正在运行的任务数量
  }

  // 添加任务到队列
  add(task) {
    return new Promise((resolve, reject) => {
      // 把任务添加到队列
      this.tasks.push(task)
      // 执行任务
      this._run()
    })
  }

  // 执行任务的方法
  _run() {
    // 当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候,开始执行任务
    while(this.runningCount < this.paralleCount && this.tasks.length > 0) {
      const task = this.tasks.shift() // 取出队列中的第一个任务
      this.runningCount++ // 增加正在运行的任务数量
      task() // 执行任务
    }
  }
}

修改 add 函数添加任务逻辑

现在,这里就又涉及要一个问题了,现在在 _run() 里边执行的这个 task() 是哪里来的?是之前 add(task) 中添加任务中添加到任务队列里边的,我们取出了队列里第一个任务作为 task 来执行。

这个 task 调用完成之后,我们是不是得调用 add 里边的 resolvereject,这样才能保证之前 add 返回的 Promise 的状态是完成或拒绝。

但是,问题是在 _run() 函数里边找不到 add()Promiseresolvereject。这也是我们在封装一些跟 Promise 相关的公共功能的时候,经常会遇到的问题。就是在函数 b 里边找不到函数 a 的一些局部信息了。

那么怎么办呢?这里的做法基本都是统一的,那就是在 add() 中添加任务到队列的时候,就不仅仅是把任务本身添加进去,还需要把 resolvereject 也加进去。

class SuperTask {
  // ...省略其它

  // 添加任务到队列
  add(task) {
    return new Promise((resolve, reject) => {
      // 把任务添加到队列
      this.tasks.push({
        task,
        resolve,
        reject
      })
      // 执行任务
      this._run()
    })
  }
}

修改 _run 函数

因为在 add() 中添加任务到队列的时候,已经把任务本身以及对应的改变 Promise 状态的方法也一起添加到队列了,所以在执行 _run 的时候也需要改动:

class SuperTask {
  // ...省略其它

  // 执行任务的方法
  _run() {
    // 当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候,开始执行任务
    while(this.runningCount < this.paralleCount && this.tasks.length > 0) {
      // 取出队列中的第一个任务以及对应的改变 Promise 状态的方法
      const { task, resolve, reject } = this.tasks.shift()
      this.runningCount++ // 增加正在运行的任务数量
      
      // 执行任务
      // 任务成功调用 resolve 方法,失败调用 reject 方法
      task().then(resolve, reject)
    }
  }
}

但是,写到这里会发现一个问题,万一这个 task 返回的不是 Promise,它是一个同步任务,它没有 then 方法,那怎么办?那我们就应该用 Promise.resolve()task() 包裹起来。

Promise.resolve() 静态方法将给定的值转换为一个 Promise。如果该值本身就是一个 Promise,那么该 Promise 将被返回:

class SuperTask {
  // ...省略其它

  // 执行任务的方法
  _run() {
    // 当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候,开始执行任务
    while(this.runningCount < this.paralleCount && this.tasks.length > 0) {
      // 取出队列中的第一个任务以及对应的改变 Promise 状态的方法
      const { task, resolve, reject } = this.tasks.shift()
      this.runningCount++ // 增加正在运行的任务数量
      
      // 执行任务
      // 任务成功调用 resolve 方法,失败调用 reject 方法
      Promise.resolve(task()).then(resolve, reject)
    }
  }
}

一旦这个 task 完成之后,又得继续进行叫号了,也就是说又得继续调用 _run() 函数了。因此还要写一个 .finally() 不管任务执行成功还是失败都继续执行 _run()

class SuperTask {
  // ...省略其它

  // 执行任务的方法
  _run() {
    // 当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候,开始执行任务
    while(this.runningCount < this.paralleCount && this.tasks.length > 0) {
      // 取出队列中的第一个任务以及对应的改变 Promise 状态的方法
      const { task, resolve, reject } = this.tasks.shift()
      this.runningCount++ // 增加正在运行的任务数量
      
      // 执行任务
      // 任务成功调用 resolve 方法,失败调用 reject 方法
      Promise.resolve(task())
      .then(resolve, reject)
      .finally(() => {
        this.runningCount-- // 任务执行完成,减少正在运行的任务数量
        this._run() // 继续执行任务
      })
    }
  }
}

完整代码及测试

index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>并发任务控制</title>
</head>

<body>
  <button>点击运行,查看结果</button>
  <!-- 并发任务控制构造函数 -->
  <script src="./superTask.js"></script>
  <!-- 主程序 -->
  <script src="./index.js"></script>
</body>

</html>

superTask.js

/**
 * 构造函数,异步任务的调度器
 * 这是一个通用型的函数。像并发请求之类的东西,都可以使用它进行完成。
 */
class SuperTask {
  // paralleCount 最大并发数量,可以在实例化对象时传递进来,默认为 2
  constructor(paralleCount = 2) {
    this.paralleCount = paralleCount// 最大并发数量
    this.tasks = [] // 任务队列
    this.runningCount = 0 // 正在运行的任务数量
  }

  // 添加任务到队列
  add(task) {
    return new Promise((resolve, reject) => {
      // 把任务添加到队列
      this.tasks.push({
        task,
        resolve,
        reject
      })
      // 执行任务
      this._run()
    })
  }

  // 执行任务的方法
  _run() {
    // 当前的执行任务的数量小于了并发的任务数量,并且任务队列中有任务的时候,开始执行任务
    while(this.runningCount < this.paralleCount && this.tasks.length > 0) {
      // 取出队列中的第一个任务以及对应的改变 Promise 状态的方法
      const { task, resolve, reject } = this.tasks.shift()
      this.runningCount++ // 增加正在运行的任务数量
      
      // 执行任务
      // 任务成功调用 resolve 方法,失败调用 reject 方法
      Promise.resolve(task())
      .then(resolve, reject)
      .finally(() => {
        this.runningCount-- // 任务执行完成,减少正在运行的任务数量
        this._run() // 继续执行任务
      })
    }
  }
}

index.js 测试 superTask

// 这里创建了一个 SuperTask 的构造函数的实例
// SuperTask 构造函数就是我们要手动实现的方法。
const superTask = new SuperTask()

/**
 * 延迟函数
 * 返回一个 Promise,然后在指定时间到达后,这个 Promise 完成。
 * @param {*} time 
 * @returns 
 */
function timeout(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, time)
  })
}

/**
 * 添加任务
 * 传入一段时间和传入一个任务名称,它要做的事情就是
 * 调用这个实例的 add 方法,从而添加一个任务。
 * 
 * 可以看出这里的任务就是一个异步的函数。而且添加之后会返回一个 Promise,
 * 当这个任务完成之后,会输出这个任务已经完成。
 * 
 * @param { Number } time 延时时间
 * @param { String | Number } name 任务名称
 */
function addTask(time, name) {
  superTask
    // 利用实例提供的方法添加任务
    // 这个 add 本身应该也得返回一个 Promise,
    // 当这个 Promise 完成后就会执行 then 方法。
    .add(() => timeout(time))
    .then(() => {
      console.log(`任务${name}完成`)
    })
}

const btn = document.querySelector('button')
btn.onclick = () => {
  addTask(10000, 1) // 10000ms 后输出:任务1完成
  addTask(5000, 2) // 5000ms 后输出:任务2完成
  addTask(3000, 3) // 8000ms 后输出:任务3完成
  addTask(4000, 4) // 12000ms 后输出:任务4完成
  addTask(5000, 5) // 15000ms 后输出:任务5完成
}

结果如下:

001.png

如果把 timeout 的执行时间都一样,则会以设置的并发数量执行对应的任务:

const btn = document.querySelector('button')
btn.onclick = () => {
  // 如果都改成 1000ms,会发现以 并发数量为 2 的任务数量进行
  // 1s 后同时输出 1 2
  // 2s 后同时输出 3 4
  // 3s 后输出 5
  addTask(1000, 1)
  addTask(1000, 2)
  addTask(1000, 3)
  addTask(1000, 4)
  addTask(1000, 5)
}

参考教程

www.bilibili.com/video/BV1Ex…