ToB企服应用市场:ToB评测及商务社交产业平台

标题: 一文带你走进【内存泄漏】 [打印本页]

作者: 羊蹓狼    时间: 2022-6-25 01:43
标题: 一文带你走进【内存泄漏】
文章目录



0. 背景


没想到项目放到线上后,随着请求量的增多,却感觉到首屏速度越来越慢,并且是在持续性地变慢。而且在发布完后(也就是容器重建了),耗时又陡然降下来了。

因此很合理地怀疑是内存泄漏了。故而在 STKE 的监控面板瞧一瞧,内存确实是一波一波似浪花。

1. 复现问题

知道是内存泄漏,我们就需要找到泄漏的点。因为不能轻易操作线上环境,线上代码也是压缩的,因此我们需要先搭建本地环境看能否方便调试问题。这里我们我们可以在本地起 Server 后,写脚本发起请求,来模拟线上环境。(但是看过上篇文章的小伙伴都知道,我们还有个骨架屏的模式,可以跳过发起 CGI 请求的步骤,大大降低单次请求耗时,让这个结果几秒钟就出来了)
我们可以使用 heapdump 包来将堆栈信息写入本地文件。heapdump 的基本使用姿势是这样的:
  1. const heapdump = require('heapdump');
  2. heapdump.writeSnapshot('./test.heapsnapshot');
复制代码
然后就可以将堆栈文件导入到 Chrome 开发者工具的 Memory 栏来分析。这里我选择了分别是运行了 1 次、50 次、100 次 以及等待几秒钟垃圾回收后再写个 101 次的堆栈信息。可以看到堆栈文件越变越大,从 35M 增大到 249M。
选择两个堆栈文件做比较来分析,这里有个技巧就是按内存大小排序,然后看到同一个大小的对象个数非常多,那么很有可能就是它被引用了很多次,泄漏的点就可能在那里。然后就发现了问题可能出在 console 对象上。

2. 分析问题

正常地使用 console 对象不会造成内存泄漏,因此就怀疑是否是对 console 做了什么操作。搜索了一番代码,排除正常调用外,发现有个赋值的操作,就类似于下面这段代码:
  1. const nativeError = console.error;
  2. console.error = (...argv) => {
  3.     // 省略一些操作
  4.     nativeError(...argv);
  5. };
复制代码
这段代码在前端开发中其实是比较常见的,比如需要在 log 中自动添加时间:
  1. const nativeError = console.error;
  2. console.error = (...argv) => {
  3.     nativeError(`[${(new Date()).toTimeString()}]`, ...argv);
  4. };
  5. console.error('Test');
  6. // [20:58:17 GMT+0800 (中国标准时间)] Test
复制代码
还有一个更常见的场景是,我们要在生产环境下屏蔽大部分的 log 输出,但是又要保留一个 log 函数引用,用来有时候在浏览器终端上输出一些关键信息,这时候会这么写:
  1. // 引用,用来有时候在需要的时候上报
  2. const logger = console.log;
  3. // 必需用函数赋值,原有的一大堆使用 console.log('...') 的地方才不会报错
  4. console.log = () => {};
  5. logger('浏览器终端 AlloyTeam 招聘信息');
复制代码
但是在我们的环境下,原来客户端的代码是被编译后放在 vm 里反复运行的,这会带来什么问题呢?
这里附个代码,感兴趣的小伙伴可以跑一下:
  1. const vm = require('vm');
  2. const heapdump = require('heapdump');
  3. const total = 5000;
  4. const writeSnapshot = (count) => {
  5.     heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`);
  6. };
  7. const code = `
  8.     const nativeError = console.error;
  9.     console.error = (...argv) => {
  10.         nativeError(argv);
  11.     }
  12. `;
  13. const script = new vm.Script(code);
  14. for (let i = 1; i <= total; i++) {
  15.     script.runInNewContext({
  16.         console,
  17.     });
  18.     console.log(`${i}/${total}`);
  19.     switch (i) {
  20.         case 1:
  21.         case Math.floor(total * 0.5):
  22.         case total:
  23.             writeSnapshot(i);
  24.     }
  25. }
  26. setTimeout(() => {
  27.     writeSnapshot(total + 1);
  28. }, 3000);
复制代码
很小一段代码,运行 5000 次后内存占用到了 1G 多,并且还没有回收的迹象。

我们先来考虑在 vm 的环境下,差异点在于:

那么我们的问题就会出现如上图所示:
所以聪明的小伙伴们发现问题没有,这变成了一个链式引用。这条链上的对象一个都别想被回收,都被牢牢绑死了。

如果我们要解决这个问题,理想的引用模型应该是什么样的呢?

理想的一个引用模型应该是无论 vm 代码被执行了多少次,在我们取值和赋值操作应该做到:
这其实就要求我们不仅对 vm 的上下文做隔离,对 vm 创建的上下文所传递的属于宿主环境的引用对象也要做隔离。

3. 解决问题

有什么简单的解决办法吗?假设我们很清楚的认识到代码执行环境(多次执行且共享宿主对象),那么只需要做个标志位防止多次执行就可以了:
  1. const nativeError = console.error;
  2. if (!nativeError.hasBeenRewrite) {
  3.     console.error = (...argv) => {
  4.         nativeError(argv);
  5.     };
  6.     console.error.hasBeenRewrite = true;
  7. }
复制代码
但是在原来运行于客户端的代码里会这么写的,感觉要么是已经遭遇过了这个问题,要么只能说优秀,一开始就有了这个意识!

那么当我们要做一个基础运行库的时候,可以做到不需要业务关心这么细的问题吗?也就是我们可能对对象隔离出上下文环境里的上下文环境吗?有这么几个条件是支持我们这么做的:
那么回到我们上文提到的理想模型,这里先附上代码,再来对整个方案做解读:
  1. const vm = require('vm');
  2. const heapdump = require('heapdump');
  3. const total = 5000;
  4. const writeSnapshot = (count) => {
  5.     heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`);
  6. };
  7. const code = `
  8.     const nativeError = console.error;
  9.     console.error = (...argv) => {
  10.         nativeError(...argv);
  11.     }
  12. `;
  13. const script = new vm.Script(code);
  14. const vmProxy = (context, obj, name) => {
  15.     const proxyStore = {};
  16.     const proxyObj = new Proxy(obj, {
  17.         get: function (target, propKey) {
  18.             if (proxyStore[name] && proxyStore[name][propKey]) {
  19.                 return proxyStore[name][propKey];
  20.             }
  21.             return target[propKey];
  22.         },
  23.         set: function (target, propKey, value) {
  24.             if (!proxyStore[name]) {
  25.                 proxyStore[name] = {};
  26.             }
  27.             const defineObj = proxyStore[name];
  28.             if ((typeof value === 'function' || typeof value === 'object') && value !== null) {
  29.                 defineObj[propKey] = value;
  30.             }
  31.         },
  32.     });
  33.     context[name] = proxyObj;
  34.     context.proxyStore = proxyStore;
  35.     return context;
  36. };
  37. for (let i = 1; i <= total; i++) {
  38.     const context = vmProxy({}, console, 'console');
  39.     script.runInNewContext(context);
  40.     console.log(`${i}/${total}`);
  41.     switch (i) {
  42.         case 1:
  43.         case Math.floor(total * 0.5):
  44.         case total:
  45.             writeSnapshot(i);
  46.     }
  47. }
  48. setTimeout(() => {
  49.     writeSnapshot(total + 1);
  50. }, 3000);
复制代码
这里有几个关键的点:
分步骤来看:



通过以上的操作,我们维持了 console.error 始终指向原生 error 方法,每次的引用也都是引用的原生的 error 方法,而不是上一次设置的方法。
然后我们就解决了这个内存泄漏的问题:

4. 规避问题

用这么个聪明的方法解决了这个问题,貌似都有点欣赏自己了呢。

但是我们再来考虑 Proxy 会带来什么问题,会有性能问题吗?
实践出真知,我们对比上面两种解决方法的性能差异:
  1. const vm = require('vm');
  2. const total = 10000;
  3. const vmProxy = (context, obj, name) => {
  4.     const proxyStore = {};
  5.     const proxyObj = new Proxy(obj, {
  6.         get: function (target, propKey) {
  7.             if (proxyStore[name] && proxyStore[name][propKey]) {
  8.                 return proxyStore[name][propKey];
  9.             }
  10.             return target[propKey];
  11.         },
  12.         set: function (target, propKey, value) {
  13.             if (!proxyStore[name]) {
  14.                 proxyStore[name] = {};
  15.             }
  16.             const defineObj = proxyStore[name];
  17.             if ((typeof value === 'function' || typeof value === 'object') && value !== null) {
  18.                 defineObj[propKey] = value;
  19.             }
  20.         },
  21.     });
  22.     context[name] = proxyObj;
  23.     context.proxyStore = proxyStore;
  24.     return context;
  25. };
  26. (() => {
  27.     const code = `
  28.         const nativeError = console.error;
  29.         console.error = (...argv) => {
  30.             nativeError(...argv);
  31.         }
  32.     `;
  33.     const script = new vm.Script(code);
  34.     console.time('proxy');
  35.     for (let i = 1; i <= total; i++) {
  36.         const context = vmProxy({}, console, 'console');
  37.         script.runInNewContext(context);
  38.     }
  39.     console.timeEnd('proxy');
  40. })();
  41. (() => {
  42.     let code = `
  43.         const nativeError = console.error;
  44.         if (!nativeError.hasBeenRewrite) {
  45.             console.error = (...argv) => {
  46.                 nativeError(argv);
  47.             };
  48.             console.error.hasBeenRewrite = true;
  49.         }
  50.     `;
  51.     let script = new vm.Script(code);
  52.     console.time('flag');
  53.     for (let i = 1; i <= total; i++) {
  54.         script.runInNewContext({
  55.             console,
  56.         });
  57.     }
  58.     console.timeEnd('flag');
  59. })();
复制代码
这里的原因在于只要同一个 vm 虚拟机里对宿主环境的引用对象的同一个 key 同时做 get 和 set 操作,那么就会存在内存泄漏。我们来考虑下面这三种情况是否会存在内存泄漏:
相同的 key:
  1. const nativeError = console.error;
  2. console.error = (...argv) => {
  3.     nativeError(...argv);
  4. }
复制代码
不同的 key:
  1. const nativeError = console.undefined;
  2. console.undefined = (...argv) => {
  3.     nativeError(argv);
  4. }
复制代码
设置的不是引用对象:
  1. const nativeError = console.undefined;
  2. console.notExist = (...argv) => {
  3.     nativeError(argv);
  4. }
复制代码
答案是第一个会存在内存泄漏,第二和第三不会。好奇的小伙伴可以用上面的例子代码跑一下。
我们将这个问题简化了,再来看检测的方案,照例先上代码:
[code]const { workerData, Worker, isMainThread } = require('worker_threads');const vm = require('vm');const log = console.log; const memoryCheckStore = {}; const isReferenced = value => !!(value && typeof value === 'object' || typeof value === 'function'); const vmProxy = (context, obj, name) => {    const proxyObj = new Proxy(obj, {        get: function (target, propKey) {            const propValue = target[propKey];             if (!memoryCheckStore[obj]) {                memoryCheckStore[obj] = {};            }            // todo: 需要处理数组和迭代子对象            if (!memoryCheckStore[obj][propKey]) {                memoryCheckStore[obj][propKey] = 1;            }             return propValue;        },        set: function (target, propKey, value) {            if (isReferenced(value) && memoryCheckStore[obj][propKey]) {                log(new Error('[警告] 可能存在内存泄漏'));            }             target[propKey] = value;        },    });     context[name] = proxyObj;    return context;}; const code1 = `    const nativeError = console.undefined;     // 泄漏    console.undefined = (...argv) => {}`; const code2 = `    const nativeError = console.undefined;     // 不会泄漏    console.notExist = (...argv) => {}`; const code3 = `    const nativeError = console.undefined;     // 不会泄漏    console.error = 'AlloyTeam';`; const code4 = `    const nativeError = console.error;     // 泄漏    console.error = (...argv) => {}`; if (isMainThread) {    for (let i = 1; i




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4