并发任务控制
前言
我们在日常开发中,如果遇到有大量的任务需要同时进行时,就可以使用 并发任务控制 来控制任务的并发执行。
拟定并发任务实现
首先,我们拟定一个构造函数,用来实现 并发任务控制:
/**
* 构造函数,异步任务的调度器
* 这是一个通用型的函数。像并发请求之类的东西,都可以使用它进行完成。
*/
class SuperTask {
// ...待实现
}
// 这里创建了一个 SuperTask 的构造函数的实例
const superTask = new SuperTask()
然后,我们就要使用该构造函数的实例对象来实现并发任务的控制。
假设我们已经有两个辅助函数,分别是 timeout 和 addTask,timeout 这个辅助函数非常简单,就是模拟异步操作,等待一段时间后 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 ->
10s完成。 - 任务2 ->
5s完成。 - 任务3 -> 给定
3s,由于此时 任务1 和 任务2 还在执行过程中,所以先等待,当 任务2 完成后再继续执行。所以 任务3 总耗时8s。 - 任务4 -> 等待。
- ...
这其实就是一个 异步任务的调度器。这是一个 通用型的函数。像并发请求之类的东西,都可以使用它进行完成。
实现 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 里边的 resolve 或 reject,这样才能保证之前 add 返回的 Promise 的状态是完成或拒绝。
但是,问题是在 _run() 函数里边找不到 add() 中 Promise 的 resolve 和 reject。这也是我们在封装一些跟 Promise 相关的公共功能的时候,经常会遇到的问题。就是在函数 b 里边找不到函数 a 的一些局部信息了。
那么怎么办呢?这里的做法基本都是统一的,那就是在 add() 中添加任务到队列的时候,就不仅仅是把任务本身添加进去,还需要把 resolve 和 reject 也加进去。
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完成
}
结果如下:
如果把 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)
}