JavaScript运行机制:event-loop

我们从javascript的单线程->任务队列->事件和回调函数->事件环,一步一步讲解javascript的执行机制。

一、JavaScript是单线程

JavaScript语言的特点是单线程,同一时间只能做一件事,JavaScript之所以是单线程,跟他的用途有关,作为浏览器脚本语言,JavaScript的主要用途是操作DOM,假如同时有两个js线程同时操作一个DOM,一个添加一个删除,浏览器就懵了吧。

二、任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后面的任务就必须等待。

IO读写操作一般是IO设备比较慢,这些任务耗时将很久,主线程将长时间处于等待结果返回的状态。

JavaScript语言的设计者意识到主线程完全可以不管处于等待队列中的任务,先去运行后面的任务,当处于等待中的任务返回结果,再继续执行此任务。

所有的任务可以分两种:同步任务、异步任务。同步任务是在主线程上排队的任务,一个任务执行完毕,才会执行下一个任务;异步任务是指,不进入主线程,而进入任务队列,只有任务队列通知主线程,某个异步任务可以执行了,主线程才会来执行此异步任务的回调。

异步执行的运行机制如下:

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

  • 主线程之外,还存在一个任务队列,只要异步任务有了运行结果,就在任务队列中放置一个事件;

  • 一旦执行栈中所有同步任务执行完毕,系统就会读取任务队列,哪个任务结束了等待状态,就进入执行栈,开始执行。

  • 主线程不断重复上一步。

三、事件和回调函数

"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。

所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

四、Event Loop

主线程从任务队列中读取事件,这个过程是循环不断的,这个运行机制被称为Event Loop(事件环)

五、先来看一下浏览器的事件环

主线程运行的时候,产生堆和栈,heap就是堆,堆里面是存的是各种对象和函数,stack是栈,var a=1就存储在栈内;dom事件,ajax请求,定时器等异步操作的回调会被放到任务队列callback queue中,这个队列时先进先出的顺序,主线程执行完毕之后会依次执行callback queue中的任务,当这些任务是等待结束状态,就进入主线程被执行。

1、浏览器的宏任务和微任务

异步任务中分“宏任务(macro-task)”和“微任务(micro-task)”机制 macrotask 和 microtask 两种分类。在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完,这就是浏览器中Event Loop对宏任务和微任务的执行机制。

两个类别的具体分类如下:

macro-task: script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering,mesageChannel micro-task: Promises(这里指浏览器实现的原生 Promise),Object.observe, MutationObserver 我们用下面一段代码来检验一下是否理解浏览器事件环:

setTimeout(function(){
    console.log('setTimeout1')
    Promise.resolve().then(()=>{
        console.log('then1');

    })
},0)

Promise.resolve().then(()=>{
    console.log('then2');
    Promise.resolve().then(()=>{
        console.log('then3');
    })
    setTimeout(function(){
        console.log('setTimeout2')
    },0)
})
复制代码

执行结果是then2 then3 setTimeout1 then1 setTimeout2 首先代码里面的setTimeout和Promise都是异步任务,js从上到下执行代码,分别将这两个异步任务放到了宏任务队列和微任务队列,主线程先到微任务队列中,所以先输出了then2,然后在微任务队列中有添加一个then3的promise任务,在宏任务中添加了一个setTimeout2的定时器任务,现在接着去执行微任务队列,所以输出了then3,开始执行第一个宏任务,输出setTimeout1,并且在微任务队列又天机then1的promise任务,所以转去执行微任务,输出then1,再去执行一个宏任务,就是之前放进去的setTimeout2.

六、node Event Loop

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。

node的代码虽然也是运行在V8引擎上的,但是他还有一个libuv库,专门处理异步i/o操作的,libuv库底层是靠多线程加阻塞I/O模拟实现的异步i/o实现的。 根据上图,Node.js的运行机制如下:

  • 1、我们写的js代码会交给v8引擎进行解析;
  • 2、代码中可能会调用nodeapi,node会交给libuv库处理
  • 3、libuv通过阻塞i/o和多线程实现了异步i/o
  • 4、通过事件驱动的方式,将结果放到事件队列中,最终交给我们的应用。

Node在进程启动时,便会创建一个类似于while(true)的循环,每执行一次循环体的过程被称为tick,中文翻译应该意为“滴答”,就像时钟一样,每滴答一下,就表示过去了1s。这个tick也有点这个意思,每循环一次,都表示本次tick结束,下次tick开始。每个tick开始之初,都会检查是否有事件需要处理,如果有,就取出事件及关联的callbak函数,如果存在有关联的callback函数,就把事件的结果作为参数调用这个callback函数执行。如果不在有事件处理,就退出进程。

那么在每个tick的过程中,如何判断是否有事件需要处理,先要引入一个概念,叫做“观察者”(watcher)。每一个事件循环都有一个或者多个观察者,判断是否有事件要处理的过程就是向这些观察者询问是否有需要处理的事件

Node的观察者有这样几种:

  • 定时器观察者:setTimeout,setInterval

  • idle观察者:顾名思义,就是早已等在那里的观察者,以后会说到的process.nextTick就属于这类

  • I/O观察者:顾名思义,就是I/O相关观察者,也就是I/O的回调事件,如网络,文件,数据库I/O等

  • check观察者:顾名思义,就是需要检查的观察者,后面会说到的setImmediate就属于这类

事件循环是一个典型的生产者/消费者模型。异步I/O,网络请求,setTimeout等都是典型的事件生产者,源源不断的为Node提供不同类型的事件,这些事件被传到对应的观察者那里,事件循环在每次tick时则从观察者那里取出事件并处理。

我们现在知道,JavaScript的异步I/O调用过程中,回调函数并不由我们开发者调用,事实上,在JavaScript发起调用到内核执行完I/O操作的过程中,存在一种中间产物,它叫做请求对象。这个请求对象会重新封装回调函数及参数,并做一些其他的处理。这个请求对象,会在异步事件完成时被调用,取出回调函数和参数,并传入执行结果进行回调。

组装好请求对象,送入I/O线程池等待执行,实际上只是完成了异步I/O的第一步;第二步则是异步I/O被线程池处理结束后的回调,也就是执行回调。

应该说,事件循环、观察者、请求对象、I/O线程池,这四者共同组成了Node异步I/O模型的基本要素。

不同类型的观察者,处理的优先级不同,idle观察者最先,I/O观察者其次,check观察者最后。

setTimeout()和setInterval()分别用于单次和多次运行任务,其创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick运行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,若超过则形成一个事件,其回调函数马上运行。

1、Node中宏任务和微任务

promise的then是微任务,process.nextTick()也是微任务,执行顺序是nextTick大于then

Promise.resolve().then(()=>{
    console.log('then');
})
process.nextTick(()=>{
    console.log('nextTick');
})
复制代码

上面代码先输出nextTick,后输出then 我们可以利用process.nextTick是异步任务,并且执行快的特点实现一些巧妙的解决办法。

class A{
    constructor(){
        this.arr=[];
        process.nextTick(()=>{   
            console.log(this.arr);
        })
    }
    add(val){
        this.arr.push(val);
    }
}
let a=new A();
a.add('123');
a.add('456');
复制代码

假如我们这里没有加process.nextTick的时候,这里打印出来的空数组,因为new实例的时候,就执行了constructor了,但是加了这个process.nextTick后,里面的代码会等同步代码先执行完毕后再执行,这是就已经拿到了数据。打印出['123','456']。

setTimeout(()=>{
    console.log('timeout1');
    process.nextTick(()=>{
        console.log('nextTick');
    })
},1000)
setTimeout(()=>{
    console.log('timeout2')
},1000)

复制代码

输出:timeout1 timeout2 nextTick 先清空时间队列,去执行下一个队列之前,先去清空微任务队列,也就是idle队列,所以顺序是这样的

setTimeout(()=>{
    console.log('timeout1');
    process.nextTick(()=>{
        console.log('nextTick1');
    })
},1000)
process.nextTick(()=>{
    setTimeout(()=>{
        console.log('timeout2')
    },1000)
    console.log('nextTick2');
})
复制代码

上面代码的执行顺序是不固定的,有时候

nextTick2 timeout1 nextTick1 timeout2

nextTick2 timeout1 timeout2 nextTick1

timer阶段的定时器是不准的,他是在超过规定时间后,一旦得到执行机会就立即执行。

上面代码,先走idle队列,先输出nextTick2是固定的,这时候定时器队列中放了两个定时器了。肯定是限制性timeout1,因为他是先放进去的,但是第一个定时器执行完毕后,第二个定时器不一定到结束时间,所以就会去执行idle队列,输出nextTick1,之后再执行timeout2。

第一个定时器是1000毫秒,但是第二个定时器的结束时间可能是1000.8ms,因为process。nextTick也需要执行时间。第一个定时器执行完之后,可能还没到1000.8ms,所以他就去清空了idle任务队列,如果第一个定时器执行完毕后,已经到了1000.8ms,那么肯定先执行第二个定时器。

所以定时器的时间在底层实现的时候是不一样的。

又一个例子

setImmediate(()=>{
    console.log('setImmediate');
})
setTimeout(()=>{
    console.log('setTimeout');
},0);  //规范是4ms,这里规定的时间0,在底层实现的时候不是0ms
复制代码

输出:谁都可能先输出

node执行栈执行时间如果是5ms,那么走到时间队列的时候,定时器时间就已经到了,所以先执行setTimeout,再执行setImmediate,但是也有可能node执行栈中代码执行了2ms,没到4ms,就会先走setImmediate,再走时间队列。

let fs=require('fs');
fs.readFile('./1.txt',function(){
    setImmediate(()=>{
        console.log('setImmediate');
    })
    setTimeout(()=>{
        console.log('setTimeout');
    },0);
})
复制代码

这段代码上来就会走poll轮询阶段,得到回调信息后,会先走check阶段,所以setImmediate永远先走。执行结果顺序永远一样

最后一个小测试

let fs=require('fs');
setImmediate(()=>{
    Promise.resolve().then(()=>{
        console.log('then1');
    })
},0)
Promise.resolve().then(()=>{
    console.log('then2');
})
fs.readFile('./1.txt',function(){
    process.nextTick(()=>{
        console.log('nextTick');
    })
    setImmediate(()=>{
        console.log('setImmediate');
    })
})
复制代码

答案在下面哦~

then2 then1 nextTick setImmediate

猜你喜欢

转载自juejin.im/post/5b6aea43f265da0f894b956b