60 亿次 for 循环,原来这么多东西

原创
2020/08/30 23:49
阅读数 7.4K

起因

  • 有人在思否论坛上向我付费提问
  • 当时觉得,这个人问的有问题吧。仔细一看,还是有点东西的

问题重现

  • 编写一段 Node.js代码
var http = require('http');
  
http.createServer(function (request, response) {
    var num = 0
    for (var i = 1; i < 5900000000; i++) {
        num += i
    }
    response.end('Hello' + num);
}).listen(8888);
  • 使用 nodemon启动服务,用 time curl调用这个接口
  • 首次需要7.xxs耗时

  • 多次调用后,问题重现

  • 为什么这个耗时突然变高,由于我是调用的是本机服务,我看 CPU使用当时很高,差不多打到 100%了.但是我后面发现不是这个问题.

问题排查

  • 排除掉 CPU问题,看内存消耗占用。
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 5900000000; i++) {
      num += i;
    }
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('Hello' + num);
![](https://imgkr2.cn-bj.ufileos.com/13455121-9d87-42c3-a32e-ea999a2cd09b.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=E3cF2kymC92LifrIC5IOfIZQvnk%253D&Expires=1598883364)

![](https://imgkr2.cn-bj.ufileos.com/1e7b95df-2a48-41c3-827c-3c24b39f4b5b.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=%252FANTTuhgbpIsXslXMc1qCkj2TMU%253D&Expires=1598883362)

  })
  .listen(8888);
  • 测试结果:
  • 内存占用和 CPU都正常
  • 跟字符串拼接有关,此刻关闭字符串拼接(此时为了快速测试,我把循环次数降到 5.9亿次
  • 发现耗时稳定下来了

定位问题在字符串拼接,先看看字符串拼接的几种方式

  • 一、使用连接符 “+” 把要连接的字符串连起来
var a = 'java'
var b = a + 'script'

* 只连接100个以下的字符串建议用这种方法最方便

  • 二、使用数组的 join 方法连接字符串
var arr = ['hello','java','script']
var str = arr.join("")
  • 比第一种消耗更少的资源,速度也更快

  • 三、使用模板字符串,以反引号( ` )标识

var a = 'java'
var b = `hello ${a}script`
  • 四、使用 JavaScript concat() 方法连接字符串
var a = 'java'
var b = 'script'

var str = a.concat(b)

五、使用对象属性来连接字符串

function StringConnect(){
    this.arr = new Array()
}

StringConnect.prototype.append = function(str) {
    this.arr.push(str)
}

StringConnect.prototype.toString = function() {
    return this.arr.join("")
}

var mystr = new StringConnect()

mystr.append("abc")
mystr.append("def")
mystr.append("g")

var str = mystr.toString()

更换字符串的拼接方式

  • 我把字符串拼接换成了数组的 join方式(此时循环 5.9亿次)
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 590000000; i++) {
      num += i;
    }
    const arr = ['Hello'];
    arr.push(num);
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(arr.join(''));
  })
  .listen(8888);
  • 测试结果,发现接口调用的耗时稳定了( 注意此时是5.9亿次循环)
  • 《javascript高级程序设计》中,有一段关于字符串特点的描述,原文大概如下: ECMAScript中的字符串是不可变的,也就是说,字符串一旦创建,他们的值就不能改变。要改变某个变量的保存的的字符串,首先要销毁原来的字符串,然后再用另外一个包含新值的字符串填充该变量

就完了?

  • +直接拼接字符串自然会对性能产生一些影响,因为字符串是不可变的,在操作的时候会产生临时字符串副本, +操作符需要消耗时间,重新赋值分配内存需要消耗时间。
  • 但是,我更换了代码后,发现,即使没有字符串拼接,也会耗时不稳定
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 5900000000; i++) {
    //   num++;
    }
    const arr = ['Hello'];
    // arr[1] = num;
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('hello');
  })
  .listen(8888);
  • 测试结果:
  • 现在我怀疑,不仅仅是字符串拼接的效率问题,更重要的是 for循环的耗时不一致
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 5900000000; i++) {
    //   num++;
    }
    console.timeEnd('测试');
    const arr = ['Hello'];
    // arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('hello');
  })
  .listen(8888);

  • 测试运行结果:
  • for循环内部的 i++其实就是变量不断的重新赋值覆盖
  • 经过我的测试发现, 40亿次50亿次的区别,差距很大, 40亿次的for循环,都是稳定的,但是 50亿次就不稳定了.
  • Node.jsEventLoop:
  • 我们目前被阻塞的状态:

  • 我电脑的CPU使用情况

优化方案

  • 遇到了 60亿次的循环,像有使用多进程异步计算的,但是本质上没有解决这部分循环代码的调用耗时。
  • 改变策略,拆解单次次数过大的 for循环:
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 600000; i++) {
      num++;
      for (let j = 0; j < 10000; j++) {
        num++;
      }
    }
    console.timeEnd('测试');
    const arr = ['Hello'];
    console.log(num, 'num');
    arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(arr.join(''));
  })
  .listen(8888);

  • 结果,耗时基本稳定, 60亿次循环总共:

推翻字符串的拼接耗时说法

  • 修改代码回最原始的 +方式拼接字符串
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 600000; i++) {
      num++;
      for (let j = 0; j < 10000; j++) {
        num++;
      }
    }
    console.timeEnd('测试');
    // const arr = ['Hello'];
    console.log(num, 'num');
    // arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(`Hello` + num);
  })
  .listen(8888);
  • 测试结果稳定,符合预期:

总结:

  • 对于单次循环超过一定阀值次数的,用拆解方式, Node.js的运行耗时是稳定,但是如果是循环次数过多,那么就会出现刚才那种情况,阻塞严重,耗时不一样。
  • 为什么?

深度分析问题

  • 遍历60亿次,这个数字是有一些大了,如果是40亿次,是稳定的
  • 这里应该还是跟 CPU有一些 关系,因为 top 查看一直是在 升高
  • 此处虽然不是真正意义上的内存泄漏,但是我们如果在一个循环中不仅要不断更新 i的值到 60亿,还要不断更新 num的值 60亿,内存使用会不断上升,最终出现两份 60亿的数据,然后再回收。( 因为GC自动垃圾回收,一样会阻塞主线程,多次接口调用后, CPU占用也会升高)
  • 使用 for循环拆解后:
 for (let i = 1; i < 60000; i++) {
      num++;
      for (let j = 0; j < 100000; j++) {
        num++;
      }
    }
  • 只要 num60亿即可,解决了这个问题。

哪些场景会遇到这个类似的超大计算量问题:

  • 图片处理
  • 加解密

如果是异步的业务场景,也可以用多进程参与解决超大计算量问题,今天这里就不重复介绍了

最后

  • 如果感觉写得不错,可以点个 在看/ ,转发一下,让更多人看到
  • 我是 Peter谭老师,欢迎你关注公众号: 前端巅峰,后台回复: 加群即可加入大前端交流群


本文分享自微信公众号 - 前端巅峰(Java-Script-)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中

作者的其它热门文章

打赏
6
2 收藏
分享
打赏
4 评论
2 收藏
6
分享
返回顶部
顶部
返回顶部
顶部