Node.js采用事件驱动和异步I/O的方式,实现了单线程、高并发的JavaScript运行环境。既然单线程意味着一次只能做一件事,那么Node.js如何通过一个线程实现高并发和异步I/O呢?本文将围绕这个问题探讨 Node.js 的单线程模型。
一般来说,高并发的解决方案是提供多线程模型。服务器为每个客户端请求分配一个线程并使用同步 I/O。系统通过线程切换来弥补同步I/O调用的时间成本。例如,Apache就使用这种策略。由于 I/O 操作通常非常耗时,因此这种方法很难获得高性能。不过,它非常简单,可以实现复杂的交互逻辑。
事实上,大多数 Web 服务器端并不执行太多计算。接收到请求后,将请求传递给其他服务(例如读取数据库),然后等待结果返回,最后将结果发送给客户端。因此,Node.js 使用单线程模型来处理这种情况。它不是为每个传入的请求分配一个线程,而是使用一个主线程来处理所有请求,然后异步处理 I/O 操作,避免了创建、销毁线程以及线程之间切换的开销和复杂性。
Node.js 在主线程中维护一个事件队列。当收到请求时,它会作为事件添加到此队列中,然后继续接收其他请求。当主线程空闲时(没有请求传入),它开始循环遍历事件队列以检查是否有事件需要处理。有两种情况:对于非I/O任务,主线程会直接处理,并通过回调函数返回上层;对于I/O任务,它会从线程池中取出一个线程来处理事件,指定一个回调函数,然后继续循环队列中的其他事件。
一旦线程中的I/O任务完成,就执行指定的回调函数,并将完成的事件放在事件队列的末尾,等待事件循环。当主线程再次循环到这个事件时,直接处理并返回给上层。这个过程称为Event Loop,其运行原理如下图所示:
该图展示了Node.js的整体运行原理。 Node.js 从左到右、从上到下分为四层:应用层、V8 引擎层、Node API 层、LIBUV 层。
无论是Linux平台还是Windows平台,Node.js内部都使用线程池来完成异步I/O操作,LIBUV统一了不同平台差异的调用。所以,Node.js 中的单线程仅意味着 JavaScript 在单线程中运行,而不是 Node.js 整体是单线程的。
Node.js 实现异步的核心在于事件。也就是说,它将每个任务视为一个事件,然后通过事件循环来模拟异步效果。为了更具体、更清楚地理解和接受这个事实,我们下面用伪代码来描述它的工作原理。
由于它是一个队列,所以它是先进先出(FIFO)的数据结构。我们用JS数组来描述,如下:
/** * Define the event queue * Enqueue: push() * Dequeue: shift() * Empty queue: length === 0 */ let globalEventQueue = [];
我们用数组来模拟队列结构:数组的第一个元素是队列的头,最后一个元素是队列的尾部。 push() 在队列尾部插入一个元素,shift() 从队列头部删除一个元素。这样就实现了一个简单的事件队列。
每个请求都会被拦截并进入处理函数,如下图:
/** * Receive user requests * Every request will enter this function * Pass parameters request and response */ function processHttpRequest(request, response) { // Define an event object let event = createEvent({ params: request.params, // Pass request parameters result: null, // Store request results callback: function() {} // Specify a callback function }); // Add the event to the end of the queue globalEventQueue.push(event); }
该函数只是将用户的请求封装为一个事件,放入队列中,然后继续接收其他请求。
当主线程空闲时,开始循环事件队列。所以我们需要定义一个函数来循环事件队列:
/** * The main body of the event loop, executed by the main thread when appropriate * Loop through the event queue * Handle non-IO tasks * Handle IO tasks * Execute callbacks and return to the upper layer */ function eventLoop() { // If the queue is not empty, continue to loop while (this.globalEventQueue.length > 0) { // Take an event from the head of the queue let event = this.globalEventQueue.shift(); // If it's a time-consuming task if (isIOTask(event)) { // Take a thread from the thread pool let thread = getThreadFromThreadPool(); // Hand it over to the thread to handle thread.handleIOTask(event); } else { // After handling non-time-consuming tasks, directly return the result let result = handleEvent(event); // Finally, return to V8 through the callback function, and then V8 returns to the application event.callback.call(null, result); } } }
主线程持续监听事件队列。对于I/O任务,它交给线程池处理,对于非I/O任务,它自己处理并返回。
线程池收到任务后,直接处理I/O操作,比如读取数据库:
/** * Define the event queue * Enqueue: push() * Dequeue: shift() * Empty queue: length === 0 */ let globalEventQueue = [];
当I/O任务完成时,执行回调,将请求结果存储到事件中,并将事件放回到队列中,等待循环。最后,当前线程被释放。当主线程再次循环到该事件时,直接处理。
总结上面的过程,我们发现Node.js只使用一个主线程来接收请求。接收到请求后,并不直接处理,而是将其放入事件队列中,然后继续接收其他请求。当它空闲时,它通过事件循环处理这些事件,从而达到异步的效果。当然,对于I/O任务,还是需要依赖系统层面的线程池来处理。
因此,我们可以简单地理解为 Node.js 本身是一个多线程平台,但它在单线程中处理 JavaScript 级别的任务。
到现在为止,我们应该对 Node.js 的单线程模型有了一个简单清晰的认识。它通过事件驱动模型实现高并发和异步I/O。然而,Node.js 也有不擅长的地方。
如上所述,对于I/O任务,Node.js将其交给线程池进行异步处理,高效且简单。因此,Node.js 适合处理 I/O 密集型任务。但并非所有任务都是 I/O 密集型的。当遇到CPU密集型任务,即只依赖CPU计算的操作,如数据加解密(node.bcrypt.js)、数据压缩解压(node-tar)时,Node.js会一一处理一。如果前面的任务没有完成,后面的任务就只能等待。如下图所示:
在事件队列中,如果前面的CPU计算任务没有完成,后面的任务就会被阻塞,导致响应缓慢。如果操作系统是单核的话,可能还可以忍受。但现在大多数服务器都是多CPU或多核的,而Node.js只有一个EventLoop,也就是说只占用一个CPU核。当 Node.js 被 CPU 密集型任务占用,导致其他任务被阻塞时,仍然有 CPU 核心闲置,造成资源浪费。
所以,Node.js 不适合 CPU 密集型任务。
最后介绍一下最适合部署Node.js服务的平台:Leapcell。
在文档中探索更多内容!
Leapcell Twitter:https://x.com/LeapcellHQ
以上是Node.js 事件循环内部:深入探究的详细内容。更多信息请关注PHP中文网其他相关文章!