1. 一個(gè)有趣的內(nèi)存泄漏案例

        共 11129字,需瀏覽 23分鐘

         ·

        2021-04-13 10:05


        點(diǎn)擊上方藍(lán)字“TianTianUp”關(guān)注我
        您的關(guān)注意義重大

        0. 背景

        之前在這篇文章里說過做了個(gè) SSR 《論如何像素級(jí)直出具有14W行代碼量的前端頁面》,本以為今天順順利利,高高興興。

        沒想到項(xiàng)目放到線上后,隨著請(qǐng)求量的增多,卻感覺到首屏速度越來越慢,并且是在持續(xù)性地變慢。而且在發(fā)布完后(也就是容器重建了),耗時(shí)又陡然降下來了。

        因此很合理地懷疑是內(nèi)存泄漏了。故而在 STKE 的監(jiān)控面板瞧一瞧,內(nèi)存確實(shí)是一波一波似浪花。

        1. 復(fù)現(xiàn)問題

        知道是內(nèi)存泄漏,我們就需要找到泄漏的點(diǎn)。因?yàn)椴荒茌p易操作線上環(huán)境,線上代碼也是壓縮的,因此我們需要先搭建本地環(huán)境看能否方便調(diào)試問題。這里我們我們可以在本地起 Server 后,寫腳本發(fā)起請(qǐng)求,來模擬線上環(huán)境。(但是看過上篇文章的小伙伴都知道,我們還有個(gè)骨架屏的模式,可以跳過發(fā)起 CGI 請(qǐng)求的步驟,大大降低單次請(qǐng)求耗時(shí),讓這個(gè)結(jié)果幾秒鐘就出來了)

        我們可以使用 heapdump 包來將堆棧信息寫入本地文件。 heapdump 的基本使用姿勢是這樣的:

        const heapdump = require('heapdump');
        heapdump.writeSnapshot('./test.heapsnapshot');

        然后就可以將堆棧文件導(dǎo)入到 Chrome 開發(fā)者工具的 Memory 欄來分析。這里我選擇了分別是運(yùn)行了 1次、50次、100次 以及等待幾秒鐘垃圾回收后再寫個(gè) 101 次的堆棧信息。可以看到堆棧文件越變?cè)酱?,?35M 增大到 249M。

        選擇兩個(gè)堆棧文件做比較來分析,這里有個(gè)技巧就是按內(nèi)存大小排序,然后看到同一個(gè)大小的對(duì)象個(gè)數(shù)非常多,那么很有可能就是它被引用了很多次,泄漏的點(diǎn)就可能在那里。然后就發(fā)現(xiàn)了問題可能出在 console 對(duì)象上。

        2. 分析問題

        正常地使用 console 對(duì)象不會(huì)造成內(nèi)存泄漏,因此就懷疑是否是對(duì) console 做了什么操作。搜索了一番代碼,排除正常調(diào)用外,發(fā)現(xiàn)有個(gè)賦值的操作,就類似于下面這段代碼:

        const nativeError = console.error;
        console.error = (...argv) => { // 省略一些操作 nativeError(...argv);};

        這段代碼在前端開發(fā)中其實(shí)是比較常見的,比如需要在 log 中自動(dòng)添加時(shí)間:

        const nativeError = console.error;
        console.error = (...argv) => { nativeError(`[${(new Date()).toTimeString()}]`, ...argv);};
        console.error('Test');// [20:58:17 GMT+0800 (中國標(biāo)準(zhǔn)時(shí)間)] Test

        還有一個(gè)更常見的場景是,我們要在生產(chǎn)環(huán)境下屏蔽大部分的 log 輸出,但是又要保留一個(gè) log 函數(shù)引用,用來有時(shí)候在瀏覽器終端上輸出一些關(guān)鍵信息,這時(shí)候會(huì)這么寫:

        // 引用,用來有時(shí)候在需要的時(shí)候上報(bào)const logger = console.log;
        // 必需用函數(shù)賦值,原有的一大堆使用 console.log('...') 的地方才不會(huì)報(bào)錯(cuò)console.log = () => {};
        logger('瀏覽器終端 AlloyTeam 招聘信息');

        但是在我們的環(huán)境下,原來客戶端的代碼是被編譯后放在 vm 里反復(fù)運(yùn)行的,這會(huì)帶來什么問題呢?

        這里附個(gè)代碼,感興趣的小伙伴可以跑一下:

        const vm = require('vm');const heapdump = require('heapdump');
        const total = 5000;
        const writeSnapshot = (count) => { heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`);};
        const code = ` const nativeError = console.error;
        console.error = (...argv) => { nativeError(argv); }`;
        const script = new vm.Script(code);
        for (let i = 1; i <= total; i++) { script.runInNewContext({ console, });
        console.log(`${i}/${total}`);
        switch (i) { case 1: case Math.floor(total * 0.5): case total: writeSnapshot(i); }}
        setTimeout(() => { writeSnapshot(total + 1);}, 3000);

        很小一段代碼,運(yùn)行 5000次后內(nèi)存占用到了 1G 多,并且還沒有回收的跡象。

        我們先來考慮在 vm 的環(huán)境下,差異點(diǎn)在于:

        1. vm 里是沒有 console 對(duì)象的,vm 里的 console 對(duì)象是宿主環(huán)境傳遞進(jìn)去的,在 vm 里針對(duì) console 的修改,也會(huì)反映在宿主環(huán)境的 console 對(duì)象上;

        2. 在同一段代碼多次執(zhí)行的情況下,也就意味著這幾次執(zhí)行環(huán)境是共享 console 對(duì)象的,而在瀏覽器環(huán)境下,刷新頁面后,代碼被多次執(zhí)行,環(huán)境都是獨(dú)立的;

        那么我們的問題就會(huì)出現(xiàn)如上圖所示:

        1. 在宿主環(huán)境上, console.error 原來指向的是原生的 error 方法;

        2. 在 vm 第一次執(zhí)行的時(shí)候(假設(shè)這個(gè)過程要賦值的函數(shù)是 Func1),先是引用了 console.error ,也就是引用了原生的 error 方法,同時(shí)通過賦值操作將宿主環(huán)境上的 console.error 指向了 Func1;

        3. 在 vm 第二次執(zhí)行的時(shí)候,也是先引用了 console.error 方法,但是引用到的已經(jīng)是第 2 步設(shè)置的 Func1,也就是 Func2 引用了 Func1。同時(shí)它又將宿主環(huán)境上的 console.error 設(shè)置成了 Func2;

        4. 同理,F(xiàn)unc3 引用了 Func2,并且 console.error 指向了 Func3;

        所以聰明的小伙伴們發(fā)現(xiàn)問題沒有,這變成了一個(gè)鏈?zhǔn)揭谩_@條鏈上的對(duì)象一個(gè)都別想被回收,都被牢牢綁死了。

        如果我們要解決這個(gè)問題,理想的引用模型應(yīng)該是什么樣的呢?

        理想的一個(gè)引用模型應(yīng)該是無論 vm 代碼被執(zhí)行了多少次,在我們?nèi)≈岛唾x值操作應(yīng)該做到:

        1. 取值操作始終取的是原生的 error 方法,因?yàn)槿绻〉搅松洗芜\(yùn)行賦值的方法,那么就會(huì)存在引用關(guān)系;

        2. 賦值操作將不能操作到宿主環(huán)境的 console 對(duì)象,因?yàn)檫@樣將會(huì)影響到其他批次 vm 里的全局 console 對(duì)象;

        3. 賦值操作后的取值操作將需要取到賦值后的方法,這樣才能執(zhí)行到自定義的邏輯;

        這其實(shí)就要求我們不僅對(duì) vm 的上下文做隔離,對(duì) vm 創(chuàng)建的上下文所傳遞的屬于宿主環(huán)境的引用對(duì)象也要做隔離。

        3. 解決問題

        有什么簡單的解決辦法嗎?假設(shè)我們很清楚的認(rèn)識(shí)到代碼執(zhí)行環(huán)境(多次執(zhí)行且共享宿主對(duì)象),那么只需要做個(gè)標(biāo)志位防止多次執(zhí)行就可以了:

        const nativeError = console.error;
        if (!nativeError.hasBeenRewrite) { console.error = (...argv) => { nativeError(argv); }; console.error.hasBeenRewrite = true;}

        但是在原來運(yùn)行于客戶端的代碼里會(huì)這么寫的,感覺要么是已經(jīng)遭遇過了這個(gè)問題,要么只能說優(yōu)秀,一開始就有了這個(gè)意識(shí)!

        那么當(dāng)我們要做一個(gè)基礎(chǔ)運(yùn)行庫的時(shí)候,可以做到不需要業(yè)務(wù)關(guān)心這么細(xì)的問題嗎?也就是我們可能對(duì)對(duì)象隔離出上下文環(huán)境里的上下文環(huán)境嗎?有這么幾個(gè)條件是支持我們這么做的:

        1. 我們傳遞到 vm 里屬于宿主環(huán)境的引用對(duì)象其實(shí)很有限,因此可以對(duì)這么幾個(gè)有限的對(duì)象做隔離;

        2. 我們需要隔離的對(duì)象是跟隨著 vm 創(chuàng)建的上下文的;

        那么回到我們上文提到的理想模型,這里先附上代碼,再來對(duì)整個(gè)方案做解讀:

        const vm = require('vm');const heapdump = require('heapdump');
        const total = 5000;
        const writeSnapshot = (count) => { heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`);};
        const code = ` const nativeError = console.error;
        console.error = (...argv) => { nativeError(...argv); }`;
        const script = new vm.Script(code);
        const vmProxy = (context, obj, name) => { const proxyStore = {};
        const proxyObj = new Proxy(obj, { get: function (target, propKey) { if (proxyStore[name] && proxyStore[name][propKey]) { return proxyStore[name][propKey]; }
        return target[propKey]; }, set: function (target, propKey, value) { if (!proxyStore[name]) { proxyStore[name] = {}; }
        const defineObj = proxyStore[name]; if ((typeof value === 'function' || typeof value === 'object') && value !== null) { defineObj[propKey] = value; } }, });
        context[name] = proxyObj; context.proxyStore = proxyStore; return context;};
        for (let i = 1; i <= total; i++) { const context = vmProxy({}, console, 'console');
        script.runInNewContext(context);
        console.log(`${i}/${total}`);
        switch (i) { case 1: case Math.floor(total * 0.5): case total: writeSnapshot(i); }}
        setTimeout(() => { writeSnapshot(total + 1);}, 3000);

        這里有幾個(gè)關(guān)鍵的點(diǎn):

        1. 用 Proxy 方法,對(duì) console 的屬性 get 操作做攔截;

        2. 我們將在 vm 上下文對(duì)象上設(shè)置 proxyStore 對(duì)象用來存儲(chǔ) set 操作設(shè)置的值,這個(gè) proxyStore 將跟隨著上下文的回收而回收;

        3. 對(duì) console 的 set 操作將不會(huì)設(shè)置到 console 上而影響宿主環(huán)境的引用對(duì)象,但是又需要做存儲(chǔ);

        分步驟來看:

        1. 對(duì) console.error 的取值操作,我們判斷 ProxyStore 里是否被當(dāng)前環(huán)境設(shè)置過了,這時(shí)候沒有,那么我們給取值操作返回原生的 error 方法;

        1. 對(duì) console.error 賦值 Func1 的操作,我們判斷 ProxyStore 里沒有存儲(chǔ)對(duì)這個(gè)屬性的賦值,那么將 Func1 存儲(chǔ)到 ProxyStore,這里注意我們不能將 Func1 設(shè)置到 console.error 上;

        1. 在后續(xù)的調(diào)用 console.error 操作,又會(huì)被我們攔截 get 方法,我們判斷到 ProxyStore 里有被賦值過 Func1,這時(shí)候返回 Func1,調(diào)用 console.error 就變成了調(diào)用 Func1 ;

        通過以上的操作,我們維持了 console.error 始終指向原生 error 方法,每次的引用也都是引用的原生的 error 方法,而不是上一次設(shè)置的方法。

        然后我們就解決了這個(gè)內(nèi)存泄漏的問題:

        4. 規(guī)避問題

        用這么個(gè)聰明的方法解決了這個(gè)問題,貌似都有點(diǎn)欣賞自己了呢。

        但是我們?cè)賮砜紤] Proxy 會(huì)帶來什么問題,會(huì)有性能問題嗎?

        實(shí)踐出真知,我們對(duì)比上面兩種解決方法的性能差異:

        const vm = require('vm');
        const total = 10000;
        const vmProxy = (context, obj, name) => { const proxyStore = {};
        const proxyObj = new Proxy(obj, { get: function (target, propKey) { if (proxyStore[name] && proxyStore[name][propKey]) { return proxyStore[name][propKey]; }
        return target[propKey]; }, set: function (target, propKey, value) { if (!proxyStore[name]) { proxyStore[name] = {}; }
        const defineObj = proxyStore[name]; if ((typeof value === 'function' || typeof value === 'object') && value !== null) { defineObj[propKey] = value; } }, });
        context[name] = proxyObj; context.proxyStore = proxyStore; return context;};
        (() => { const code = ` const nativeError = console.error;
        console.error = (...argv) => { nativeError(...argv); } `;
        const script = new vm.Script(code);
        console.time('proxy'); for (let i = 1; i <= total; i++) { const context = vmProxy({}, console, 'console');
        script.runInNewContext(context); } console.timeEnd('proxy');})();
        (() => { let code = ` const nativeError = console.error;
        if (!nativeError.hasBeenRewrite) { console.error = (...argv) => { nativeError(argv); }; console.error.hasBeenRewrite = true; } `;
        let script = new vm.Script(code); console.time('flag'); for (let i = 1; i <= total; i++) { script.runInNewContext({ console, }); } console.timeEnd('flag');})();

        看起來幾乎沒什么性能差異

        但是 Proxy 有個(gè) this 指向的問題,因?yàn)?nbsp;Proxy 不是個(gè)透明代理,被 Proxy 代理的對(duì)象內(nèi)部的 this 指向會(huì)指向 proxy 實(shí)例,因此如果是這么個(gè)簡單例子還好,但是放到線上代理比較復(fù)雜的對(duì)象,心里還是毛毛的。(還需要考慮對(duì)象里的對(duì)象)

        有沒有可能在開發(fā)階段就能發(fā)現(xiàn)類似的內(nèi)存泄漏問題,而不是等到發(fā)布線上才發(fā)現(xiàn)呢?

        當(dāng)然是想到了辦法我才會(huì)說了,之前想這個(gè)問題的時(shí)候想了一下午,想得太復(fù)雜了,所以試了好多種方法也沒有想出來。我們先來澄清一點(diǎn),這里是因?yàn)橐x值的函數(shù)里又調(diào)用了存儲(chǔ)的 nativeError 嗎?其實(shí)是無關(guān)的,即使你將 nativeError(...argv) 注釋掉,仍然是會(huì)存在內(nèi)存泄漏的問題。

        const nativeError = console.error;
        console.error = (...argv) => { nativeError(...argv);}

        這里的原因在于只要同一個(gè) vm 虛擬機(jī)里對(duì)宿主環(huán)境的引用對(duì)象的同一個(gè) key 同時(shí)做 get 和 set 操作,那么就會(huì)存在內(nèi)存泄漏。我們來考慮下面這三種情況是否會(huì)存在內(nèi)存泄漏:

        相同的 key:

        const nativeError = console.undefined;
        console.undefined = (...argv) => { nativeError(argv);}

        不同的 key:

        const nativeError = console.undefined;
        console.notExist = (...argv) => { nativeError(argv);}

        設(shè)置的不是引用對(duì)象:

        const nativeError = console.error;
        console.error = 'AlloyTeam';

        答案是第一個(gè)會(huì)存在內(nèi)存泄漏,第二和第三不會(huì)。好奇的小伙伴可以用上面的例子代碼跑一下。

        我們將這個(gè)問題簡化了,再來看檢測的方案,照例先上代碼:

        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: 需要處理數(shù)組和迭代子對(duì)象 if (!memoryCheckStore[obj][propKey]) { memoryCheckStore[obj][propKey] = 1; }
        return propValue; }, set: function (target, propKey, value) { if (isReferenced(value) && memoryCheckStore[obj][propKey]) { log(new Error('[警告] 可能存在內(nèi)存泄漏')); }
        target[propKey] = value; }, });
        context[name] = proxyObj; return context;};
        const code1 = ` const nativeError = console.undefined;
        // 泄漏 console.undefined = (...argv) => {}`;
        const code2 = ` const nativeError = console.undefined;
        // 不會(huì)泄漏 console.notExist = (...argv) => {}`;
        const code3 = ` const nativeError = console.undefined;
        // 不會(huì)泄漏 console.error = 'AlloyTeam';`;
        const code4 = ` const nativeError = console.error;
        // 泄漏 console.error = (...argv) => {}`;
        if (isMainThread) { for (let i = 1; i <= 4; i++) { new Worker(__filename, { workerData: { code: eval(`code${i}`), flag: i, }, }); }} else { const { code, flag } = workerData;
        const script = new vm.Script(code, { filename: `code${flag}`, });
        const context = vmProxy({}, console, 'console'); script.runInNewContext(context);}

        僅一次運(yùn)行,就知道 code1、code4 可能存在內(nèi)存泄漏:

        方案圖解1,get 階段:

        1. 一開始 console.error 指向原生的 error 方法;

        2. 我們?cè)谌衷O(shè)置個(gè) GlobalGetStore 對(duì)象,用來記錄被引用的對(duì)象和被引用的屬性名;

        3. 第一次運(yùn)行,攔截的 get 方法里判斷 store 里沒有這個(gè)對(duì)象,就記錄對(duì)象到 store,同時(shí)也記錄被引用的 key 值;

        方案圖解2,set 階段:

        1. 攔截的 set 方法里判斷了 store 里已經(jīng)有存儲(chǔ)了被引用的對(duì)象,同時(shí)當(dāng)次操作的 key 值也已經(jīng)被引用過了,因此判定在 vm 這樣多次執(zhí)行的環(huán)境里,可能存在內(nèi)存泄漏,打印出告警信息;

        這樣我們就可以在開發(fā)階段部署這樣內(nèi)存檢測代碼(demo 代碼仍然需要處理數(shù)組和對(duì)象屬性是引用類型的情況),在生產(chǎn)環(huán)境上移除或失效。

        當(dāng)然了,一個(gè)較優(yōu)秀的項(xiàng)目,上線前后仍然有兩件相關(guān)的事情可以做:

        1. 自動(dòng)化測試,通過模擬發(fā)起多個(gè)用戶請(qǐng)求,檢測內(nèi)存變化,上線前檢測到可能的內(nèi)存泄漏;

        2. 設(shè)置告警策略,在內(nèi)存超限時(shí)告警,查看內(nèi)存變化,確認(rèn)是否泄漏;

        5. 后記

        遇到這樣一個(gè)問題,其實(shí)還挺有趣的,雖然是一個(gè)小點(diǎn),但是梳理了一個(gè)比較完整的思考過程,希望能對(duì)小伙伴們解決相關(guān)問題帶來參考和想法。

        我們是在做騰訊文檔的 AlloyTeam,歡迎有技術(shù)想法的小伙伴來撩~

        END



        如果覺得這篇文章還不錯(cuò)
        點(diǎn)擊下面卡片關(guān)注我
        來個(gè)【分享、點(diǎn)贊、在看】三連支持一下吧

           “分享、點(diǎn)贊、在看” 支持一波  

        瀏覽 87
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 亚洲一级片 | 美女骚穴| 国产成人久久精品 | 女人做爰呻吟娇喘 | 欧美操逼虐待视频网 |