事件循环
下面的两句话是Node.js官网对Node.js的简介。这两句话应该是我们看到的最多的了。
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。
Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。
简短两句句话就Node.js介绍得非常清楚了。但是很多人对事件驱动和非阻塞I/O模型很模糊。
简单来说就是:
- 事件驱动:是指Node.js把每一个任务当成事件来处理。
- 非阻塞I/O:是指Node.js遇到I/O任务时,会从线程池调度单独的线程处理I/O操作,不会阻塞主线程。
异步非阻塞I/O
Node.js JavaScript 代码运行在单个线程上。 每次只处理一件事。
但它大大简化了并发编程的方式,我们只需要记住代码会在单个事件循环上运行,并避免任何可能阻塞线程的事情。
一般来说,高并发的解决方案就是提供多线程模型。那么 Node.js 如何通过单线程来实现高并发和异步 I/O?
答案是Node.js非常重要的事件循环
什么是事件循环
我们先来看一张很常见的图(下图),它展示了Node.js的运行原理
从图中我们可以看出Node.js 被分为了四层,分别是应用层、V8引擎层、Node API层 和 LIBUV层。
- 应用层:即JavaScript 交互层,常见的就是Node.js的模块,比如 http,fs
- V8引擎层: 即利用 V8 引擎来解析JavaScript 语法,进而和下层 API 交互
- NodeAPI层: 为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互 。
- LIBUV层: 是跨平台的底层封装,实现了事件循环、文件操作等,是 Node.js 实现异步的核心 。
EVENT LOOP即事件循环,从上图可以看出EVENT LOOP属于LIBUV层。
事件循环过程分为如下几个步骤:
- Node.js在主线程里维护了一个事件队列(EVENT QUEUE)
- 当接收到请求后,就将该请求作为一个事件放入这个队列中
- 然后继续接收其他请求。
- 当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件。
- 处理事件分以下两种情况:
- 如果事件是非I/O任务,就亲自处理,并通过回调函数返回到上层调用
- 如果事件是I/O任务
① 从线程池中拿出一个线程处理事件,并指定回调函数
②然后继续循环队列中的其他事件
③当线程中的I/O任务完成后,就执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环
④当主线程再次循环到该事件时,就直接处理并返回给上层调用。
通过以上事件循环过程,我们可以得知:
Node.js内部是通过LIBUV层的线程池来完成异步I/O操作的,因此,Node.js的单线程仅仅只JavaScript代码运行在单线程中,而并非Node.js是单线程,这就是为什么Node.js中JavaScript运行在单线程中,却能够通过事件驱动模型实现高并发和异步I/O。
接下来我会以代码示例来简单介绍事件循环的过程
栈溢出
栈是连续的存储结构,并且栈的结构比较小,要在内存中寻找一份连续的存储结构是不容易的。所以当调用层级很深时,调用栈内存在的函数执行上下文太多,就会造成栈溢出。
比如下面的代码就会造成栈溢出
function foo() {
foo();
}
foo();
代码执行过程以及调用栈结构如下图所示:
在 foo 函数中调用 foo 函数,就要不停地将其执行上下文压入栈中,无法结束。造成栈溢出。
下面我将介绍使用setTimeout函数解决这个栈溢出的问题,也就会用到事件循环中的消息队列
消息队列(Event Loop)
将栈溢出的代码加上setTimeout函数,代码如下:
function foo() {
console.log('foo');
setTimeout(foo, 0);
}
foo();
执行后,一直打印foo,可以看到没有出现栈溢出的问题
我先将该代码在消息队列、主线程、调用栈的执行流程画下,你看执行流程,再看结论。
-
压入全局执行上下文
-
执行foo函数,压入foo函数执行上下文
-
执行setTimeout,setTimeout会将foo函数封装成一个新的宏任务,并将其添加到消息队列中,等待执行
-
foo函数执行结束,从调用栈中弹出其执行上下文,调用栈被清空
-
主线程从消息队列中取出待执行的回调函数foo并执行
总结:
由于事件循环赋予调用堆栈优先级,即首先处理调用堆栈中找到的所有东西,一旦其中没有任何东西,便开始处理消息队列中的东西。setTimeout的本质是将同步函数调用改为异步函数调用,即将回调函数封装成宏任务,并将其添加消息队列中,这样就不用一直占用主线程的调用栈,所以不会出现栈溢出的问题。
考察你学会了没有
那么你再看下下面的代码执行结果是什么?
自己先想想输出结果是什么,然后再看答案
var bar = () => console.log('bar')
var baz = () => console.log('baz')
var foo = () => {
console.log('foo')
setTimeout(bar, 0)
baz()
}
foo()
该代码打印如下:
foo
baz
bar
你明白了吗?不明白那就在看一遍上面讲的消息队列
ES6作业队列
通过上面一系列的执行流程图,我们知道了setTimeout是添加到消息队列的后面,如果消息队列中的任务过多,那可能会导致要执行的操作延迟。
那有没有办法让其任务添加到消息队列的前面呢?
ECMAScript 2015 引入了作业队列的概念,Promise 使用了该队列。 这种方式会尽快地执行异步函数。
比如下面的代码:
var bar = () => console.log('bar')
var baz = () => console.log('baz')
var foo = () => {
console.log('foo')
setTimeout(bar, 0)
new Promise((resolve, reject) =>
resolve('应该在 baz 之后、bar 之前')
).then(resolve => console.log(resolve))
baz()
}
foo()
打印结果如下:
foo
baz
应该在 baz 之后、bar 之前
bar
这是 Promise(以及基于Promise 构建的 async/await)与通过 setTimeout() 或其他平台 API 的普通的旧异步函数之间的巨大区别。
参考
支持🤟
🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟
- 我会持续编写文章,保持每周至少一篇文章。💪
- 有时候编写一篇文章需要大量时间。💪
- 您只需一秒即可完成【点赞👍或关注❤️】。💪
- 您的支持将给与我更大的动力。💪
🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟🤟