一道字节前端面试题,我直接把Promise的使用功力秀面试官一脸!

今天我们来研究一道字节跳动的前端面试题,即JavaScript如何实现并发控制

题目是这样的:要求用JS实现一个带并发限制的异步调度器Scheduler,保证同时运行的任务最多有两个。

它给出的初始代码如下,要求我们实现addTask方法,使之输出结果满足要求。

js 复制代码
class Scheduler {
  // ...todo
}
const scheduler = new Scheduler(2);
const addTask = (time, name) => {
  scheduler.add(time, name);
};

addTask(1000,"1"); // 1000ms后输出1
addTask(500,"2");  // 500ms后输出2
addTask(600,"3"); // 1100ms后输出3
addTask(400,"4"); // 1400ms后输出4
scheduler.start();

我们先来分析一下这段代码可以得到如下信息:

  1. scheduler是一个构造器,它提供一个构造器入参,限制任务同时并发的数量,它有一个start方法,调用start后,任务会开始执行。它还有一个add方法,往scheduler中添加任务。
  2. addTask函数有两个参数timename,分别表示任务执行的时间任务名字

我们再来依次分析下输出结果

  1. 1000ms后输出1:这个很好理解,因为第一个任务的执行时间就是1000ms;
  2. 500ms后输出2:这个也好理解,因为第二个任务的执行时间就是500ms;
  3. 1100ms后输出3:这里第三个任务是在1100ms后输出的,是因为任务同时执行的并发数量为2个,所以必须这第三个任务必须等待前两个任务其中一个完成后,才能开始执行,这里是等待了第二个任务执行了500ms,再加上自己本身的执行时间600ms,所以1100ms后才输出3。
  4. 1400ms后输出4:第三个任务开始执行后,这时候会等待任务一和任务三哪个先完成,很现任这时候任务一只剩500ms就执行完了,而任务三却需要600ms执行完,所以任务四会在任务一执行完成后才开始执行,所以任务四需要任务一的1000ms + 本身的400ms总共1400ms后输出4。

这个并发控制就像是你去银行办业务,银行只有2个柜台,所以最多只能有两个人同时办业务,第三个人需要等待前两个人的其中一个办完业务后,才能开始办业务,第四个人的话依次类推

明白了题目的要求之后,我们这里开始实现:

解题思路一:等任务完成后递归执行下一个任务,并且用变量runningCount控制并发数

先来实现下Schedulerconstructor

js 复制代码
class Scheduler {
    constructor(limit) {
        this.tasks = [] // 任务队列
        this.limit = limit // 最大并发数
        this.runningCount = 0 // 当前正在运行的任务个数
    }
}

我们先来实现下添加任务的add方法:

js 复制代码
class Scheduler {
    // ...
    add(delay, name) {
        const promiseCreator = () => {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log(name)
                    resolve()
                }, delay)
            })
        }
        this.tasks.push(promiseCreator)
    }
    // ...
}

我这里声明了一个promiseCreator函数,它用来在执行的时间后执行任务,并且返回一个promise

我们还需要一个执行任务的run方法:

js 复制代码
class Scheduler {
    // ...
   run() {
        // tasks空校验 + 限制并发数
        if (!this.tasks.length || this.runningCount >= this.limit) {
            return
        }
        // 当前正在执行的任务数+1
        this.runningCount++
        // 从 tasks 头部取出一个任务并执行
        this.tasks.shift()().finally(() => {
            // 当前任务已经执行完了,所以 runningCount 需要-1
            this.runningCount--
            // 执行下一个任务
            this.run()
        })
    }
    // ...
}

最后我们需要实现start方法,先取limit个任务并调用run方法开始执行。

js 复制代码
class Scheduler {
    // ...
  start() {
      for(let i = 0; i < this.limit; i++) {
          this.run()
      }
  }
  // ...
}

完整代码如下:

js 复制代码
class Scheduler {
    constructor(limit) {
        this.tasks = [] // 任务队列
        this.limit = limit // 最大并发数
        this.runningCount = 0 // 当前正在运行的任务个数
    }
    add(delay, name) {
        const promiseCreator = () => {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log(name)
                    resolve()
                }, delay)
            })
        }
        this.tasks.push(promiseCreator)
    }
    run() {
        // tasks空校验 + 限制并发数
        if (!this.tasks.length || this.runningCount >= this.limit) {
            return
        }
        // 当前正在执行的任务数+1
        this.runningCount++
        // 从 tasks 头部取出一个任务并执行
        this.tasks.shift()().finally(() => {
            // 当前任务已经执行完了,所以 runningCount 需要-1
            this.runningCount--
            // 递归执行下一个任务
            this.run()
        })
    }
    start() {
        for(let i = 0; i < this.limit; i++) {
            this.run()
        }
    }
}

核心思路就是:比如并发数为2个,

  1. 先在start方法中,调用run方法开始执行前两个任务;
  2. 然后在每一个任务执行完后,继续调用run方法递归执行下一个任务。

你可能会有疑问,在我这里runningCount似乎并没取到作用,似乎去掉也能正常得到想要的结果。

但假如start方法被一次性主动调用多次,没有runningCount限制,输出结果就会异常了,有兴趣的小伙伴可以试下。

解题思路二:利用Promise链 + Promise.race

同样的先来实现下Schedulerconstructor

js 复制代码
class Scheduler {
    constructor(limit) {
        this.tasks = []
        this.limit = limit
    }
}

我们先来实现下添加任务的add方法,这里只是把任务执行时间delay和任务名称name存到一个对象中:

js 复制代码
class Scheduler {
    // ...
    add(delay, name)  {
        this.tasks.push({ delay, name })
    }
    // ...
}

再来实现下start方法:

js 复制代码
class Scheduler {
    // ...
    start() {
        start() {
        // 创建一个只有 limit 个并发量的 任务池
        const pool = this.tasks.slice(0, this.limit).map(({ delay, name }, index) => {
            return new Promise((resolve) => {
                setTimeout(() => {
                    console.log(name)
                    // 这里把当前任务在任务池中的索引传递出去,后续会用到
                    resolve(index)
                }, delay)
            })
        })
        let p = Promise.race(pool)
        for(let i = this.limit; i < this.tasks.length; i++) {
            // 通过for循环形成一条promise链
            p = p.then((index) => {
                // 利用Promise.race拿到任务池中最快完成的任务,在then回调用通过之前存的这个任务在任务池中的索引,用新的任务将它替换
                pool[index] = new Promise((resolve) => {
                    const { delay, name } = this.tasks[i]
                    setTimeout(() => {
                        console.log(name)
                        resolve(index)
                    }, delay)
                })
                // 再利用Promise.race拿到任务池中最快完成的任务
                return Promise.race(pool)
            })
        }
        return p
    }
    }
    // ...
}

完整代码如下:

js 复制代码
class Scheduler {
    constructor(limit) {
        this.tasks = []
        this.limit = limit
    }
    start() {
        // 创建一个只有 limit 个并发量的 任务池
        const pool = this.tasks.slice(0, this.limit).map(({ delay, name }, index) => {
            return new Promise((resolve) => {
                setTimeout(() => {
                    console.log(name)
                    // 这里把当前任务在任务池中的索引传递出去,后续会用到
                    resolve(index)
                }, delay)
            })
        })
        let p = Promise.race(pool)
        for(let i = this.limit; i < this.tasks.length; i++) {
            // 通过for循环形成一条promise链
            p = p.then((index) => {
                // 利用Promise.race拿到任务池中最快完成的任务,在then回调用通过之前存的这个任务在任务池中的索引,用新的任务将它替换
                pool[index] = new Promise((resolve) => {
                    const { delay, name } = this.tasks[i]
                    setTimeout(() => {
                        console.log(name)
                        resolve(index)
                    }, delay)
                })
                // 再利用Promise.race拿到任务池中最快完成的任务
                return Promise.race(pool)
            })
        }
        return p
    }
    add(delay, name)  {
        this.tasks.push({ delay, name })
    }
}

其核心思路如下:

  1. 先创建一个任务池pool,pool的长度就是并发数量
  2. 在将任务推入到pool中时,通过闭包,将每个任务在pool中的索引保存下来,
  3. 通过for循环形成一条promsie链promise链的长度就是剩下需要执行的任务的个数
  4. 通过Promise.race拿到任务池中最快完成的任务,在其then回调用通过之前存的这个任务在任务池中的索引,用新的任务将它替换。

小结

上面介绍了一道字节的面试真题,即如何通过JavaScript实现并发控制,并提供了两种解决方案,第一种方案比较常规一些,相信大家很容易能想到,而第二种实现方式,就需要看到并发控制第一时间就想到使用Promise.race了,再配上promise链,直接秀面试官一脸,哈哈!

除了这两种大家还有其它更巧妙的解题思路么?欢迎大家留言评论!

相关推荐
CodeSheep3 分钟前
当了leader才发现,大厂最想裁掉的,不是上班总迟到的,也不是下班搞失联的,而是经常把这3句话挂在嘴边的
前端·后端·程序员
吃饺子不吃馅8 分钟前
✨ 你知道吗?SVG 里藏了一个「任意门」——它就是 foreignObject! 🚪💫
前端·javascript·面试
IT_陈寒1 小时前
Python开发者必须掌握的12个高效数据处理技巧,用过都说香!
前端·人工智能·后端
gnip8 小时前
企业级配置式表单组件封装
前端·javascript·vue.js
一只叫煤球的猫9 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
excel10 小时前
Three.js 材质(Material)详解 —— 区别、原理、场景与示例
前端
掘金安东尼10 小时前
抛弃自定义模态框:原生Dialog的实力
前端·javascript·github
hj5914_前端新手14 小时前
javascript基础- 函数中 this 指向、call、apply、bind
前端·javascript
薛定谔的算法14 小时前
低代码编辑器项目设计与实现:以JSON为核心的数据驱动架构
前端·react.js·前端框架
Hilaku14 小时前
都2025年了,我们还有必要为了兼容性,去写那么多polyfill吗?
前端·javascript·css