Event Loop ( 事件轮询 ) 机制在 JS 中非常常见,但是这个在浏览器中和 Node.js 中又有一些不同。本文会分别介绍一下。
一、单线程的 JS
众所周知, JavaScript 的一大特点是单线程,也就是同一个时间只能做一件事情。这主要是因为 JavaScript 最初是为了解决在客户端的用户交互的问题,如果 JavaScript 是多线程的,假想一下,我们又要执行增加一个 DOM 的操作,又要执行删除这个 DOM 的操作,那此时 JavaScript 究竟该怎么做?所以, JavaScript 只能是单线程操作。
二、主线程 和 任务队列
JavaScript 中是存在异步事件的,如 Ajax 。在初期很多人会把异步理解成类似多线程的编程模式,但是他们还是有一定的差别。接下来我会慢慢介绍,在介绍前我需要先讲几个概念,先看图示:
- 主线程 : 执行同步任务,形成一个执行栈
- 任务队列 : 存在于主线程之外,主要用于存放异步事件的结果
上图主要表达的是:
- 所有同步任务都在”主线程”上执行,形成一个执行栈
- “主线程”之外,存在一个”任务队列”。只要异步任务有了运行结果,就在”任务队列”中放置一个事件
- 一旦”主线程”的执行栈中所有的同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行
- “主线程”不断重复上面的第三步
只要主线程空了,就回去读取”任务队列”,这就是 JavaScript 的运行机制。这个过程会不断重复。
三、Event Loop
“任务队列”是一个事件的队列, I/O 设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。这些事件也就是回调函数。
异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
主线程从”任务队列”中读取事假,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。举例说明:1
2
3
4
5let xhr = new XMLHttpRequest();
xhr.open('GET',url);
xhr.onload = function(){}
xhr.onerroe = function(){}
xhr.send()
执行栈中的代码(同步任务)总是在读取”任务队列”(异步任务)之前执行。代码中,xhr.send() 是 Ajax 操作向服务器发送数据,这是一个异步任务,所以只有当前脚本的所有代码执行完,系统才会去读取”任务队列”。以上代码等价于:1
2
3
4
5let xhr = new XMLHttpRequest();
xhr.open('GET',url);
xhr.send()
xhr.onload = function(){}
xhr.onerroe = function(){}
回调部分(onload 和 onerror)会被放到任务队列,待主线程执行完再从任务队列中读取。
四、Node.js 的 Event Loop
先看图:
根据上图,Node.js 的运行机制如下:
- V8 引擎解析 JavaScript 脚本
- 解析后的代码,调用 Node API
- libv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop ,以异步的方式将任务的执行结果返回给 V8 引擎
- V8 引擎再将结果返回给用户
Node.js 的 Event Loop 就是用来使 JS 能够完成异步操作的,Node.js 借助的是系统完成的异步操作。举例说明,如果 Node.js 要读取一个文件,他不会自己去读取,而是通知操作系统去读取一个文件,操作系统读取完成后将通知 Node.js 文件读取完成并返回文件数据执行回调。Event Loop 是用来处理系统返回的文件信息以及执行回调操作的。
当 Node.js 启动时,会执行:
- 初始化 Event Loop
- 开始执行脚本。这些脚本可能会调用一些异步 API 、设定定时器或者调用 process.nextTick()
- 开始处理 Event Loop
1、Evevt Loop 的 6 个阶段
先看图示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
各阶段概览:
- timers 阶段:用于处理 setTimeout() 和 setInterval(),此阶段存在一个队列,队列中会存在多个定时回调
- I/O callbacks 阶段:这个阶段会执行一些操作系统的回调函数,比如 TCP 报错
- poll 阶段:又称轮询阶段,在这个阶段 Node.js 委托系统进行文件读取、http 服务等,在执行完异步操作后,回执行回调,poll 轮询的就是这个回调。
- check 只处理一个事情,就是 setImmediately
- close callbacks : 如果一个 socket 或者 handle 被突然关闭(比如 socket.destroy()),那么就会有一个 close 事件进入这个阶段
关于 event loop 阶段:
当 Event Loop 进入 poll 阶段,如果发现没有计时器,就会:
- 如果 poll 队列不是空的,event loop 就会依次执行队列里的回调函数,直到队列被清空或者到达 poll 阶段的时间上限
- 如果 poll 队列是空的,如果有 setImmediate() 任务,event loop 就结束 poll 阶段去往 check 阶段;如果没有 setImmediate() 任务,event loop 就会等待新的回调函数进入 poll 队列,并立即执行它
2、nextTick
不是 Event Loop 的一部分,而是在进入每一个阶段之前都会执行的方法。实际上,不管 event loop 当前处于哪个阶段,nextTick 队列都是在当前阶段后就被执行了。
process.nextTick 方法可以在当前”执行栈”的尾部—-下一次 Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与 setTimeout(fn, 0) 很像。
关于 Node.js 的 Event Loop 写的比较浅,主要是因为个人理解还是有些不到位。详细的请看下列出的三个参考链接。
参考: