我们将通过一道高频代码题来给出任务调度问题的一个通用解法,同时抛砖引玉,希望有其他理解的大佬在评论区发表意见。
实现类LazyMan
new LazyMan('Hank')
// 你好,我是Hank
new LazyMan('Hank').eat('🍎')
// 你好,我是Hank
// 吃了 🍎
new LazyMan('Hank').eat('🍎').sleep(1).eat('🍌')
// 你好,我是Hank
// 吃了 🍎
// (过了1秒) 睡了 1秒
// 吃了 🍌
new LazyMan('Hank').eat('🍎').sleepFirst(3)
// 你好,我是Hank
// (过了3秒) 睡了 3秒
// 吃了 🍎
new LazyMan('Hank').eat('🍎').sleep(1).eat('🍌').sleepFirst(3)
// 你好,我是Hank
// (过了3秒) 睡了 3秒
// 吃了 🍎
// (过了1秒) 睡了 1秒
// 吃了 🍌
拆解
初见题目,会有一种看着不难却总有哪里不对劲的感觉。Anyway,我们先将其拆解,职责分离,一切就清晰很多。同时采用 TS 编写也是某种程度上的 TDD,能做到编辑器不飘红就成功了一半。
我们先把代码架子搭建一下。
class LazyMan {
constructor(name: string) {
console.log(`你好,我是${name}`)
}
sleep(second: number) {
console.log(`睡了 ${second}秒`)
}
sleepFirst(second: number) {
console.log(`睡了 ${second}秒`)
}
eat(food: string) {
console.log(`吃了 ${food}`)
}
}
拆解如下:
- 链式调用。
- 阻塞运行
sleep方法正常阻塞。sleepFirst方法提前阻塞。
链式调用
这个简单,在方法内直接将实例返回出来。
class LazyMan {
constructor(name: string) {
console.log(`你好,我是${name}`)
}
sleep(second: number) {
console.log(`睡了 ${second}秒`)
return this
}
sleepFirst(second: number) {
console.log(`睡了 ${second}秒`)
return this
}
eat(food: string) {
console.log(`吃了 ${food}`)
return this
}
}
new LazyMan('Hank').eat('🍎').sleep(1) // ts不报错
阻塞运行
阻塞运行貌似就是这道题的关键。试想下,该如何实现阻塞呢? 最直观的方式:长任务堵死线程。
// (伪代码)
class LazyMan {
sleep(second) {
let startTime = performance.now()
while (true) {
if (performance.now() - startTime > 1000 * second) {
break
}
}
return this
}
}
这个方式直观、简单。sleep 会卡住 n 秒,结束之后也会自动调用下一个方法,一切都是那么的顺其自然。但他仍旧无法实现需求:
- 功能孱弱,无法实现 sleepFirst 这种特殊功能。
- 性能浪费严重。
不过能提出问题就相当于成功了一半,我们接下来就来解决它们。
调度才是关键
对于问题一,“你方唱罢我登场”式的设计之所以简单直观,是因为我们不用考虑任务调度。但真的没有调度吗?不是的。
我们的链式调用本身就自带一种调度系统:方法调用的顺序决定了任务执行的顺序。所以如果你没考虑到调度,是因为你忽视了链式调用自带的调度。
看到这你或许就明白了:阻塞运行只是一个表象,隐藏在背后的其实是任务的调度。而链式调用更像是一个陷阱,让你忽视了调度问题。
所以问题一的本质是:默认的调度系统太弱了。既然如此,那我们就自己实现一套更强的调度系统。
所谓调度,可以理解为通过维护一个指定的上下文,在指定的时机做指定的事。既然调度由我们自己来实现,那就可以不用长任务卡死线程来实现阻塞,这样也同时解决了问题二。
如果你已经灵感迸发了,不妨自己接着解题~
实现
首先,我们构造任务。使用interface而不是type,确保可扩展与可维护性。
interface Task {
action: () => any // 任务行为
}
任务有了,我们接着实现调度,看下面这段代码。
new LazyMan('Hank').sleep(1).eat('🍎').sleep(1).eat('🍌')
我们猜测它的输出为
0s 无动作
1.0s 打印 睡了 1秒
1.0s 打印 吃了 🍎
2.0s 打印 睡了 1秒
2.0s 打印 吃了 🍌
第一列是时间,第二列是行为。
我们之前所说的调度本质:通过维护一个指定的上下文,在指定的时机做指定的事。而在这里,“时机”就是第一列的时间,“事”就是第二列的“行为”。
行为我们不需要从上下文而来,因为在这个场景下不会对行为有什么处理,而时间则需要从上下文而来:如果睡了n秒,那么后面所有行为都要延后n秒,所以我们需要维护一个名为totalDelay的上下文,每一个任务都会影响totalDelay,同时totalDelay也会影响每一个任务的时间。由此,我们先扩展Task,并实现调度具体的系统。
// 行为和时间应该是分离的,具体的调度的控制权要交给上层的调度系统
interface Task {
delay: number // 调用时机
action: () => any // 任务行为
}
// 一个工厂函数,构造一个任务。之所以不用class,是因为类太重了,用不上。
function taskFactory(delay: number, action: Task['action']): Task {
return {
delay,
action,
}
}
调度系统方面,我们使用队列这一数据结构,在任务调度的场景下十分常见,比如JS的核心机制:事件循环,就是在任务队列+调用栈之上运转的。
class TaskQueue {
constructor() {}
queue: Task[] = []
totalDelay = 0 // 重点关注
run() { // 重点关注
this.queue.forEach((task) => {
this.totalDelay += task.delay
setTimeout(() => {
task.action()
}, this.totalDelay)
})
}
addTask(task: Task) {
this.queue.push(task)
}
unshiftTask(task: Task) {
this.queue.unshift(task)
}
}
这段代码大家都能看懂,重点关注totalDelay和run方法。这个totalDelay就是我们调度系统中的上下文。run则是调度的核心:维护上下文并根据上下文来确定任务的调用时机。至此
与实际场景集成
大家应该发现了,这套调度系统和我们的实际场景LazyMan是完全解耦的,因为实际场景是千变万化的,我们要确保可扩展性。现在我们将二者集成:
class LazyMan {
name: string
private taskQueue: TaskQueue
constructor(name: string) {
this.name = name
this.taskQueue = new TaskQueue()
this.greet()
setTimeout(() => {
this.taskQueue.run()
})
}
greet() {
console.log(`你好,我是${this.name}`)
}
sleep(second: number) {
this.taskQueue.addTask(
taskFactory(second * 1000, () => {
console.log(`睡了 ${second}秒`)
})
)
return this
}
sleepFirst(second: number) {
this.taskQueue.unshiftTask(
taskFactory(second * 1000, () => {
console.log(`睡了 ${second}秒`)
})
)
return this
}
eat(food: string) {
this.taskQueue.addTask(
taskFactory(0, () => {
console.log(`吃了 ${food}`)
})
)
return this
}
}
注意要在一个异步任务中调用taskQueue.run(),或者自己实现一套订阅、触发机制,而如果要做这些改动也只要改动LazyMan而不用去动taskQueue。