一道字节前端面试题,我直接把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链,直接秀面试官一脸,哈哈!

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

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax