这周在 dev.to 上看了一篇关于 Node 的文章,从为什么有 Node.js,到工作线程解决了什么问题,写的非常清晰易懂。 找原作者要了授权,趁周末整理一下翻译,分享给掘金的小伙伴。作者有 6 年的 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。
这个需求听起来很简单,但是真正发生了什么呢?
用户访问
/products
的时候,代码会返回所的有产品。 所以,有一小段代码会解析 url 请求并且找到返回产品的方法。 线程正在工作。✔️执行该方法的第一行。 线程正在工作。✔️
作为优秀的开发人员,我们会记录所有的系统日志。在日志中写入「方法 X 执行中!」。 这是一个阻塞 I/O 的操作。线程正在等待。❌
保持日志,执行该方法剩下的几行代码。 线程再次工作。✔️
接下来去数据库并获取所有产品了,一个简单的 sql 查询,比如
SELECT * FROM products
。 这也是阻塞的 I/O 的操作。线程正在等待。❌现在我们得到了产品列表,将它们记录下来。 线程正在等待。❌
接下来是渲染 html 模板。需要先读取所有数据。 线程正在等待。❌
模板引擎完成它的工作,并将响应发送到客户端。 该线程再次工作。✔️
线程是空闲的,就像鸟一样。🕊️
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,这意味着:
- 主线程不会在 I/O 操作中阻塞。
- 服务器将继续参加请求。
- 我们将使用异步代码。
让我们写一个例子,请求 /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.createServer
和 fs.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 排版