跳转至

35 多进程部署:如何最大限度利用服务器资源运行Node.js服务?

你好,我是杨文坚。

上节课,我们基于服务端的纵向和横向切面,把Node.js服务化整为零,分解成了服务的各个最小颗粒度模块;然后根据这些模块逻辑,设计出两种方向切面的扩展规范;最后根据扩展规范设计和服务端结构分析,我们详细展示了服务端“技术底座”,分析了如何根据技术框架优势,更优雅地扩展功能。

到现在,我们已经实现了搭建功能,也做好功能扩展工作,那服务端开发方面的工作,是否能告一段落呢?

答案是否定的。还记得吗,之前我们在阶段性完成前端页面的开发工作后,还用一节课的时间,学习了如何优化搭建平台的前端性能。服务端的开发工作也是一样的,除了完成功能,还需要做服务端的性能优化。

在前端页面性能优化中,我提到两种优化思路,“前置优化”和“后置优化”。无论选择哪种优化思路,都要考虑常见的性能问题场景,也不能“过度设计”造成开发资源浪费。所以,服务端性能优化的工作,我们也要尽量“好钢用在刀刃上”,优先考虑大概率的性能场景问题,做好优化准备。

那服务端大概率的性能问题是什么?如何优化解决呢?我们开始今天的学习。

为什么Node.js会发生阻塞

服务端的性能问题,说到底其实就是机器资源的使用问题,主要是CPU和内存的利用问题。通常有两种极端情况,机器资源不够用、机器资源利用不充分,我们统称为机器资源使用不合理。

对于Node.js服务来说,我们都知道,机器资源使用不合理,通常会导致程序阻塞。那为什么Node.js会发生阻塞问题呢?

Node.js是运行JavaScript语言的一种环境,而JavaScript是“单线程语言”,按照JavaScript语法执行逻辑,在Node.js环境里的执行过程也是单线程的。

插句题外话,要注意,“多线程”和“多进程”是两个不同的技术概念,别混淆了。

可能你听说过,浏览器可以用Web Workers API实现多线程,或者Node.js自带了多线程和多进程模块,但是这些都不是JavaScript本身的标准能力,而是浏览器和Node.js借助底层的能力,实现多线程或者多进程,属于运行环境提供的能力。

如果你不太理解,也不用担心,我们后面会讲Node.js多线程和多进程的使用,现在你可以直接理解,在Node.js环境里,默认单线程执行程序。

好,讲回来。Node.js的单线程,因为省去了多线程频繁切换操作,避免共享资源冲突等风险,也没有线程锁操作等繁琐逻辑,能让服务程序变得轻量和简便。

你可以类比成“发快递”。单线程就是一个快递员派件,他知道所有地址,交通工具只有自己用,按部就班进行派件。多线程就是多个快递员派件,虽然多人并行派件,但是要分配工作事项、共用交通工具,增加了很多“管理成本”,类似多线程的“繁琐操作逻辑”。

虽然单线程管理简单,但是因为只有一个“执行单元”,如果遇到CPU密集计算的执行流程,就会被阻断住,导致后续程序内容执行不了,这就是Node.js发生阻塞的原因。

这么描述有点抽象,我们看一个实际的Node.js服务代码案例。

// demos/http.cjs
const Koa = require('Koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();
const port = 9001;

router.get('/001', async (ctx) => {
  ctx.body = '<div>普通页面结果</div>';
});

router.get('/002', async (ctx) => {
  let count = 0;
  for (let i = 0; i < 9999999999; i++) {
    count++;
  }
  ctx.body = `<div>CPU密集计算数据 - 共[${count}]次计算</div>`;
});

app.use(router.routes());

app.listen(port, () => {
  console.log('服务已启动');
  console.log(`访问普通请求 http://127.0.0.1:${port}/001`);
  console.log(`访问CPU密集计算请求 http://127.0.0.1:${port}/002`);
});

这段代码是一个简单的Koa.js实现的Node.js服务,提供了两个HTTP页面,页面路径分别是 “/001”和“/002”。其中,路径“/001”请求是普通的HTML页面,路径“/002”请求是经过九十多亿次计算后才响应的HTML页面。这里,九十多亿次的计算属于CPU密集计算,在这个环节,Node.js单线程服务会被阻塞住,直到被占用CPU的资源被释放。

我们写一个请求HTTP脚本,请求这两个路径,验证一下效果。

首先是单独请求普通HTML页面路径“/001”。

// demos/http-get.cjs
// 单独请求 /001
const start = Date.now();
fetch('http://127.0.0.1:9001/001')
  .then((res) => res.text())
  .then(() => {
    console.log(`/001 请求耗时 ${Date.now() - start}ms`);
  });

在Node.js环境执行这段代码,结果就是这张截图。

图片

可以看出,路径为“/001”普通页面请求处理,大概需要几十毫秒耗时。

接下来换成单独请求CPU密集计算路径“/002”。

// demos/http-get.cjs
// 单独请求 /002
const start = Date.now();
fetch('http://127.0.0.1:9001/002')
  .then((res) => res.text())
  .then(() => {
    console.log(`/002 请求耗时 ${Date.now() - start}ms`);
  });

在Node.js环境执行代码,结果就是这样。

图片

从截图可以看出,路径为“/002”CPU密集计算请求处理,大概需要一万多毫秒的耗时,也就是九十亿的加法计算,在我本地电脑中,是十几秒级别的延时。

现在有两个路径的单独请求耗时,我们来模拟一下同时请求操作,验证阻塞的效果。

// demos/http-get.cjs
// 模拟并发请求两种路径
const startFor002 = Date.now();
console.log('开始执行请求 /002 ...');
fetch('http://127.0.0.1:9001/002')
  .then((res) => res.text())
  .then(() => {
    console.log(`/002 请求耗时 ${Date.now() - startFor002}ms`);
  });

const startFor001 = Date.now();
console.log('开始执行请求 /001 ...');
fetch('http://127.0.0.1:9001/001')
  .then((res) => res.text())
  .then(() => {
    console.log(`/001 请求耗时 ${Date.now() - startFor001}ms`);
  });

执行代码后,看结果的截图。

图片

可以看出,先请求CPU密集计算的“/002”路径,会阻塞住后面普通页面的“/001”路径请求,导致原本“/001”请求从耗时几十毫秒上升到十几秒,瞬间从“毫秒级”降级到“秒级”。

不过,你可能会有疑问,我们这个代码案例里几十亿的加法计算,已经是天文数量级别了,现实工作会遇到这类场景吗?

好问题,现实工作里,我们确实很少碰到“几十亿”级别的计算量,但是导致CPU密集计算不只这一个原因,还可能是多个并发操作累积导致的。例如,在Vue.js的服务端渲染场景,需要在服务进行HTML字符串拼接操作,这也占用CPU计算资源。如果碰到HTML字符串计算复杂的情况,再叠加并发请求过多,就很容易导致服务请求被阻塞。

所以,Node.js服务中一旦出现密集计算的过程,就容易导致阻塞问题,阻塞了后续请求过程,导致并发问题。这类Web服务的密集计算场景,其实很常见,我们也避免不了的。

那么有没有办法,来解决这类问题呢?答案是有的,就是利用多进程或多线程。

什么是进程和线程

相信你在日常工作或者学习过程中,一定听说“线程”和“进程”,但是很多人会混淆这两个技术词汇。实际上,线程和进程是不同的技术概念,但是两者也有一定技术关系。

进程,英文称为Process,是计算机系统里调度和分配资源的单位,也是线程的运行的宿主容器。

线程,Thread,是计算机系统里运算的最小单位,在进程中运行,也是进程中实际运行程序的单位。我们经常提到的“多线程”,就是指在“同一进程”里,有“多个线程”来执行程序,并且共享“同一进程分配的资源”。

听起来有点抽象,我们还是用“发快递”的例子来类比“多进程”和“多线程”。

如果把计算机系统类比成一个城市的快递体系,那么:

  • 进程,就是城市里每一个快递点,可以调度和分配快递的运力资源。
  • 线程,就是快递点里的快递员,是实际快递点执行配送快递的最小单元。

这个城市有多个快递点同时执行快递运输,就像一个计算机系统里多进程在执行任务。每个快递点,同时有多个快递员进行快件配送,就像同个进程,可以多线程执行程序任务,而且,每个快递员执行的是最后的送件工作,意味着线程是最小运行单元。

同个快递点,所有快递员,可以互相共享交通工具,类似线程之间可以共用资源,例如内存等。

不同快递站点,责任分明,交通工具不能跨站点共用,类似进程之间的资源不能共用。虽然不能共用交通工具,但是,不同快递站点,可以互相联系告知工作情况,类似进程之间虽然不能共享资源,但是可以进行进程间通信,全称 InterProcess communication,简称IPC

理清了进程和线程,我们回到课程Vue.js和Node.js的全栈项目里,用Koa.js搭建Web服务,大概率遇到的CPU密集计算的场景,主要集中在服务端渲染环节,也就是在服务端运行Vue.js代码,通过计算和拼接字符串,来生成HTML内容。

如果这个页面的HTML结构非常复杂,请求页面的时候,服务端就会执行Vue.js代码,进行大量HTML字符串拼接计算,属于CPU的密集计算。这个时候,默认Node.js服务只有一个进程,进程里只有一个线程来执行任务,也就是单进程单线程服务。如果页面再叠加上并发请求,就可能造成阻塞问题。

现在我们解决的办法就是,利用多进程或者多线程来并行执行任务,缓解密集计算的任务压力,避免发生阻塞或并发问题。那么,如何在Node.js服务中使用多进程或多线程呢?

如何使用Node.js的多进程和多线程

前面提过,目前Node.js天然提供了进程和线程的控制模块,我们可以直接使用。

想发挥多线程多进程的优势,首先要有瓶颈场景,那我们先模拟复杂的Vue.js服务端渲染场景。

// demos/html-action.js
const { h, renderList, toDisplayString, createSSRApp } = require('vue');
const { renderToString } = require('vue/server-renderer');

// Vue.js组件
const Item = {
  props: { index: Number, text: String },
  setup(props) {
    const { text, index } = props;
    return () => {
      return h('div', { class: 'v-item' }, [
        toDisplayString(index),
        toDisplayString(text)
      ]);
    };
  }
};

// Vue.js组件
const List = {
  props: {
    list: Array
  },
  setup(props) {
    const { list } = props;
    return () => {
      return h(
        'ul',
        { class: 'v-list' },
        renderList(list, (item, index) => {
          return h('li', null, [
            h('li', null, [h(Item, { text: item.text, index: index })])
          ]);
        })
      );
    };
  }
};

// Vue.js服务端渲染代码
// 按count数量,循环拼接Vue.js组件HTML字符串
async function createAppHTML(count) {
  const list = [];
  for (let i = 0; i < count; i++) {
    list.push({ text: `data-${i}` });
  }
  const app = createSSRApp(List, { list });
  const html = await renderToString(app);
  return html;
}

// 循环次数列表
const dataList = [100000, 200000, 300000, 400000];

module.exports = {
  createAppHTML,
  dataList
};

如果用默认“单进程单线程”的方式来执行,代码就是这样的:

// demos/html.js
const { createAppHTML, dataList } = require('./html-action');

async function main() {
  const htmlList = [];
  const start = Date.now();
  for (let i = 0; i < dataList.length; i++) {
    const s = Date.now();
    const html = await createAppHTML(dataList[i]);
    htmlList.push(html);
    console.log(
      `编译数据量为 [${dataList[i]}] 的Vue模板,耗时 ${Date.now() - s}ms`
    );
  }
  console.log(`编译HTML结束,总耗时为 ${Date.now() - start}ms`);
}

main();

接着,我们在Linux或MacOS系统环境下,用“time”命令来辅助执行代码,统计一下耗时和资源使用情况,看结果截图。

图片

可以看到,单进程单线程模式下,耗时55多秒,CPU使用率96%。

这个CPU使用率怎么理解呢?如果是多核CPU的机器,96%使用率是比较低的。我们可以用Node.js多线程的方式来处理,得到新的CPU使用率,对比一下。

// demos/html-thread.js 
const {
  isMainThread,
  workerData,
  Worker,
  parentPort
} = require('worker_threads');
const { createAppHTML, dataList } = require('./html-action');

const htmlList = [];
// 线程数量为 多次密集计算的数量
const threadCount = dataList.length;

if (isMainThread) {
  // 如果是在主线程内
  console.log('Main Thread: 主线程');
  const mainStart = Date.now();
  // 触发多线程
  for (let i = 0; i < threadCount; i++) {
    // 将多次Vue.js服务端渲染的密集计算分配给子线程
    const worker = new Worker(__filename, {
      workerData: { count: dataList[i] }
    });
    // 线程间的通信
    worker.on('message', (data) => {
      htmlList.push(data.html);
      console.log(
        `Child Thread (${worker.threadId}) 子线程执行耗时:${data.time}ms`
      );
      if (htmlList.length >= dataList.length) {
        console.log(`执行全部结束,总耗时: ${Date.now() - mainStart}ms`);
      }
      // 子线程执行完计算后,触发结束子线程
      worker.emit('end');
    });
  }
} else {
  // 如果进入子线程
  // 并行帮忙执行分配的任务
  console.log(`Child Thread: 启动子线程, 初始数据:${workerData.count}`);
  const start = Date.now();
  createAppHTML(workerData.count).then((html) => {
    parentPort.postMessage({ html, time: Date.now() - start });
  });
}

我们用Node.js的多线程模块来运行Vue.js代码的密集计算,也用Linux下的“time”命令执行,可以看到结果。

图片

用多线程模式(单进程的多线程),耗时25秒左右,CPU使用率达到222%,比刚才的单线程96%使用率高,这是因为利用多线程,发挥了多核CPU的算力,得到性能的提升,提高运行速度,降低运行时间。

看了多线程的操作,我们换成多进程的方式试试看,也就是多进程的单线程模式。

// demos/html-process.js
const cluster = require('cluster');
const { createAppHTML, dataList } = require('./html-action');

const htmlList = [];
// 进程数量为 多次密集计算的数量
const processCount = dataList.length;

if (cluster.isMaster) {
  // 进入主进程
  console.log('Main Process: 主进程');
  const mainStart = Date.now();

  for (let i = 0; i < processCount; i++) {
    // 启动多进程来并发执行任务
    const worker = cluster.fork();
    worker.send({ count: dataList[i] });

    // 进程之间的IPC通信
    // 主进程向子进程发送任务数据
    worker.on('message', (data) => {
      htmlList.push(data.html);
      console.log(
        `Child Process (${worker.process.pid}) 子进程执行耗时:${data.time}ms`
      );
      if (htmlList.length >= dataList.length) {
        console.log(`执行全部结束,总耗时: ${Date.now() - mainStart}ms`);
      }
      // 子进程执行完任务后,退出子进程
      worker.kill();
    });
  }
} else {
  console.log(`Child Process: 启动子进程 (pid: ${process.pid})`);
  // 进程之间的IPC通信
  // 接收主进程的消息
  process.on('message', (data) => {
    const start = Date.now();
    // 执行Vue.js服务端渲染的密集计算
    createAppHTML(data.count).then((html) => {
      // 通过IPC,向主进程发送消息
      process.send({ html, time: Date.now() - start });
    });
  });
}

执行代码,查看多进程的性能使用情况。

图片

从截图可以看出,多线程执行的耗时24秒左右,CPU使用率达到143%,这是因为使用了多进程,也同样发挥了多核CPU的算力,得到了性能的提升。

多进程和多线程两次运行对比,除了CPU使用率有差异,耗时是差不多的,而且都比单进程单线程执行的耗时少。我们归纳一下多线程和多进程的优劣。

多线程比多进程省内存等资源,但是,多进程比多线程稳定性强一些。你可以这么理解,多线程比较适合单独解决密集计算问题,多进程较适合管理服务的稳定性

所以,这里我们就选择多进程的方式,来提高CPU等机器资源的利用率,提升性能。那么运营搭建平台,如何用多进程来部署Node.js服务呢?

如何部署搭建平台多进程服务

从前面Node.js的多进程案例代码中,我们可以看出,开启多进程是直接扩展出子进程,执行Node.js的应用程序,不需要改动原有的应用代码。

那么面向本课程的运营搭建平台,我们可以添加这个服务进程管理文件,执行服务的多进程。

// packages/work-server/start.cjs
const cluster = require('cluster');
const path = require('path');
const os = require('os');

const processCount = os.cpus().length;
const entryFile = path.join(__dirname, 'dist', 'index.cjs');

cluster.setupMaster({
  exec: entryFile
});

// 根据CPU核数,启动多进程
for (let i = 0; i < processCount; i++) {
  cluster.fork();
}

从代码可以看出,Node.js服务,只需要新增一个脚本来启动多进程。这里,多进程的数量,建议跟当前服务器器的CPU核数保持一致,能最大限度发挥多核CPU的资源和能力。

有一点你要特别注意,多进程模式,主要在生产模式中使用,不要在开发模式下使用

因为在开发模式中,我们是基于TypeScript语法进行代码开发,同时又有nodemon进行代码热更新,如果这时候开发模式加上了多进程,会带来很多开发上的干扰。而且,多进程的使用,是为了解决生产环境下的遇到服务阻塞问题或并发问题,是一种服务端性能优化的技术措施,并不是必要的技术措施。所以在课程的代码案例里,我们在生产模式中启动多进程服务。

当然,我们基于原生Node.js的进程模块,启动了服务的多进程,社区也有成熟的现成工具来直接辅助启动多进程,你可以考虑使用pm2,具体工具信息可以查看这里:https://www.npmjs.com/package/pm2

总结

围绕服务“阻塞问题”这一常见的服务端性能瓶颈点,我们展开了对Node.js服务的性能优化分析。性能问题,根本原因可以归纳成两种。

  • 代码逻辑不合理,导致大量CPU密集计算直接运行。
  • 机器资源利用不合理,单线程单进程执行代码,没充分利用机器资源。

我们有两个解决思路。

  • 直接思路:直接优化资源利用,因为性能瓶颈问题,说到底就是资源使用或利用问题。
  • 根治思路:优化代码逻辑,尽量按需设计代码,按需执行程序,避免直接执行CPU密集计算的逻辑。

关于Node.js的性能优化,也可以归纳成两个方面,CPU密集计算优化和机器资源使用优化。

  • CPU密集计算优化方面:优先尽量少做密集计算逻辑,根据功能最小需求,按需计算。如果真的避免不了密集计算,可以选择Node.js环境提供的多线程模块进行密集计算。
  • 机器资源优化方面:如果机器条件允许,可以尽量使用多线程来启动服务程序,保证机器资源的充分利用。

线程和进程,进程是调度资源最小单位,线程是进程里执行操作最小单位。两者各有利弊,各有适用的场景,应该扬长避短地选择使用。

思考题

服务端的性能优化措施,除了多线程、多进程的优化措施外,还有其它优化方案吗?

欢迎留言分享你的思考。在掌握Node.js服务端的性能优化操作的同时,也要记得举一反三应用到其它开发场景中。我们下节课见。

完整的代码在这里

精选留言(1)
  • ifelse 👍(0) 💬(0)

    多线程和多进程主要是解决cpu阻塞操作问题,比如io阻塞,网络阻塞问题。 至于计算密集型,一般就是要改代码才能充分利用cpu多核的性能优势,比如大任务切分为小任务,然后利用多任务执行提高性能。

    2024-10-08