正文
我们知道Node.js里充满着大量的异步, 后来出现了Promise以及async/await来解决"callback hell"的问题。我们就来看看promise以及async/await如何简化JS并发代码的编写, 最后再给出一份实现相同功能的Go代码。
问题
代码开发中经常会做的一件事就是去请求一个api, 并可能进一步根据api返回结果去获取访问新的接口。 这里我们构造一个问题:获取https://cnodejs.org/ 前10个主题的id、title、date、作者昵称以及第一个回复者的昵称。 cnodejs提供了api, https://cnodejs.org/api 这里的前两个接口就能满足我们的要求。 首先用https://cnodejs.org/api/v1/topics 接口获取到前10个topics, 然后取出每个topic的id去访问
get /topic/:id 主题详情
接口,
里面可以获取到回复数据。
简单实现
发起网络请求有很多方法, 我们这里采用axios库, 有几个好处, 其中包括同时支持Node.js和Browser。
我们直接用“最先进”的async/await来实现一个版本:
1const axios = require("axios"); 2 3async function getFirst10TopicsIncludeFirstReplyAuthor() { 4 const response = await axios.get( 5 "https://cnodejs.org/api/v1/topics?limit=10" 6 ); 7 const json = response.data; 8 const first10 = json.data.map(topic => { 9 return {10 id: topic.id,11 title: topic.title,12 date: topic.create_at,13 author: topic.author.loginname14 };15 });1617 for (let topic of first10) {18 const response = await axios.get(19 `https://cnodejs.org/api/v1/topic/${topic.id}`20 );21 const json = response.data;22 const firstReply = json.data.replies[0];23 topic.firstReplyAuthor = firstReply && firstReply.author.loginname;24 }2526 return first10;27}2829getFirst10TopicsIncludeFirstReplyAuthor().then(data => console.log(data));
并发
上述代码简单直接, 用了async/await, 异步代码看上去基本上是同步的, 很直观易懂。 先发起一个请求, 获取10个topics的信息, 然后针对每个topic发起一个请求, 去获取第一条回复数据,最后把数据拼凑在一起返回。 由于后面的请求需要第一个请求返回的id, 因此必须等到第一个请求回来才可以发送后面的请求, 这块没有任何问题。 但是后面的10个请求完全是独立的, 因此可以并发请求,这样能大大缩短时间。比如每个请求需要花费1s, 则上述代码总共需要花费
1(第一个请求) + 10(后面10个请求) = 11s
,
而如果将第二步的请求完全并发则只需要
1(第一个请求) + 1(后面10个请求同时请求) = 2s
!!!
由于网络请求受网速影响很大不利于我们精确分析问题, 也避免大量的请求给Cnodejs服务造成影响, 我们在本地用
setTimout
模拟网络请求花费的时间。
上述代码在并发性上跟下面代码基本等价:
1// 模拟一次api网络请求花费1s 2function mockAPI(result, time = 1000) { 3 return new Promise((resolve, reject) => { 4 setTimeout(() => { 5 resolve(result); 6 }, time); 7 }); 8} 910async function get10Topics() {11 const t1 = Date.now();12 const result = [];13 const total = await mockAPI(10);14 for (let i = 1; i <= total; i += 1) {15 const r = await mockAPI(i);16 result.push(r);17 }18 const t2 = Date.now();19 console.log(`total cost: ${t2 - t1}ms.`);20 return result;21}2223get10Topics().then(data => console.log(data));
执行之后发现, 确实在11s左右:
1➜ test-js git:(master) ✗ node p1.js2total cost: 11037ms.3[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Promise.all可以同时发起多个Promise,等到所有Promise都完成了之后返回一个数组, 包含每个Promise的结果。
1// 模拟一次api网络请求花费1s 2function mockAPI(result, time = 1000) { 3 return new Promise((resolve, reject) => { 4 setTimeout(() => { 5 resolve(result); 6 }, time); 7 }); 8} 910async function get10Topics2() {11 const t1 = Date.now();12 const total = await mockAPI(10);13 const promises = [];14 for (let i = 1; i <= total; i += 1) {15 promises.push(mockAPI(i));16 }17 const result = await Promise.all(promises)18 const t2 = Date.now();19 console.log(`total cost: ${t2 - t1}ms.`);20 return result;21}2223get10Topics2().then(data => console.log(data));
时间正如我们说的, 缩短成了2s!
1➜ test-js git:(master) ✗ node p2.js2total cost: 2005ms.3[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
限流
上面第二种方法已经大大提高率效率, 而且请求数越多, 提高的效率越多。 前面的分析可以得出, 如果是获取前100个topics, 第一种串行的方法需要101s, 而第二种还是2s!!!
仔细想想你会发现哪里不对, 那就是第二种方法“太并发”了!10个请求可能还好, 如果同时并发100个请求, 那对服务器就会造成一定的影响, 如果是1000个,10000个, 那问题就更大了, 甚至到了一定程度, 会超过操作系统允许打开的连接数, 对客户端本身也会有很大的影响。
所以我们需要限制最大并发数,比如我们限制最大并发数为3, 则10个请求大概是3个3个一组, 总共会有4组(最后一组只有1个), 总共时间是5s, 这也比11s提高了50%多。一种实现方式如下:
1async function get10Topics3() { 2 const t1 = Date.now(); 3 const total = await mockAPI(10); 4 const MAX_CURRENCY = 3; 5 const result = []; 6 for (let i = 1; i <= total; i += MAX_CURRENCY) { 7 const promises = []; 8 for (let j = i; j < i + MAX_CURRENCY && j <= total; j += 1) { 9 promises.push(mockAPI(j));10 }11 const r = await Promise.all(promises);12 result.push(...r);13 }14 const t2 = Date.now();15 console.log(`total cost: ${t2 - t1}ms.`);16 return result;17}1819get10Topics3().then(data => console.log(data));
看一下结果:
1➜ test-js git:(master) ✗ node p3.js2total cost: 5012ms.3[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
还有什么问题么?
One More Step
上面的实现方法, 既利用了并发, 又对并发做了一定限制保证不至于把系统资源耗尽,似乎是完美的。 但是如果每个请求所需要的时间不一样呢?
get10Topics3
的实现方式是每三个一组, 等着三个都完成了, 再进行下一组请求。 那么如果三个任务中, 有一个花费的时间比较多, 另外两个任务完成了之后, 本来可以继续开始新的任务的, 现在必须等着第三个任务完成了才能开始新的任务。甚至如果三个任务需要的时间都不一样, 那么第一个需要等第二个和第三个, 第二个需要等第三个,
整个系统就被最慢的那个任务拖累了。 比如第一个任务需要1s, 第二个任务需要2s, 第三个任务需要3s, 则
get10Topics3
每组任务需要3s, 三组任务需要
3 * 3 = 9s
, 最后一组那个任务只需要1s, 总共需要
1 + 3 + 3 + 3 + 1 = 11s
, 当然这也比完全串行需要的时间
1 + 1 + 2 + 3 + 1 + 2 + 3 + 1 + 2 + 3 + 1 = 20s
要快不少。
1// 模拟一次api网络请求花费特定时间 2function mockAPI(result, time = 1000) { 3 console.log(result, time); 4 return new Promise((resolve, reject) => { 5 setTimeout(() => { 6 resolve(result); 7 }, time); 8 }); 9}1011async function get10Topics4() {12 const t1 = Date.now();13 const total = await mockAPI(10);14 const MAX_CURRENCY = 3;15 const result = [];16 for (let i = 1; i <= total; i += MAX_CURRENCY) {17 const promises = [];18 for (let j = i; j < i + MAX_CURRENCY && j <= total; j += 1) {19 const costtime = j % 3 === 0 ? 3 : j % 3; // 第一个任务1s, 第二个2是, 第三个3s...20 promises.push(mockAPI(j, costtime * 1000));21 }22 const t3 = Date.now();23 const r = await Promise.all(promises);24 const t4 = Date.now();25 console.log(`promise ${i} cost: ${t4 - t3}ms`);26 result.push(...r);27 }28 const t2 = Date.now();29 console.log(`total cost: ${t2 - t1}ms.`);30 return result;31}3233get10Topics4().then(data => console.log(data));
运行结果: