深入 Node.js 事件循环架构

简介: 深入 Node.js 事件循环架构

关于 Node.js ,相信你已经了解过不少内容,诸如 Node.js 内核、事件循环、单线程、setTimeout 或 setImmediate 函数的执行机制等等。


当然最重要的,你应该知道 Node.js 使用的是非阻塞 IO 模型以及异步的编程风格。本文仍将深入核心进行相关内容的探讨。



01


事件循环到底是什么?Node.js 到底是单线程还是多线程?


关于这个问题,网络上充斥着各种不清晰甚至错误的答案。本文将会深入 Node.js 内核,阐述它是如何实现的以及它的工作机制。 Node.js 并不仅仅只是 " JavaScript on the Server " ,更重要的是,其中约 30% 的部分是 C++ 而不是 JS 。本文将会讲述这些 C++ 部分在 Node.js 中实际做了什么。



Node.js 是单线程?

答案:Node.js 既是单线程,但同时也不是。


一些相关名词:multitasking(多任务)、single-threaded(单线程)、multi-threaded(多线程),thread pool(线程池)、epoll loop(epoll 循环)、event loop(事件循环)。


让我们从头开始深入了解 Node.js 内核中发生了什么?


  • 处理器可以一次处理一件事,也可以一次并行地处理多个任务(multitasking)。


对于单核处理器,其只能一次处理一个任务,应用程序在完成任务后调用 yield 去通知处理器开始处理下一个任务,就像 JavaScript 中的 generator 函数一样,否则没有 yield 则将返回当前任务。在过去,当应用程序无法调用 yield 时,其服务将处于无法访问的状态。


  • 进程是一个 top level 执行容器,它有自己专用的内存系统。


这意味着在一个进程中无法直接获取另一个进程的内存中的数据,为了使两个进程进行通信,我们必须要另外做一些工作,称之为 inter-process communication( IPC ,进程间通信),它依赖于 system sockets(系统套接字)。


Unix 系统中的工作基于 sockets 套接字。Socket 就是一个整数,返回一个 Socket() 系统调用,它被称为 socket descriptor(套接字描述符)或者 file descriptor(文件描述符)。

Sockets 通过虚拟的接口( read / write / pool / close 等)指向系统内核中的对象。


System sockets 系统套接字的工作方式类似于 TCP sockets :将数据转换为 buffer 然后发送。由于我们在进行进程间通信时使用的是 JavaScript ,因此我们必须多次调用 JSON.stringify ,显然这是很低效的。


然而,我们拥有线程!


  • 执行线程是可由调度器独立管理的最小程序指令序列。


线程在进程中运行,一个进程可以包含许多线程,并且由于这些线程处于同一个进程中,因此它们共享同一个内存。


这也就是说线程间通信不需要做任何额外的事情。如果我们在一个线程中托管一个全局变量,那么我们可以直接在另一个线程中访问它,因为它们都保持对同一个内存的引用,这种方式非常高效。


但是我们假设在一个线程中有一个函数,它写入一个 foo 变量,另一个线程则从中读取,这将会发生什么?


答案无从得知,因为我们无法确定读和写的先后顺序。这也正是多线程编程的难点所在。让我们看看 Node.js 如何处理这个问题。


Node.js 说:我只有一个线程。


实际上,Node.js 基于 V8 引擎,代码在主线程中执行,事件循环也运行在主线程中,这就是为什么我们说 Node.js 是单线程的


但是,Node.js 不仅仅只是 V8,它有许多 APIs(C++),并且这些 API 都由 Event Loop 事件循环管理,通过 libuv(C++)实现。


C++ 在后台执行 JavaScript 代码并且拥有访问线程的权限。如果你执行从 Node.js 中调用的 JavaScript 同步方法,它将始终在主线程中运行。但是如果你执行一些异步的任务,它不会总是在主线程中执行:根据你使用的方法,事件循环可以将它路由到 APIs 中的某一个,并且它可以在另一个线程中执行。


看一个示例 CRYPTO ,它有许多 CPU 密集型方法,一些是同步的,一些是异步的。这里看一下 pbkdf2 方法。如果我们在 2 核处理器中执行其同步版本并进行 4 次调用,假设一次调用的执行时间是 2 ms ,则总耗时为 4 * 2 ms = 8 ms 。


但是如果在同一个 CPU(2核)中执行这个方法的异步版本,总耗时则为 2 * 2 ms = 4 ms ,因为处理器将使用默认 4 个线程(下文将会说明),将它托管到两个进程中并执行。


这也就是:Node.js 并发地执行异步方法


Node.js 使用一组预先分配的线程,称之为线程池,如果我们没有指定要打开的线程数,它默认就是使用 4 个线程。

我们可以通过 UV_THREADPOOL_SIZE 进行设置。


所以,Node.js 是多线程的吗?

当然,Node.js 使用了多线程。

然而,Node.js 到底是单线程还是多线程,这取决于 when ?


02


我们来看看 TCP 连接。


Thread per connection :

创建一个 TCP server 最简单的方式就是创建一个 socket ,绑定这个 socket 到某个端口上然后 listen 监听。

在我们调用 listen 之前,该 socket 可用于建立连接或接受连接。当我们调用 listen 时,我们准备接受连接。

当连接到达并且我们需要写入它时,直到我们完成写入之前,我们都无法接受另一个连接,这就是我们将它推入另一个线程的原因。所以我们将 socket descriptor 和 function pointer 传递给线程。


现在,系统可以轻松处理几千个线程,但在这种情况下,我们必须为每个连接向线程发送大量数据,并且这样做并不能很好的扩展到两万到四万个并发连接。


但是,我们实际需要的仅仅只是 socket descriptor 套接字描述符,并记住我们要做的事情(也就是如何使用这些套接字)。所以有一种更好的方法:使用 Epoll(unix系统)或着 Kqueue(BSD系统,其实跟 Epoll 是同一个东西,不同系统名称不一样而已)。


Epoll 是 unix 系统相关底层知识。


Epoll 循环:

Epoll 能为我们带来什么,为什么要使用它。使用 Epoll 允许我们告诉 Kernel(系统内核)我们关注的事件,并且 Kernel 将会告诉我们这些事件何时发生。在上面的例子中,我们关注的是传入的 TCP 连接,因此,我们创建一个 Epoll 描述符并将其添加到 Epoll 循环中,并调用 wait 。每当有 TCP 连接传入时便会唤醒,然后将它添加到 Epoll 循环中并等待来自它的数据。这就是事件循环为我们做的事情。


举个例子:


当我们通过 http 请求向同一个 2 核处理器下载数据时,4 个,6 个,甚至 8 个请求需要的时间相同。这意味着什么?这意味着这里的限制与我们在线程池中的限制不同。


因为操作系统负责下载,我们只是要求它下载,然后问它:完成了吗?还没好吗?完成了吗?(监听 Epoll 中的 data 事件)。


03


APIs


哪些 API 对应于哪种方式呢?(线程,Epoll)


所有 fs.* 方法使用 uv thread pool,除非是同步方法。阻塞调用由线程完成,完成后将信号发送回事件循环。我们无法直接在 Epoll 中 wait ,只能 pipe 。Pipe 管道连接两端:一端是线程,当它完成时,往管道中写入数据,另一端在 Epoll 循环中等待,当它获取到数据时,Epoll 循环唤醒。因此 pipe 是由 Epoll 响应的。


一些主要的方法及其对应的响应方式:


EPOLL :

  • TCP/UDP servers and clients
  • pipes
  • dns.resolve


NGINX :

  • nginx signals ( sigterm )
  • Child processes ( exec, spawn )
  • TTY input ( console )


THREAD POOL :

  • fs.
  • dns.lookup


事件循环负责发送和接受结果,如同中央调度器一般,将请求路由到 C++ API,然后将结果返回给 JavaScript 。



04


Event loop


事件循环到底是什么?它是一个无限的 while 循环,调用 Epoll wait 或者 pool ,当 Node.js 中我们关注的事情如 callback 回调、event 事件、fs 发生时,它将返回给 Node.js ,然后当 Epoll 不再有 wait 时退出。这就是 Node.js 中的异步工作方式,以及为什么我们称之为事件驱动。事件循环允许 Node.js 执行非阻塞 IO 操作。尽管 JavaScript 是单线程的,但只要有可能就会将操作丢给系统内核。


事件循环的一次迭代称之为 Tick,它有自己的 phases(阶段)

更多关于 event loop 的 phases、Timers、process.nextTick() 等请查阅官方文档。


05


Node.js v10.5.0 版本之后,新增了 worker_threads 工作线程模块,允许用户多线程并行执行 JavaScript


工作线程对于执行 CPU 密集型 JavaScript 操作非常有用,但对于 IO 密集型工作没有多大帮助,因为 Node.js 内置的异步 IO 操作比这些 workers 更高效。

目录
相关文章
|
6天前
|
设计模式 JavaScript API
Node.js 事件循环
Node.js 事件循环
15 2
|
1月前
|
Web App开发 JavaScript 前端开发
浏览器与Node.js事件循环:异同点及工作原理
浏览器与Node.js事件循环:异同点及工作原理
|
1月前
|
前端开发 JavaScript UED
深入理解JavaScript中的事件循环机制
JavaScript中的事件循环机制是其异步编程的核心,深入理解该机制对于开发高效、流畅的前端应用至关重要。本文将介绍事件循环的工作原理、常见的事件循环模型,以及如何利用这些知识解决前端开发中的常见问题。
|
2天前
|
存储 前端开发 JavaScript
JavaScript 事件循环的详细描述
【6月更文挑战第15天】JavaScript事件循环是单线程非阻塞I/O的关键,通过调用栈跟踪同步函数,任务队列存储异步任务,事件循环在调用栈空时从队列取任务执行。当遇到异步操作,回调函数进入队列,同步代码执行完后开始事件循环,检查并执行任务。微任务如Promise回调在每个宏任务结束时执行,确保不阻塞主线程,优化用户体验。
10 6
|
6天前
|
前端开发 JavaScript 安全
TypeScript作为一种静态类型的JavaScript超集,其强大的类型系统和面向对象编程特性为微前端架构的实现提供了有力的支持
【6月更文挑战第11天】微前端架构借助TypeScript提升开发效率和代码可靠性。 TypeScript提供类型安全,防止微前端间通信出错;智能提示和自动补全加速跨代码库开发;重构支持简化代码更新。通过定义公共接口确保一致性,用TypeScript编写微前端以保证质量。集成到构建流程确保顺利构建打包。在微前端场景中,TypeScript是强有力的语言选择。
23 2
|
18天前
|
Web App开发 JavaScript Cloud Native
构建高效可扩展的RESTful API:Node.js与Express框架实践指南构建未来:云原生架构在企业数字化转型中的关键作用
【5月更文挑战第29天】 在数字化时代的驱动下,后端服务架构的稳定性与效率成为企业竞争力的关键。本文深入探讨了如何利用Node.js结合Express框架构建一个高效且可扩展的RESTful API。我们将从设计理念、核心模块、中间件应用以及性能优化等方面进行系统性阐述。通过实例引导读者理解RESTful接口设计的最佳实践,并展示如何应对大规模并发请求的挑战,确保系统的高可用性与安全性。
|
24天前
|
前端开发 JavaScript 中间件
基于最新koa的Node.js后端API架构与MVC模式
基于最新koa的Node.js后端API架构与MVC模式
32 1
|
30天前
|
存储 前端开发 JavaScript
JavaScript数据类型归纳,架构师花费近一年时间整理出来的前端核心知识
JavaScript数据类型归纳,架构师花费近一年时间整理出来的前端核心知识
JavaScript数据类型归纳,架构师花费近一年时间整理出来的前端核心知识
|
30天前
|
JavaScript 前端开发
前端 JS 经典:宏任务、微任务、事件循环(EventLoop)
前端 JS 经典:宏任务、微任务、事件循环(EventLoop)
25 0
|
1月前
|
前端开发 JavaScript
深入理解JavaScript的事件循环(Event Loop)
深入理解JavaScript的事件循环(Event Loop)

热门文章

最新文章