<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Node.js 進程、線程調(diào)試和診斷的設計和實現(xiàn)

          共 19046字,需瀏覽 39分鐘

           ·

          2021-12-09 17:22

          大廠技術  高級前端  Node進階

          點擊上方 程序員成長指北,關注公眾號

          回復1,加入高級Node交流群

          前言:本文介紹 Node.js 中,關于進程、線程調(diào)試和診斷的相關內(nèi)容。進程和線程的方案類似,但是也有一些不一樣的地方,本文將會分開介紹,另外本文介紹的是對業(yè)務代碼無侵入的方案,通過命令行開啟 Inspector 端口或者在代碼里通過 Inspector 模塊打開端口在很多場景下并不適用,我們需要的是一種動態(tài)控制的能力。

          1. 背景

          隨著前端的快速發(fā)展,Node.js 在業(yè)務中的使用場景也越來越多,如何保證 Node.js 服務的穩(wěn)定也逐漸成為一個非常重要事情,傳統(tǒng)的服務器架構大多數(shù)基于多進程、多線程的,任務的執(zhí)行是隔離的,一個任務出現(xiàn)問題通常不會影響其他任務,比如在一個請求中執(zhí)行一個死循環(huán),服務器還能處理其他的請求。

          但是 Node.js 不一樣,從整體來看,Node.js 是單線程的,單個任務出現(xiàn)問題有可能會影響其他任務,比如在一個請求中執(zhí)行了死循環(huán),那么整個服務就沒法繼續(xù)工作了。所以在 Node.js 中,我們更加需要方便的調(diào)試和診斷工具,以便遇到問題時可以快速找到問題,解決問題,另外,工具不僅可以幫我們排查問題,還可以找出我們服務中的性能瓶頸,方便我們進行性能優(yōu)化。

          2. 目標

          我們基于 Node.js 本身提供的調(diào)試和診斷能力,提供一個調(diào)試和診斷平臺,使用方只需要引入 SDK,然后通過調(diào)試和診斷平臺就可以對服務的進程和線程進行調(diào)試和診斷。

          3. 實現(xiàn)

          目前支持了多進程和多線程的調(diào)試和診斷,下面按照進程和線程兩個方面介紹一下原理和具體實現(xiàn)。

          3.1. 單進程

          3.1.1 調(diào)試和診斷基礎

          在 Node.js 中,可以通過以下方式收集進程的數(shù)據(jù)。

          const inspector = require('inspector');
          const session = new inspector.Session();
          session.connect();
          // 發(fā)送命令
          session.post('Profiler.enable', () => {});

          使用方式很簡單,通過新建一個和 V8 Inspector 通信的 Session 就可以對進程進行數(shù)據(jù)的收集,比如抓取進程的堆快照和 Profile 數(shù)據(jù)。有了這個基礎后,我們就可以封裝這個能力。

          const http = require('http');
          const inspector = require('inspector');
          const fs = require('fs');

          // 打開一個和 V8 Inspector 的會話
          const session = new inspector.Session();
          session.connect();

          function getCpuprofile(req, res{
            // 向V8 Inspector 提交命令,開啟 CPU Profile 并收集數(shù)據(jù)
            session.post('Profiler.enable', () = >{
              session.post('Profiler.start', () = >{
                // 收集一段時間后提交停止收集命令
                setTimeout(() = >{
                  session.post('Profiler.stop', (err, { profile }) = >{
                    // 把數(shù)據(jù)寫入文件
                    if (!err && profile) {
                      fs.writeFileSync('./profile.cpuprofile'JSON.stringify(profile));
                    }
                    // 回復客戶端
                    res.end('ok');
                  });
                },
                3000);
              })
            });
          }

          http.createServer((req, res) = >{
            if (req.url == '/debug/getCpuprofile') {
              getCpuprofile(req, res);
            } else {
              res.end('ok');
            }
          }).listen(80);

          但是這種方式不能調(diào)試進程,調(diào)試進程需要使用另外的 API,可以通過以下方式啟動調(diào)試進程的服務。

          const inspector = require('inspector');
          inspector.open();
          console.log(inspector.url());

          這時候 Node.js 進程中就會啟動一個 WebSocket Server,我們可以通過 Chrome Dev Tools 連上這個 Server 進行調(diào)試,我們看看如何封裝。

          const inspector = require('inspector');
          const http = require('http');
          let isOpend = false;

          function getHTML({
            return `<html>
              <meta charset="utf-8" />
              <body>
                復制到新 Tab 打開該 URL 開始調(diào)試 devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${inspector.url().replace("ws://"'')}
              </body>
            </html>`
          ;
          }

          http.createServer((req, res) = >{
            if (req.url == '/debug/open') {
              // 還沒開啟則開啟
              if (!isOpend) {
                isOpend = true;
                // 打開調(diào)試器
                inspector.open();
              }
              // 返回給前端的內(nèi)容
              const html = getHTML();
              res.end(html);
            } else if (req.url == '/debug/close') {
              // 如果開啟了則關閉
              if (isOpend) {
                inspector.close();
                isOpend = false;
              }
              res.end('ok');
            } else {
              res.end('ok');
            }

          }).listen(80);

          我們以 API 的方式對外提供動態(tài)控制進程調(diào)試和診斷的能力,具體的實現(xiàn)可以根據(jù)場景去修改,比如給前端返回一個不帶 Inspector 端口的 URL,前端再通過 URL 訪問服務,服務代理請求 Websocket 請求到 Inspector 對應的 WebSocket 服務。比如把收集的數(shù)據(jù)上傳到云上,給前端返回一個 URL。

          3.1.2 具體實現(xiàn)

          我們通過 API 的方式提供功能,設計上采用插件化的思想,主框架負責接收請求和路由處理,具體的邏輯交給具體的插件去做,結構如下所示。

          數(shù)據(jù)收集的實現(xiàn)和上面的例子中類似,收到請求路由到對應的插件,插件通過 Session 和 V8 Inspector 通信完成數(shù)據(jù)的收集。調(diào)試的實現(xiàn)就稍微復雜些,主要的原因是我們不能把端口返回給前端,讓前端直接連接該端口。這個不是因為安全問題,因為調(diào)試的 URL 是一個帶有一個復雜隨機值的字符串,就算端口暴露了,攻擊者也很難猜對隨機值,相比來說,通過提供 API 的方式更加不安全,因為只要知道服務的地址,就可以通過 API 去調(diào)試進程了,所以嚴格來說,這里還需要加一些校驗機制。言歸正傳,不暴露端口的原因是通常前端無法直接連接到這個端口,原因可能有很多,比如我們的服務運行在容器中,容器只對外暴露有限的端口,我們不能期待在進程中隨便起一個端口,在前端就可以直接訪問,但是有一個可以肯定的是,服務至少會對外提供一個端口,那就意味著我們可以通過某個對外的端口把非業(yè)務相關的請求傳遞到進程內(nèi),基于上面的情況,當我們打開 Inspector 端口時,我們只會告訴前端打開成功或者失敗,當前端通過調(diào)試 API 訪問服務器時,我們會判斷端口是否已經(jīng)打開,是的話代理請求到 WebSocket Server。結構設計如下:

          大致實現(xiàn)如下:

          const client = connect(WebSocket Server地址);
          client.on('connect', () = >{
            // 轉發(fā)協(xié)議升級的 HTTP 請求給 WebSocket Server
            client.write(`GET ${req.path} HTTP/1.1\r\n` + buildHeaders(req.headers) + '\r\n');
            // 透傳
            socket.pipe(client);
            client.pipe(socket);
          });

          收到客戶的的請求后,首先連接到 WebSocket Server,然后透傳客戶端的請求,接著通過管道讓 WebSocket Server 和客戶端通信就行。

          3.2 多進程

          為了利用多核,Node.js 服務通常會啟動多個進程,所以支持多進程的調(diào)試和診斷也是非常必要的。但是單進程的調(diào)試診斷方案無法通過橫行拓展來支持多進程的場景。

          3.2.1 單進程方案的限制

          前面提到的方式看起來工作得不錯,但是如果服務是單實例上多進程部署,就會存在一些限制。我們來看看這時候的結構:

          假如我們只有一個對外端口:

          1. 基于 Node.js Cluster 模塊的多進程管理機制,多個進程監(jiān)聽同一個端口是沒問題的,但是請求的分發(fā)上會存在問題,比如請求 1 被分發(fā)到進程 1,打開了進程 1 的 Inspector 端口,接著請求 2 想關閉這個端口,但是請求被分發(fā)到了進程 2,但是進程 2 并沒有打開 Inspector 端口。
          2. 基于 child_process 的 fork 創(chuàng)建多進程,則在重復監(jiān)聽端口時會報錯,導致只有一個進程可以使用提供的功能。

          3.2.1 Agent 進程

          一種解決方案是每個進程監(jiān)聽不同的端口,這樣又回到了前面討論到問題,但是這種方案也不是完全不可行,只需要基于這個方案做一下改進,那就是引入 Agent 進程,這時候結構如下:

          Agent 進程負責收集和管理工作進程的信息(如 pid、監(jiān)聽地址),并接管所有調(diào)試和診斷相關的請求,收到請求后根據(jù)參數(shù)進行請求分發(fā)。具體流程如下:

          1. Agent 啟動一個服務器。
          2. 子進程啟動后,把自己的 pid 和監(jiān)聽的隨機服務地址注冊到 Agent。
          3. 客戶端通過 Agent 獲取進程的 pid 列表,并選擇需要操作的進程。
          4. Agent 收到客戶的請求,根據(jù)入?yún)⒅械?pid 把請求發(fā)送給對應的子進程。
          5. 子進程處理完畢后返回給 Agent,Agent返回給客戶端。

          3.2.2 如何創(chuàng)建 Agent 進程

          確定了 Agent 進程的方案后,如何創(chuàng)建 Agent 進程成為一個需要解決的問題。在 Node.js 里啟動多個服務器的方式是通過 Cluster 或者直接通過 child_process 模塊 fork 出多個子進程,Node.js 框架/工具通常都會封裝這些邏輯,但是框架不一定會提供創(chuàng)建 Agent 進程的方式。為了通用,我們不能假設運行在某種框架/工具中,所以我們只能尋找一種獨立于框架/工具的方案。我們在每個 Worker 進程里都創(chuàng)建一個 Agent 進程,然后多個 Agent 進程競爭監(jiān)聽一個端口,監(jiān)聽成功的進程繼續(xù)運行,監(jiān)聽失敗的退出,最終剩下一個 Agent 進程。

          3.3 多線程

          線程和進程的調(diào)試、診斷類似,下面主要講一下不一樣的地方。

          3.3.1 調(diào)試和診斷基礎

          可以通過以下方式收集線程的數(shù)據(jù)。

          const { Worker, workerData } = require('worker_threads');
          const { Session } = require('inspector');

          const session = new Session();
          session.connect();
          let id = 1;

          // 給子線程發(fā)送消息
          function post(sessionId, method, params, callback{
            session.post('NodeWorker.sendMessageToWorker', {
              sessionId,
              messageJSON.stringify({
                id: id++,
                method,
                params
              })
            },
            callback);
          }

          // 子線程連接上 V8 Inspector 后觸發(fā)
          session.on('NodeWorker.attachedToWorker', (data) = >{
            post(data.params.sessionId, 'Profiler.enable');
            post(data.params.sessionId, 'Profiler.start');
            // 收集一段時間后提交停止收集命令
            setTimeout(() = >{
              post(data.params.sessionId, 'Profiler.stop');
            },
            10000)
          });

          // 收到子線程消息時觸發(fā)
          session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{});

          const worker = new Worker('./httpServer.js', { workerData: { port80 } });

          worker.on('online', () = >{
            session.post("NodeWorker.enable", { waitForDebuggerOnStartfalse }, (err) = >{
              console.log(err, "NodeWorker.enable");
            });
          });

          setInterval(() = >{},100000);

          類似通過 Agent 進程管理多個 Worker 進程一樣,因為一個進程中可能存在多個線程,所以需要對多個線程進行管理。首先通過 NodeWorker.enable 命令開啟子線程的 Inspector 能力,然后通過 NodeWorker.attachedToWorker 事件拿到線程對應的 sessionId,后續(xù)通過 sessionId 和線程進行通信。接著看一下調(diào)試的實現(xiàn):

          const { Worker, workerData } = require('worker_threads');
          const { Session } = require('inspector');

          const session = new Session();
          session.connect();
          let workerSessionId;
          let id = 1;

          function post(method, params{
            session.post('NodeWorker.sendMessageToWorker', {
              sessionId: workerSessionId,
              messageJSON.stringify({
                id: id++,
                method,
                params
              })
            });
          }

          session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{
            const data = JSON.parse(message);
            console.log(data);
          });

          session.on('NodeWorker.attachedToWorker', (data) = >{
            workerSessionId = data.params.sessionId;
            post("Runtime.evaluate", {
              includeCommandLineAPItrue,
              expression`const inspector = process.binding('inspector');
                inspector.open();
                inspector.url();
                `

            });
          });

          const worker = new Worker('./httpServer.js', { workerData: { port80 } });
          worker.on('online', () = >{
            session.post("NodeWorker.enable", { waitForDebuggerOnStartfalse }, (err) = >{
              err && console.log("NodeWorker.enable", err);
            });
          });

          setInterval(() = >{}, 100000);

          線程的調(diào)試主要利用 Runtime.evaluate 在子線程里動態(tài)執(zhí)行代碼來打開子線程的 Inspector 端口。了解了基礎使用后,我們看一下具體實現(xiàn)。

          3.3.2 具體實現(xiàn)

          首先我們提供一個 API 獲取線程列表,這樣我們后續(xù)就可以選擇操作某個線程,后續(xù)的每個請求都需要帶上 線程對應的 id,這里以獲取 Profile 為例講一下處理過程。

          const {
            sessionId,
            interval = INTERVAL,
            duration = DURATION
          } = req.query;
          // 向V8 Inspector 提交命令,開啟 CPU Profile 并收集數(shù)據(jù)
          this.post(sessionId, { method'Profiler.enable' }, (err) = >{
            this.post(sessionId, {
              method'Profiler.setSamplingInterval',
              params: { interval }
            });
            this.post(sessionId, { method'Profiler.start' }, (err) = >{
              // 收集一段時間后提交停止收集命令
              setTimeout(() = >{
                this.post(sessionId, { method'Profiler.stop' }, (err, { profile }) => {});
              }, duration);
            });
          })

          我們看到每一個操作都需要 sessionId。通過 sessionId,我們把請求轉發(fā)到對應的線程。但是和進程不一樣,進程發(fā)送一個請求時傳入一個回調(diào),請求成功后就會執(zhí)行對應的回調(diào),我們不需要保存請求上下文,Node.js 會幫我們處理,但是線程不一樣,存在一個嵌套的過程,因為 Inspector 命令的執(zhí)行模式是一個請求命令對應一個回調(diào),但是和線程通信時,是首選通過 NodeWorker.sendMessageToWorker 命令和主線程通信,主線程會解析出 NodeWorker.sendMessageToWorker 的參數(shù),參數(shù)里包含了給子線程發(fā)送的命令,接著主線程通過 sessionId 把請求轉發(fā)到子線程,然后這時候 NodeWorker.sendMessageToWorker 就會返回并執(zhí)行對應的回調(diào),這時候意味著 NodeWorker.sendMessageToWorker 執(zhí)行結束了,但是我們請求子線程的命令還沒有完成,也就是說我們需要自己維護請求子線程對應的回調(diào)。我們看看 post 的具體實現(xiàn):

          post(sessionId, message, callback ? ) {
            // 請求對應的 id
            const requestId = ++this.id;
            this.session.post('NodeWorker.sendMessageToWorker', {
              sessionId,
              message: JSON.stringify({ ...message, id: requestId })
            },
            (err) = >{
              /*
                回調(diào)說明 NodeWorker.sendMessageToWorker 請求完成
                err非空說明請求失敗,直接執(zhí)行回調(diào)
                err為空說明請求成功,記錄 post 調(diào)用方的請求回調(diào),通過 id 關聯(lián)
              */
              if (typeof callback === 'function') {
                // 發(fā)送失敗則直接執(zhí)行回調(diào),成功則記錄回調(diào)
                if (err) {
                  callback(err);
                } else {
                  this.sessionMap[sessionId]['requests'][requestId] = callback;
                }
              }
            });
          }

          我們看到在 NodeWorker.sendMessageToWorker 回調(diào)里保存了請求子線程的回調(diào)。接下來我們看一下線程執(zhí)行完命令后的回調(diào)。

          this.session.on('NodeWorker.receivedMessageFromWorker', ({
            params: {
              sessionId,
              message
            }
          }) = >{
            const ctx = this.sessionMap[sessionId];
            try {
              const data = JSON.parse(message);
              /**
              *  data 的內(nèi)容格式如下:
              *  {                            
              *    method: string,         
              *    params: Object             
              *  }             
              *  或者
              *  {
              *    id: number,  
              *    result: { result: Object }
              *  }
              */

              const {
                id,
                method,
                result
              } = data;
              // 有 id 說明是請求對應的響應,沒有 id 說明是 Inspector 異步觸發(fā)的事件
              if (id) {
                if (typeof ctx.requests[id] === 'function') {
                  const fn = ctx.requests[id];
                  delete ctx.requests[id];
                  fn(null, result);
                }
              } else {
                ctx.emit(method, data);
              }
            } catch(e) {
              console.warn(e);
            }
          });

          通過 NodeWorker.receivedMessageFromWorker 事件可以接收到線程返回的請求結果,從響應的數(shù)據(jù)中我們可以知道這個響應來自的線程和請求 id,根據(jù)這些信息我們就可以從維護的上下文中找到對應的回調(diào)(某些請求在收到響應前會觸發(fā)一些事件,這種情況下響應里是沒有請求 id 的)。

          接著看一下如何調(diào)試子線程,調(diào)試端口默認是 9229,因為存在多線程,如果我們要同時調(diào)試多個線程的話,則會失敗,所以我們要允許前端來控制打開的端口,接著給子線程發(fā)送一個命令。

          this.post(query.sessionId, {
            method"Runtime.evaluate",
            params: {
              includeCommandLineAPItrue,
              expression`let inspector;
                try {
                  inspector = require('inspector');
                  inspector.open(${port}${host});
                } catch(e) {
                  inspector = process.binding('inspector');
                  inspector.open(${port}${host});
                }
                inspector.url();`

            }
          },
          (err, result) = >{

          });

          我們通過在子線程里動態(tài)執(zhí)行代碼來打開 Inspector 端口,這里需要處理一下不同 Node.js 版本的兼容問題,高版本(比如 16)中增加了一個判斷邏輯,如果存在 session 就無法動態(tài)打開 Inspector 端口了,比如以下代碼在 16 中會報錯(換一下 connect 和 open 的位置就可以執(zhí)行)。

          const inspector = require('inspector');
          const session = new inspector.Session();
          session.connect();
          inspector.open()

          這里需要繞過 JS 層的判斷,通過 C++ 模塊提供的接口直接打開 Inspector 端口,這樣就可以保證任何時候我們都可以動態(tài)打開 Inspector 端口。最后通過  inspector.url() 讓子線程返回調(diào)試的 URL 并保存到上下文中,和進程一樣,前端也是通過 API 的方式連接子線程的 WebSocket Server。最后形成的結構如下。

          4. 使用方式

          目前支持了多個子進程和多個線程的調(diào)試、獲取 CPU Profile、獲取 Heap Profile、獲取 Heap Snapshot、獲取內(nèi)存信息(RSS、堆外內(nèi)存、ArrayBuffer等信息)能力。使用方首先在業(yè)務代碼里加載 SDK,部署服務后,進入調(diào)試診斷平臺頁面,按照以下步驟操作:

          1. 選擇調(diào)試進程還是線程
          2. 輸入服務地址(Agent 進程監(jiān)聽的地址)和選擇對應的操作類型,如果是收集數(shù)據(jù)則還需要輸入收集的持續(xù)時間。
          3. 獲取進程列表,并從中選擇你想操作的進程,每個選項 hover 時會提示進程對應的信息,比如文件路徑。
          4. 如果操作線程的話,在選擇進程后,還需要獲取該進程下的線程列表,并選擇你想操作的線程。
          5. 點擊執(zhí)行就可以獲得你想收集的數(shù)據(jù)或者在線調(diào)試的 URL。

          進程:

          線程:

          5. 總結

          進程、線程的調(diào)試和診斷在 Node.js 中的實現(xiàn)非常復雜,了解了 Node.js 的實現(xiàn)和使用方式后,具體應用到業(yè)務里也不容易,主要是要考慮到不同的業(yè)務場景,需要設計出通用的方案,另外調(diào)試是一個比較有用但是也比較危險的操作,在安全方面也需要多多考慮。調(diào)試、診斷和安全一樣,平時用不上,但是有問題的時候,能幫助我們更好地解決問題。

          更多內(nèi)容參考:

          1. 深入理解 Node.js 的 Inspector:https://mp.weixin.qq.com/s/GLIlhURSrCYQ-8Bqg7i1kA
          2. Node.js子線程調(diào)試和診斷指南:https://zhuanlan.zhihu.com/p/402855448
          - END -
          Node 社群


          我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關的交流、學習、共建。下方加 考拉 好友回復「Node」即可。


             “分享、點贊在看” 支持一波??

          瀏覽 83
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  无码抠逼视频 | 色色综合网络 | 国产熟妇XXXXXⅩ性Ⅹ交 | 亚洲最新免费视频 | 亚洲无吗视频在线观看 |