专栏名称: 狗厂
目录
相关文章推荐
51好读  ›  专栏  ›  狗厂

用Promise实现并发 vs Go goroutine

狗厂  · 掘金  ·  · 2018-03-26 06:29

正文

我们知道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));

运行结果:







请到「今天看啥」查看全文