JavaScript 事件循环和Node 事件循环

2,281 阅读8分钟

JavaScript 是单线程的

首先来说下JS语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出WebWorker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,不能获取document,window。所以,这个新标准并没有改变JavaScript单线程的本质。

这里所谓的单线程指的是主线程是单线程的。单线程特点是节约了内存,并且不需要在切换执行上下文。;而且单线程不需要管锁的问题。

同步任务/异步任务

  • 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
  • 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

异步事件包括 :IO事件/鼠标事件/页面滚动事件/setInterval/setTimeout/ajax请求等,这些指定了回调函数的事件。

任务队列

"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。

经典的浏览器EventLoop图解

主线程运行的时候,产生堆heap和栈stack

(1)所有同步执行的任务都在主线程上执行,形成一个执行栈;

(2)主线程之外,还有一个“任务队列”(对应图中callback queue),当异步任务有了结果,就在任务队列中放置一个事件(回调函数);

(3)当执行栈中的所有同步任务执行完毕,系统就会读取任务队列,将队列中队头的事件放到执行栈中执行;

(4)主线程不断重复上面的第三步。

setTimeout/setInterval定时器

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。

需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。

浏览器事件循环

浏览器并不是单线程执行的,它们有JavaScript的执行线程、UI节点的渲染线程,图片等资源的加载线程,以及Ajax请求线程等。在Chrome设计中,为了防止因一个Tab window的奔溃而影响整个浏览器,它的每一个Tab被设计为一个进程;在Chrome设计中存在很多的进程,并利用进程间通讯来完成它们之间的同步,因此这也是Chrome快速的法宝之一。这一点在任务管理器进程中就可以查看

页面渲染

GUI渲染线程负责渲染浏览器界面,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。但需要注意 GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

我们已经知道了事件循环是如何执行的,事件循环器会不停的检查事件队列,如果不为空,则取出队首压入执行栈执行。当一个任务执行完毕之后,事件循环器又会继续不停的检查事件队列,不过在这期间,浏览器会对页面进行渲染。也就是任务队列的每一个任务结束之后,如果需要,浏览器就会对页面进行渲染GUI渲染线程与JS引擎是互斥的

浏览器中宏任务/微任务

浏览器中,js引擎又将异步任务细分为

  • 微任务(少数事件):then(promise)、messageChannel、mutationObersve(不兼容)
  • 宏任务(大部分事件):setTimeout setInterval、setImmediate、I/O等各种事件(比如鼠标单击事件)的回调函数

只需要记住微任务有哪些事件,其他基本上都是宏任务

“微任务”在本轮Event Loop的所有任务结束后执行,即栈清空后,先执行微任务,再检查任务队列,继续压入栈中执行

console.log(1)
setTimeout(function(){
    console.log(2)
},0)
let promise = new Promise(function(resolve,reject){
    console.log(3)
    resolve(4)
}).then(function(data){
    console.log(data)
})
console.log(5)

上面执行结果为:13542

  • 1 没有问题
  • 3 是因为promise的executor是立即执行的
  • 5 console.log(5)是同步执行的,执行到这栈中任务都已执行完
  • 此时检查是否有微任务,这里then就是,所以打印4,
  • 继续检查宏任务,打印出2

Node事件循环

Node.js是一个基于Chrome V8引擎的JavaScript运行环境,并不是一门语言,是让js运行在后端的运行时,并且不包括JavaScript全集,因为服务端不需要DOM和BOM。
Node.js是单进程单线程应用程序,但是通过事件和回调支持并发,所以性能非常高。

Node.js是异步非阻塞式,是单线程的EventLoop,但是它的运行机制不同于浏览器环境。先来看张图理解node是如何工作的

1.我们写的js代码会交给v8引擎进行处理
2.解析后的代码中,可能会调用NodeApi
3.libuv库负责NodeApi的执行,libuv通过阻塞i/o和多线程实现了异步io;它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
4.V8引擎再将结果返回给用户。

Node中同样分微任务和宏任务
微任务 :process.nextTick,原生Promise(有些实现的promise将then方法放到了宏任务中),Object.observe(已废弃),MutationObserver
宏任务 :setTimeout setInterval setImmediate I/O

Node事件环

如图所示,每个方框代表事件循环中的一个阶段,每个阶段都有一个需要执行的回调函数的先入先出(FIFO)队列。同时,每个阶段都是特殊的,基本上,当事件循环进行到某个阶段时,会执行该阶段特有的操作,然后执行该阶段队列中的回调,直到队列空了或者达到了执行次数限制。这时候,事件循环会进入下一个阶段,循环往复。

但是微任务总是在当前"执行栈"的尾部--到--下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。

也就是说从执行栈到timers任务队列之前,会检查微任务队列,timers任务送入执行栈,执行栈清空后,又检查I/O队列,这之前,也会检查微任务队列,以此类推。

setTimeout和setImmediate的执行顺序

可以看到setTimeout和setImmediate在不同的任务队列中,他们执行的先后顺序,取决于node的执行时间,也就是可能setTimeout到时间执行时,Node正在执行setImmediate这一队列。可以用下面这段代码,反复执行测试一下,打印顺序是不固定的

setTimeout(function(){
    console.log('timeout');
})
setImmediate(function(){
    console.log('immediate')
});

但是这种情况,他们的执行顺序就是固定的了,如下:

let fs = require('fs');
fs.readFile('./1.log',function(){ //该方法会进入poll阶段,
    console.log('fs');
    setTimeout(function(){
        console.log('timeout');
    })
    setImmediate(function(){
        console.log('setImmediate')
    })
});

读取文件的回调属于poll阶段的任务,如图中所示,poll的下一个任务队列是check阶段,所以这里setImmediate 一定会比setTimeout先执行

nextTick比then执行的快,这个是定好的

Promise.resolve().then(function(){
    console.log('then')
})
process.nextTick(function(){
    console.log('nextTick')
});

不论执行多少次,都是nextTick先输出

下面这个测试用例,帮助理解执行顺序的问题

setImmediate(function(){
    console.log(1);
    process.nextTick(function(){
        console.log(4)
    }) 
})
process.nextTick(function(){
    console.log(2)
    setImmediate(function(){
        console.log(3);
    })
})
//2 1 3 4

代码先按顺序同步执行,将第一个setImmediate的回调放入对应的任务队列,将nextTick放入微任务队列
从同步执行栈到事件队列,先检查微任务,所以nextTick先执行,打印2,此时遇到setImmediate,将回调放入对应的任务队列
已经没有微任务了,timer队列也没有任务,直到check阶段,将第一个进入任务队列的setImmediate的回调事件取出,在栈中执行,打印1,此时遇到nextTick,放入微任务队列
此时check任务队列还有一个setImmediate回调事件,再执行,打印3
check队列清空,该检查微任务,发现有一个任务,即打印4

setImmediate和process.nextTick还需要深入理解,这里先将一些规则列出

setImmediate总是将事件注册到下一轮Event Loop,即指定在下次"事件循环"触发
process.nextTick指定的回调函数是在本次"事件循环"触发
多个process.nextTick语句总是在当前"执行栈"一次执行完
多个setImmediate可能则需要多次loop才能执行完
process.nextTick总是比setImmediate发生的早

参考资料

  1. www.ruanyifeng.com/blog/2014/1…
  2. www.cnblogs.com/whitewolf/p…
  3. www.runoob.com/nodejs/node…