你需要知道的 Node.js 工作原理

1,950 阅读10分钟

这周在 dev.to 上看了一篇关于 Node 的文章,从为什么有 Node.js,到工作线程解决了什么问题,写的非常清晰易懂。 找原作者要了授权,趁周末整理一下翻译,分享给掘金的小伙伴。作者有 6 年的 Node.js 经验,绝对的大佬。 水平有限,文章中如果有翻译错误恳请各位大佬指出,感谢~!

原文地址:Everything you need to know about Node.js

Node.js 是现在构建可扩展、高效的 REST API 的技术之一。 它还用于构建混合移动应用、桌面应用甚至用于物联网。

我已经使用 Node.js 大概 6 年,我很喜欢它! 这篇文章试图成为了解 Node.js 工作原理的终极指南。

Let's get started!!

Node.js 之前的世界

多线程服务器

Web 应用程序是客户端-服务器模式(client/server model)的。

  • 客户端向服务器请求资源,服务器将资源响应给客户端。
  • 只有客户端发出请求服务端才响应,并且每次响应后关闭连接。

服务器处理每个请求都需要消耗时间和资源(内存、CPU等等)。 服务器必须完成上一个请求,才能接受下一个请求。

下面问题来了,服务器一次只能处理一个请求吗? 这么说不太准确,因为服务器收到新的请求,会交给线程处理。

线程是 CPU 为执行一小段指令提供的时间和资源。

服务器一次处理多个请求,每个线程只负责一个。

要同时处理 N 个请求,那么服务器需要 N 个线程。 如果服务器收到 N + 1个请求,必须等待 N 个线程中有空闲,才会去处理。

在上图「多线程服务器」中,服务器一次最多允许4个请求(线程)。 此时又接收到 3 个请求,这 3 个请求必须等到 4 个线程中有可用线程。

解决限制的一种方法是升级服务器资源(内存,CPU 内核等), 但这可能根本不是一个好主意...

阻塞 I/O

服务器中的线程数并不是这里唯一的问题。

你可能有这样的疑问,为什么一个线程不能同时处理两个或多个请求?

因为 I/O 操作被阻塞了。 假设你正在开发商店 app,有一个列表页 /products 可以查看所有产品。 用户访问这个网页,服务器返回由数据库中的所有产品渲染的 HTML。

这个需求听起来很简单,但是真正发生了什么呢?

  1. 用户访问 /products 的时候,代码会返回所的有产品。 所以,有一小段代码会解析 url 请求并且找到返回产品的方法。 线程正在工作。✔️

  2. 执行该方法的第一行。 线程正在工作。✔️

  3. 作为优秀的开发人员,我们会记录所有的系统日志。在日志中写入「方法 X 执行中!」。 这是一个阻塞 I/O 的操作。线程正在等待。❌

  4. 保持日志,执行该方法剩下的几行代码。 线程再次工作。✔️

  5. 接下来去数据库并获取所有产品了,一个简单的 sql 查询,比如 SELECT * FROM products这也是阻塞的 I/O 的操作。线程正在等待。❌

  6. 现在我们得到了产品列表,将它们记录下来。 线程正在等待。❌

  7. 接下来是渲染 html 模板。需要先读取所有数据。 线程正在等待。❌

  8. 模板引擎完成它的工作,并将响应发送到客户端。 该线程再次工作。✔️

  9. 线程是空闲的,就像鸟一样。🕊️

I/O 操作有多慢?

操作 CPU 时钟周期数
CPU 寄存器 3 ticks
L1 缓存 8 ticks
L2 缓存 12 ticks
RAM(内存) 150 ticks
disk(磁盘) 30,000,000 ticks
RAM(内存) 150 ticks
Network(网络) 250,000,000 ticks

磁盘和网络操作太慢了。 想一想你的系统进行了多少次查询、外部 API 调用?

I/O 操作让线程等待,并且浪费资源。

C10K 问题

问题

即使服务器处理1万个同时连接 2000 年初,在一台服务器上处理 10,000 个连接,服务器和客户端会运行地很慢。

一个线程处理一个请求模型(thread-per-request model)不能解决这个问题。

为什么呢?

本机线程(native thread)为每个线程分配大约 1 MB 的内存, 因此 10k 个线程仅用于线程堆栈就需要 10GB RAM, 请记住我们在 2000 年代初期!

JavaScript 来救援?

剧透警告 🚨🚨🚨!!

Node.js 解决了这个问题...怎么做到的?

Javascript 服务器在 2000 年初并不是什么新鲜事, 它基于「thread-per-request」模式在 Java 虚拟机之上的实现, 例如 RingoJS 和 AppEngineJS。

但是,如果那(译注:thread-per-request model)不能解决 C10K 问题, 为什么 Node.js 可以呢?

好吧,因为 Javascript 是单线程的。

Node.js和事件循环

Node.js

Node.js 是基于 Google Chrome 的 Javascript 引擎(V8引擎)构建的服务器端平台, 可将 Javascript 代码编译为机器码。

Node.js 基于事件驱动的非阻塞 I/O 模型,所以它轻巧高效。 它不是框架,不是库,而是运行时环境。

让我们写一个简单的例子:

// 导出 http 模块
const http = require('http');

// Creating a server instance where every call
// the message 'Hello World' is responded to the client
const server = http.createServer(function(request, response) {
  response.write('Hello World');
  response.end();
});

// Listening port 8080
server.listen(8080);

非阻塞 I/O

Node.js 是非阻塞 I/O,这意味着:

  1. 主线程不会在 I/O 操作中阻塞。
  2. 服务器将继续参加请求。
  3. 我们将使用异步代码

让我们写一个例子,请求 /home 时,服务器相应一个 HTML 页面。 其他情况下,服务器相应「Hello World」。 要相应 HTML 页面,必须先读取这个文件。

home.html

<html>
  <body>
    <h1>This is home page</h1>
  </body>
</html>

index.js

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

const server = http.createServer(function(request, response) {
  if (request.url === '/home') {
  // 读取 html
    fs.readFile(`${ __dirname }/home.html`, function (err, content) {
      if (!err) {
        response.setHeader('Content-Type', 'text/html');
        response.write(content);
      } else {
        response.statusCode = 500;
        response.write('An error has ocurred');
      }

      response.end();
    });
  } else {
    response.write('Hello World');
    response.end();
  }
});

server.listen(8080);

如果请求的 url 是 /home,我们读取 home.html 文件。

传递给 http.createServerfs.readFile 的函数称为回调。 这些功能会在以后执行(第一个功能在服务器收到请求时执行,第二个在读取文件之后执行)。

在读取文件时,Node.js 仍然可以在单个线程中处理请求,甚至可以再次读取文件... 这是怎么做到的!?

事件循环

事件循环是 Node.js 背后的魔力。

简单来说,事件循是一个无限循环,也是唯一可用的线程。

Libuv 是 的实现此模式的库,它是 Node.js 核心模块的一部分,由 C 语言编写。 可以在这里看到更多有关 libuv 信息。

事件循环有六个阶段,所有阶段的执行称为 tick

  • timers:这个阶段执行 setTimeout()setInterval() 的回调函数。
  • pending callbacks:待处理的回掉。除了 close/timers/setImmediate 回掉,其他回掉都在这里执行。
  • idle, prepare:仅在内部使用。
  • poll:检索新的 I/O 事件,有时候 Node 会在此处阻塞。
  • check:setImmediate() 回调在这里执行。
  • close callbacks:一些准备关闭的回掉,如 socket.on('close', ...)

所以只有一个 Event Loop 线程,但是来执行 I/O 操作呢?

注意📢📢📢!

当 Event Loop 需要执行 I/O 操作时,它会使用池中的 OS 线程(通过libuv库)。 在工作完成后,回调被排在「pending callbacks」阶段执行。

这不是很棒吗?

CPU 密集型任务的问题

Node.js 看起来很完美,可以用它构建任何我们想要的东西。

我们来写一个计算质数的方法。

质数是一个大于 1 的自然数,它只能被自己和 1 整除。

给定数字N,这个方法需要返回数组中的前 N 个质数。

primes.js

function isPrime(n) {
  for(let i = 2, s = Math.sqrt(n); i <= s; i++)
    if(n % i === 0) return false;
  return n > 1;
}

function nthPrime(n) {
  let counter = n;
  let iterator = 2;
  let result = [];

  while(counter > 0) {
    isPrime(iterator) && result.push(iterator) && counter--;
    iterator++;
  }

  return result;
}

module.exports = { isPrime, nthPrime };

index.js

const http = require('http');
const url = require('url');
const primes = require('./primes');

const server = http.createServer(function (request, response) {
  const { pathname, query } = url.parse(request.url, true);

  if (pathname === '/primes') {
    const result = primes.nthPrime(query.n || 0);
    response.setHeader('Content-Type', 'application/json');
    response.write(JSON.stringify(result));
    response.end();
  } else {
    response.statusCode = 404;
    response.write('Not Found');
    response.end();
  }
});

server.listen(8080);

prime.js 是查找质数的方法, isPrime 检查给定的数字 N 是否为素数。 如果是质数,nthPrime 将返回结果。

使用 index.js 创建一个服务,每次调用都使用方法 /primes,通过 query 传递参数。

为了获取前 20 个质数,我们向发出请求 http://localhost:8080/primes?n=20

假设有 3 个客户端试图访问这个的非阻塞 API:

  • 第一个每秒请求前 5 个质数。
  • 第二个数字每秒请求前 1000 个质数。
  • 第三个请求一次输入前 10,000,000,000 个质数,但是...

当第三个客户端发送请求时,主线程被阻塞,请求卡死,这是因为质数计算占用了大量 CPU。 主线程忙于执行密集的计算,将无法执行其他任何操作。

但是 libuv 呢? 还记得这个库可以帮助 Node.js 使用 OS 线程执行 I/O 操作来避免主线程阻塞吗?

是的,它可以帮我们解决这个问题! 但是要使用 libuv,我们的必须使用 C++ 语言编写代码。

还好,Node.js v10.5 引入了工作线程 Worker Threads。

Worker Threads

文档中这么写道:

工作线程对于执行 CPU 密集型的 JavaScript 操作非常有用。 它们在 I/O 密集型的工作中用途不大,Node.js 的内置的异步 I/O 操作比工作线程效率更高。

修改代码

是时候修改我们的代码了! primes-workerthreads.js

const { workerData, parentPort } = require('worker_threads');

function isPrime(n) {
  for(let i = 2, s = Math.sqrt(n); i <= s; i++)
    if(n % i === 0) return false;
  return n > 1;
}

function nthPrime(n) {
  let counter = n;
  let iterator = 2;
  let result = [];

  while(counter > 0) {
    isPrime(iterator) && result.push(iterator) && counter--;
    iterator++;
  }

  return result;
}

parentPort.postMessage(nthPrime(workerData.n));

index-workerthreads.js

const http = require('http');
const url = require('url');
const { Worker } = require('worker_threads');

const server = http.createServer(function (request, response) {                                                                                              
  const { pathname, query } = url.parse(request.url, true);

  if (pathname === '/primes') {                                                                                                                                    
    const worker = new Worker('./primes-workerthreads.js', { workerData: { n: query.n || 0 } });

    worker.on('error', function () {
      response.statusCode = 500;
      response.write('Oops there was an error...');
      response.end();
    });

    let result;
    worker.on('message', function (message) {
      result = message;
    });

    worker.on('exit', function () {
      response.setHeader('Content-Type', 'application/json');
      response.write(JSON.stringify(result));
      response.end();
    });
  } else {
    response.statusCode = 404;
    response.write('Not Found');
    response.end();
  }
});

server.listen(8080);

index-workerthreads.js 每次被调用,都会创建一个新的 Worker 实例(来自 worker_threads 本机模块)。 在工作线程中执行 primes-workerthreads.js 文件。

当质数列表计算完成,触发 message 事件,把结果发送到主线程, 因为工作已经完成,exit 事件也会被触发,主线程将数据发送到客户端。

primes-workerthreads.js 变化小一些。

  • 导入了 workerData,这是从主线程传递的参数
  • parentPort,这是我们向主线程发送消息的方式。

现在,我们再看看上面的 3 个客户端,会发生什么?

主线程不再阻塞🎉🎉🎉🎉🎉!!!!! 这和我们预期的一样,但是直接生成工作线程并不是最佳实践,因为创建新线程的开销很大。 记得要先创建线程池哦。

总结

Node.js 是一项功能强大的技术,值得学习。

希望大家一直刨根问底,因为你知道工作原理之后,会做出更好的决定。

希望您对 Node.js 有了进一步了解。

感谢您的阅读,我们下一篇文章见。❤️

本文使用 mdnice 排版