从Promise到并发

294 阅读6分钟

异步是一个非常大的话题。我们可以先说说,在浏览器中是如何实现的。

1. 异步的逻辑

1.1 浏览器的一些原理

我们知道,在V8的引擎中,有事件循环的机制存在,很多人熟知的一个流程如下:

并由此还可以引申出很多,比如关于 IDEL 和 postMessage 在 React 的 scheduler 中的取舍,但是我想问:

  • 这个任务具体是如何执行的?
  • 这个和任务队列的关系是什么样的?
  • 以及协程的一些概念?

所以我接下来要讲的是,以上的流程,是如何实现的。

以上的描述,才是我认为细节比较足够的说法,而I/O 、 network 对应的处理结果,会以任务的形式添加进入任务队列。

  1. 关于setTimeout的4ms左右延迟,是浏览器检测到多次循环这个任务时,人为做出的延时处理。
  2. 理论上来说,宏任务切换时,未必是连续的,要看当前任务队列中的任务而决定。

1.2 为什么要有微任务

有的人会说,如果这样的话,不断地添加处理宏任务就好的,为什么要有微任务?

我觉得原因有以下:

  • 主线程执行消息队列的宏任务粒度不够,微任务可以在实时性和效率之间,做一个有效的权衡;
  • 微任务可以改变我们当前编程的模型;
  • 另外,特殊情况下,调用栈并发量太大,微任务可以解决异步(setTimeout...)时机不可控的问题

所以,V8 为每一个宏任务维护了一个微任务的队列,在宏任务处理完成时,V8准备退出宏任务之前,会检查微任务队列。而微任务会在这个任务的时间周期内,随之执行。

值得说明的是,V8执行微任务的过程中,会退出当前的调用栈,所以一个死循环的微任务是不会造成栈溢出的,但是会阻塞接下来的宏任务执行

2. 异步的发展

2.1 callback

使用场景:

  • 事件回调
  • Ajax请求
  • Node API
  • setTimeout、setInterval等异步事件回调

在上述场景中,我们最开始的处理方式就是在函数调用时传入一个回调函数,在同步或者异步事件完成之后,执行该回调函数。可以说在大部分简单场景下,采用回调函数的写法无疑是很方便的,比如我们熟知的几个高阶函数:

我们写一段简单的代码,一个带有定时器的函数.

function foo() {
    setTimeout(() => {
        console.log("foo")
    }, 1000)
}

毫无疑问,一秒后,这里会打印 foo这个字符串。那么如果我们把 foo 看作是一个复杂计算的结果。如何让被调用者调用呢?

function foo(fn) {
    setTimeout(() => {
        console.log("foo");
        fn('foo')
    }, 1000)
}

// 调用代码:
foo((res) => {
    console.log(`I‘ve got ${res}`)
})

典型的,像node中fs库的相关函数,很多都是这种用法,只是在使用时,我们需要手动的 bind 一下 this.

但在一些复杂业务的处理中,我们如果仍然秉持不抛弃不放弃的想法顽强的使用回调函数的方式就可能会出现下面的情况:

s.readFile('a.txt', 'utf-8', function(err, data) {
    fs.readFile('b.txt', 'utf-8', function(err, data1) {
        fs.readFile('c.txt', 'utf-8', function(err, data2) {
            // ......
        })
    })
})

2.2 Promise

Promise 应该是目前最常用的一种异步方式了。从数据请求API fetch,到 split chunkwebpack 中的Promise.all等。

2.3 generator

generator 的使用,我想大家应该都没有任何问题,我们来看一个简单的例子。

function* gen() {
    yield "frist mession";
    yield "second mession";
    let result = yield "third mession";
    return result
}

let result = gen();
console.log(result.next());
console.log(result.next());
console.log(result.next());
console.log(result.next("over"));

////////////////////////////

// { value: 'frist mession', done: false }
// { value: 'second mession', done: false }
// { value: 'third mession', done: false }
// { value: 'over', done: true }

这个问题的打印结果我想不用细说,但是和 Promise 最大的不同是,这是如何运行的?

协程

这背后的原理是一种比线程更加细粒度的存在,叫做协程。

你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

在每一次的 result.next() 函数调用时,都会将执行权交给 getResult() 函数,同时 getResult() 函数释放执行权。

2.4 async/await

由于 generator 函数需要生成器去驱动执行,所以在 ES7 中引入了 async/await ,这是 Javascript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力

MDN:async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

同样的,async 声明的函数在执行时,也是单独的一个协程,我们可以使用 await 来暂停这个协程。网上基于 generator 实现 async / await 的例子非常多,比较典型的也是从 co 库出发来实现:

const myAsync = (gen) => {
    return new Promise((resolve, reject) => {
        const itr = gen();
        const next = (ctx) => {
            let res;
            try {
                res = itr.next(ctx);
            } catch (e) {
                reject(e);
            }
            // 迭代 generator
            if (res.done) {
                resolve(res.value);
            } else {
                res.value.then(next, (err) => {
                    // 处理错误
                    let _res;
                    try {
                        _res = itr.throw(err);
                    } catch (e) {
                        return reject(e)
                    }
                    next(_res);
                })
            }
        }
        next();
    })
}

异步的进一步思考

2.1 如何并发执行 promise ?

这个问题比较简单,我们使用 Promise.all 即可以实现了。

假设,我们现在有 100 个 promise 的任务,每个任务resolve(index),通过一个随机时间Math.Random(),我们先来写一下这个测试的函数:

// 定义一个 Promise 的生成器
const promiseArrGenerator = (num) =>
    new Array(num).fill(0).map((item, index) => () => new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(index);
        }, Math.random() * 1000)
    }))

// 生成 100 个返回 Promise 的 函数
let proArr = promiseArrGenerator(100);

// 执行
Promise.all(proArr.map(fn => fn()))
    .then(res => console.log(res))

毫无疑问,我们大概会在1秒中的时间内,打印出0-99的数组;

2.2 如果设计一个promiseChain,链式调用?

但是如果我想顺序调用该如何处理?

我们可以使用 reduce 方法,对所有的函数串行执行:

const promiseChain = (proArr) => {
    proArr.reduce((proChain, pro) => proChain.then(res => {
        ~res && console.log(res);
        return pro();
    }), Promise.resolve(-1))
}

promiseChain(proArr);

我们可以看到控制台,0-99,不对,是0~98的执行结果。

为什么? 请注意 reduce 的终止条件,这里需要再加上一行:

const promiseChain = (proArr) => {
    proArr.reduce((proChain, pro) => proChain.then(res => {
        ~res && console.log(res);
        return pro();
      // 把 -1 的情况 hack 掉
    }), Promise.resolve(-1))
   .then(res => console.log(`the last one : ${res}`))
}

该部分的完整代码如下:

const promiseArrGenerator = (num) =>
    new Array(num).fill(0).map((item, index) => () => new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(index);
        }, Math.random() * 100)
    }))


let proArr = promiseArrGenerator(100);


const promiseChain = (proArr) => {
    proArr.reduce((proChain, pro) => proChain.then(res => {
      	// 把 -1 的情况 hack 掉
        ~res && console.log(res);
        return pro();
    }), Promise.resolve(-1))
        .then(res => console.log(`the last one : ${res}`))
}

promiseChain(proArr);

2.3 如何设计一个 pipe,去保证并发量的处理

假设我们当前的 Promise 需要并发控制,那么该怎么做呢?

const promiseArrGenerator = (num) =>
    new Array(num).fill(0).map((item, index) => () => new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(index);
        }, Math.random() * 100)
    }))


let proArr = promiseArrGenerator(100);


const promisePipe = (proArr, concurrent) => {
    if (concurrent > proArr.length) {
        return Promise.all(proArr.map(fn => fn())).then(resArr => console.log(resArr));
    }
    // 大于并发数的情况:
    let _arr = [...proArr];
    for (let i = 0; i < concurrent; ++i) {
        let fn = _arr.shift();
        run(fn)
    }
		// 递归去执行调用
    function run(fn) {
        fn().then(res => {
            console.log(res);
            if (_arr.length) run(_arr.shift())
        })
    }

}

promisePipe(proArr, 10)

有的同学会问,有没有像 promiseChain 一样优雅的方式呢?

有,我这里写一段伪代码,原因在注释里已经标注了:

let proArr = new Array(100).fill(0).map((_, index) => index);
// [0, 1, 2, 3, 4, ..., 99]

const promisefy = (value) => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(value);
    }, Math.random() * 100)
})

const promisePipe = (proArr, concurrent) => {
    let _arr = [...proArr];
    if (concurrent >= proArr.length) {
        // promise 的数量低于并发数
        Promise.all(proArr.map(item => promisefy(item)))
            .then(resArr => console.log(resArr))
    } else {
        let prevArr = _arr.splice(0, concurrent).map(item => promisefy(item));
        _arr.reduce((proPipe, current) =>
            proPipe
                .then(() => Promise.race(prevArr))
                .then(res => {
                    console.log(res);
                    // 这里只是一段伪代码,要根据实际情况找到对应的那个执行完了的 promise
                    let index = prevArr.findIndex(item => item === res);
                    prevArr.splice(index, 1, current);
                }), Promise.resolve(-1))
            .then(() => Promise.all(prevArr))
    }
}

promisePipe(proArr, 10)

这里我们很难在一组 Promise 的数组中,去找到究竟哪个是执行完了的。

怎么办呢?

其实也有一个比较 hack 的做法,我说出来,你们看一下:

我可以在每一个Promise中重写一下它的 toString 方法,去返回这个数字:

let proArr = new Array(15).fill(0).map((_, index) => index);
// [0, 1, 2, 3, 4, ..., 99]

const promisefy = (value) => {
    const newPromise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(value);
        }, Math.random() * 100)
    })
    // 这里的HACK -> 不太建议这样做。
    newPromise.toString = () => value;
    return newPromise;
}

const promisePipe = (proArr, concurrent) => {
    let _arr = [...proArr];
    if (concurrent >= proArr.length) {
        // promise 的数量低于并发数
        Promise.all(proArr.map(item => promisefy(item)))
            .then(resArr => console.log(resArr))
    } else {
        let prevArr = _arr.splice(0, concurrent).map(item => promisefy(item));
        _arr.reduce((proPipe, current) =>
            proPipe
                .then(() => Promise.race(prevArr))
                .then(res => {
                    console.log(res);
                    // 这里就可以获取到那个 Index 了
                    let index = prevArr.findIndex(item => item.toString() === res );
                    prevArr.splice(index, 1, promisefy(current));
                }), Promise.resolve(-1))
            .then(() => Promise.all(prevArr))
            .then(resArr => console.log(resArr))
    }
}

promisePipe(proArr, 10);

想到这里,我想大家可能也会有一些比较好的想法:

比如给 promise 封装成一个对象,加上索引值等等,这里我就不详细写了,大家可以在评论区讨论下。

2.4 设计一个 stream, 按照时间并发执行;

举个例子,一个正常的Promise function,我们大概是这样调用的:

func(data).then(res => console.log(res));

现在我们希望这样调用:

promiseStream(func, data).then(res => console.log(res));

而所有被 promiseStream 包裹的函数,(为方便观察效果),以每 1000ms 的时间并发执行,我们先来写一下测试用例:

const func = function (data) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(data);
        }, 0)
    })
}
promiseStream(func, "1").then(res => console.log(res));
setTimeout(() => {
    promiseStream(func, "2").then(res => console.log(res));
    promiseStream(func, "3").then(res => console.log(res));
    promiseStream(func, "4").then(res => console.log(res));
}, 500)

setTimeout(() => {
    promiseStream(func, "4").then(res => console.log(res));
    promiseStream(func, "5").then(res => console.log(res));
    promiseStream(func, "6").then(res => console.log(res));
}, 900) // 可以改成1100再试试

setTimeout(() => {
    promiseStream(func, "7").then(res => console.log(res));
    promiseStream(func, "8").then(res => console.log(res));
    promiseStream(func, "9").then(res => console.log(res));
}, 1500)

针对以上用例,我们希望1s 后打印 1,2,3,4,5,6,再过1s再打印 7,8,9。

来看一下实现吧:

const promiseStream = (function (delay) {
    let timer = null;
    let funcArray = [];
    return function (fn, data) {
        function settle() {
            funcArray.forEach(item => {
                item.fn(item.data)
                    .then(item.resolve, item.reject)
            })
            clearTimeout(timer);
            timer = null;
            funcArray = [];
        }

        if (!timer) timer = setTimeout(settle, delay);
        return new Promise((resolve, reject) => {
          // 将 resolve 和 reject 存储起来。
            funcArray.push({ fn, data, resolve, reject });
        })
    }
})(1000)

再来写几个和 Promise 相关的实现吧

作为面试的常考题,我们就对着 MDN 上一个一个来:

Promise.all()

关于 Promise.all 的写法是一个非常常见的考题,这里最主要要注意几个点:

  • 当 Promise.all 出现错误,可以直接 reject
  • Promise.all 的数组中,如果不是 promise 应该直接 resolve(value)
  • Promise.all 的过程,需要 try ... catch ...
function PromiseAll(args) {
  return new Promise((resolve, reject) => {
    if(!(args instanceof Array)) reject(new Error("typo"))
  	let len = args.length, count = 0;
    let resultArray = new Array(len);
    try {
    	for(let i = 0; i < len; i++) {
      	Promise.resolve(args[i]).then(res => {
        	resultArray[i] = res;
          count++;
          if(count === len) {
          	resolve(resultArray);
          }
        })
        .catch(reject);
      }
    } catch(err) {
      	reject(err);
     }
  })
}

Promise.allSettled()

function PromiseAllSettled(args) {
  return new Promise((resolve, reject) => {
    if(!(args instanceof Array)) reject(new Error("typo"))
  	let len = args.length, count = 0;
    let resultArray = new Array(len);
    try {
    	for(let i = 0; i < len; i++) {
      	Promise.resolve(args[i])
        .then(res => {
        	resultArray[i]= {
          	status: "fulfilled",
            value: res
          };
        })
        .catch(err => {
        	resultArray[i] = {
          	status: "rejected",
            value: err
          };
        })
        .finally(() => {
        	++count === args.length && resolve(resultArray)
        })
        ;
      }
    } catch(err) {
      	reject(err);
     }
  })
}

Promise.any()

function PromiseAll(args) {
  return new Promise((resolve, reject) => {
    if(!(args instanceof Array)) reject(new Error("typo"))
  	let len = args.length, count = 0;
    let resultArray = new Array(len);
    try {
    	for(let i = 0; i < len; i++) {
      	Promise.resolve(args[i]).then(resolve)
        .catch(err => {
        	resultArray[i] = err;
          count++;
          if(count === len) {
          	reject(resultArray);
          }
        });
      }
    } catch(err) {
      	reject(err);
     }
  })
}

Promise.race()

function promiseRace(args){
	return new Promise((resolve, reject) => {
  	try {
    	for(let i = 0; i < len; i++) {
      	Promise.resolve((args[i])
        .then(resolve, reject);
      }
    } catch(err) {
      	reject(err);
     }
  })
}

Promise.reject()

function promiseReject(reason) {
	return new Promise((resolve, reject) => {
  	reject(reason);
  })
}

Promise.resolve()

function promiseReject(value) {
	return new Promise((resolve, reject) => {
  	reject(value);
  })
}

参考文献

前端开发核心知识进阶

图解 Google V8