什么是Event Loop
Event Loop是一个程序结构,用于等待和发送消息和事件,是计算机系统的一种运行机制。JavaScript语言就采用这种机制,来解决单线程运行带来的一些问题。
那么首先要了解一下JavaScript的单线程的原因
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就要一直等着。很多时候CPU是闲着的,因为IO设备很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。然而这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
- 同步任务,指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务,指的是不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
由于I/O操作很慢,这个线程的大部分运行时间都在空等I/O操作的返回结果。这种运行方式称为"同步模式"(synchronous I/O)或"堵塞模式"(blocking I/O)。Event Loop就是为了解决这个问题而提出的。
Event Loop
简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")。
每个阶段的简单概要:
timers
: 执行setTimeout() 和 setInterval() 预先设定的回调函数。
I/O callbacks
:大部分执行都是timers 阶段或是setImmediate() 预先设定的并且出现异常的回调函数事件。
idle
,prepare
:nodejs 内部函数调用。
poll
: 搜寻I/O事件,nodejs进程在这个阶段会选择在该阶段适当的阻塞一段时间。
check
: setImmediate() 函数会在这个阶段执行。
close callbacks
: 执行一些诸如关闭事件的回调函数,如socket.on('close', ...) 。
每个阶段都有一个先进先出的回调函数队列。只有一个阶段的回调函数队列清空了,该执行的回调函数都执行了,事件循环才会进入下一个阶段。
(1)timers
这个是定时器阶段,处理setTimeout()
和setInterval()
的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。
(2)I/O callbacks
除了以下操作的回调函数,其他的回调函数都在这个阶段执行。
setTimeout()
和setInterval()
的回调函数 setImmediate()
的回调函数 用于关闭请求的回调函数,比如socket.on('close', ...)
(3)idle, prepare
该阶段只供 libuv 内部调用,这里可以忽略。
(4)Poll
这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
(5)check
该阶段执行setImmediate()
的回调函数。
(6)close callbacks
该阶段执行关闭请求的回调函数,比如socket.on('close', ...)
。
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。
计时器
下面是来自官方文档的一个示例。
const fs = require('fs');const timeoutScheduled = Date.now();// 异步任务一:100ms 后执行的定时器setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms`);}, 100);// 异步任务二:文件读取后,有一个 200ms 的回调函数fs.readFile('test.js', () => { const startCallback = Date.now(); while (Date.now() - startCallback < 200) { // 什么也不做 }});复制代码
上面代码有两个异步任务,一个是 100ms 后执行的定时器,一个是文件读取,它的回调函数需要 200ms。运行结果是什么?
脚本进入第一轮事件循环以后,没有到期的定时器,也没有已经可以执行的 I/O 回调函数,所以会进入 Poll 阶段,等待内核返回文件读取的结果。由于读取小文件一般不会超过 100ms,所以在定时器到期之前,Poll 阶段就会得到结果,因此就会继续往下执行。
第二轮事件循环,依然没有到期的定时器,但是已经有了可以执行的 I/O 回调函数,所以会进入 I/O callbacks 阶段,执行fs.readFile的回调函数。这个回调函数需要 200ms,也就是说,在它执行到一半的时候,100ms 的定时器就会到期。但是,必须等到这个回调函数执行完,才会离开这个阶段。
第三轮事件循环,已经有了到期的定时器,所以会在 timers 阶段执行定时器。最后输出结果大概是200多毫秒。
setTimeout 和 setImmediate
由于setTimeout
在 timers 阶段执行,而setImmediate
在 check 阶段执行。所以,setTimeout
会早于setImmediate
完成。
setTimeout(() => console.log(1));setImmediate(() => console.log(2));复制代码
上面代码应该先输出1,再输出2,但是实际执行的时候,结果却是不确定,有时还会先输出2,再输出1。
这是因为setTimeout
的第二个参数默认为0。但是实际上,Node 做不到0毫秒,最少也需要1毫秒,根据官方文档,第二个参数的取值范围在1毫秒到2147483647毫秒之间。也就是说,setTimeout(f, 0)
等同于setTimeout(f, 1)
。
实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate
的回调函数。
但是,下面的代码一定是先输出2,再输出1。
const fs = require('fs');fs.readFile('test.js', () => { setTimeout(() => console.log(1)); setImmediate(() => console.log(2));});复制代码
上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate
才会早于setTimeout
执行。
根据上图,Node.js的运行机制如下。
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。
除了setTimeout
和setInterval
这两个方法,Node.js还提供了另外两个与"任务队列"有关的方法:process.nextTick
和setImmediate
。它们可以帮助我们加深对"任务队列"的理解。
process.nextTick
方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate
方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)
很像。