JS的事件轮询(Event Loop)机制

11,199 阅读14分钟

前言

JS单线程、JS的事件循环(Event Loop)、执行栈、任务队列(消息队列)、主线程、宏队列(macrotask)、微队列(microtask),前端er相信很多人对这些词并不陌生,即便对js的api熟能生巧,但是却并不理解这些机制流程的话,那可能JS的提升很难了,这里也是属于提升JS的一个分水岭,在介绍这些概念之前,我们先思考几个非常经典的面试题,答案最后公布,看完这篇文章,或许就能够焕然大悟:透过现象看本质! 注:本章所有环境都是基于浏览器环境,暂不考虑node环境; 题目一:

setTimeout(() => {
    console.log(1);
}, 0);

new Promise((resolve) => {
    console.log(2);
    resolve();
}).then(() => {
    console.log(3);
});

console.log(4);
// 输出最后的结果

题目二:

setTimeout(() => {
    console.log(1);
}, 0);

new Promise((resolve) => {
    console.log(2);
    setTimeout(() => {
        console.log(5);
    }, 0);
    resolve();
}).then(() => {
    console.log(3);
});

console.log(4);
// 输出最后的结果

题目三:

setTimeout(() => {
    console.log(1);
}, 0);
new Promise((resolve,reject) =>{
    console.log(2)
    resolve(3)
}).then((val) =>{
    console.log(val);
})
console.log(4);
// 输出最后的结果

题目四:

let a = () => {
  setTimeout(() => {
    console.log('任务队列函数1')
  }, 0)
  for (let i = 0; i < 5000; i++) {
    console.log('a的for循环')
  }
  console.log('a事件执行完')
}

let b = () => {
  setTimeout(() => {
    console.log('任务队列函数2')
  }, 0)
  for (let i = 0; i < 5000; i++) {
    console.log('b的for循环')
  }
  console.log('b事件执行完')
}

let c = () => {
  setTimeout(() => {
    console.log('任务队列函数3')
  }, 0)
  for (let i = 0; i < 5000; i++) {
    console.log('c的for循环')
  }
  console.log('c事件执行完')
}

a();
b();
c();
// 输出最后的结果

JS单线程

JavaScript为什么是单线程,难道不能实现为多线程吗?

进程与任务

一般情况下,一个进程一次只能执行一个任务,如果有很多任务需要执行,不外乎三种解决方法:

(1)排队:因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务。 (2)新建进程:使用fork命令,为每个任务新建一个进程。 (3)新建线程:因为进程太耗费资源,所以如今的程序往往允许一个进程包含多个线程,由线程去完成任务。 它是一种单线程语言,所有任务都在一个线程上完成,即采用上面的第一种方法。一旦遇到大量任务或者遇到一个耗时的任务,网页就会出现"假死",因为JavaScript停不下来,也就无法响应用户的行为。

单线程

JavaScript从诞生起就是单线程,这跟历史有关系。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript为一种单线程语言。(Worker API可以实现多线程,但是JavaScript本身始终是单线程的。)

如果某个任务很耗时,比如涉及很多I/O(输入/输出)操作,那么线程的运行大概是下面的样子。

JS单线程
上图的绿色部分是程序的运行时间,红色部分是等待时间。可以看到,由于I/O操作很慢,所以这个线程的大部分运行时间都在空等I/O操作的返回结果。这种运行方式称为"同步模式"(synchronous I/O)或"堵塞模式"(blocking I/O)。

如果采用多线程,同时运行多个任务,那很可能就是下面这样。

JS单线程

上图表明,多线程不仅占用多倍的系统资源,也闲置多倍的资源,这显然不合理。

其实JavaScript单线程是指浏览器在解释和执行javascript代码时只有一个线程,即JS引擎线程,浏览器自身还会提供其他线程来支持这些异步方法,浏览器的渲染线程大概有一下几种:

JS引擎线程 事件触发线程 定时触发器线程 异步http请求线程 GUI渲染线程 ...

浏览器环境

js作为主要运行在浏览器的脚本语言,js主要用途之一是操作DOM。 在js高程中举过一个栗子,如果js同时有两个线程,同时对同一个dom进行操作,这时浏览器应该听哪个线程的,如何判断优先级? 为了避免这种问题,js必须是一门单线程语言,并且在未来这个特点也不会改变。

解决的问题

Event Loop就是为了解决这个问题而提出的。

"Event Loop是一个程序结构,用于等待和发送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)"

简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")。

JS单线程
上图主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。等到I/O程序完成操作,Event Loop线程再把结果返回主线程。主线程就调用事先设定的回调函数,完成整个任务。

可以看到,由于多出了橙色的空闲时间,所以主线程得以运行更多的任务,这就提高了效率。这种运行方式称为"异步模式"(asynchronous I/O)或"非堵塞模式"(non-blocking mode)。

这正是JavaScript语言的运行方式。单线程模型虽然对JavaScript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果部署得好,JavaScript程序是不会出现堵塞的,这就是为什么node.js平台可以用很少的资源,应付大流量访问的原因。

执行栈与任务队列

因为js是单线程语言,当遇到异步任务(如ajax操作等)时,不可能一直等待异步完成,再继续往下执行,在这期间浏览器是空闲状态,显而易见这会导致巨大的资源浪费。

执行栈

当执行某个函数、用户点击一次鼠标,Ajax完成,一个图片加载完成等事件发生时,只要指定过回调函数,这些事件发生时就会进入任务队列中,等待主线程读取,遵循先进先出原则。

执行任务队列中的某个任务,这个被执行的任务就称为执行栈。

主线程

要明确的一点是,主线程跟执行栈是不同概念,主线程规定现在执行执行栈中的哪个事件。 **主线程循环:**即主线程会不停的从执行栈中读取事件,会执行完所有栈中的同步代码。 当遇到一个异步事件后,并不会一直等待异步事件返回结果,而是会将这个事件挂在与执行栈不同的队列中,我们称之为任务队列(Task Queue)。 当主线程将执行栈中所有的代码执行完之后,主线程将会去查看任务队列是否有任务。如果有,那么主线程会依次执行那些任务队列中的回调函数。

js异步执行的运行机制

1)所有任务都在主线程上执行,形成一个执行栈。 2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。 3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列"。那些对应的异步任务,结束等待状态,进入执行栈并开始执行。

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

浏览器事件机制

浏览器在执行js代码过程中会维护一个执行栈,每个方法都会进栈执行之后然后出栈(FIFO)。与此同时,浏览器又维护了一个消息队列,所有的异步方法,在执行结束后都会将回调方法塞入消息队列中,当所有执行栈中的任务全部执行完毕后,浏览器开始往消息队列寻找任务,先进入消息队列的任务先执行。

浏览器事件机制

宏任务和微任务

那么如果两个不同种类的异步任务执行后,哪个会先执行?就像开头提到的面试题,setTimeout和promise哪个会先执行?这时候要提到概念:宏任务和微任务。 概念如下:

**宏任务(Macrotasks):**js同步执行的代码块,setTimeout、setInterval、XMLHttprequest、setImmediate、I/O、UI rendering等。 **微任务(Microtasks):**promise、process.nextTick(node环境)、Object.observe, MutationObserver等。

执行栈中执行的任务都是宏任务,当宏任务遇到Promise的时候会创建微任务,当Promise状态fullfill的时候塞入微任务队列。在一次宏任务完成后,会检查微任务队列有没有需要执行的任务,有的话按顺序执行微任务队列中所有的任务。之后再开始执行下一次宏任务。具体步骤:

(1)执行主代码块 (2)若遇到Promise,把then之后的内容放进微任务队列 (3)一次宏任务执行完成,检查微任务队列有无任务 (4)有的话执行所有微任务 (5)执行完毕后,开始下一次宏任务。

如何区分宏任务和微任务呢?划分的标准是什么?

宏任务本质:参与了事件循环的任务。

回到 Chromium 中,需要处理的消息主要分成了三类:

Chromium 自定义消息 Socket 或者文件等 IO 消息 UI 相关的消息

  1. 与平台无关的消息,例如 setTimeout 的定时器就是属于这个
  2. Chromium 的 IO 操作是基于 libevent 实现,它本身也是一个事件驱动的库
  3. UI 相关的其实属于 blink 渲染引擎过来的消息,例如各种 DOM 的事件 其实与 JavaScript 的引擎无关,都是在 Chromium 实现的。

微任务本质:直接在 Javascript 引擎中的执行的,没有参与事件循环的任务。

(1)是个内存回收的清理任务,使用过 Java 的童鞋应该都很熟悉,只是在 JavaScript 这是V8内部调用的 (2)就是普通的回调,MutationObserver 也是这一类 (3)Callable (4)包括 Fullfiled 和 Rejected 也就是 Promise 的完成和失败 (5)Thenable 对象的处理任务

宏任务,微任务的优先级

promise是在当前脚本代码执行完后,立刻执行的,它并没有参与事件循环,所以它的优先级是高于 setTimeout。 宏任务和微任务的总结: 宏任务 Macrotasks 就是参与了事件循环的异步任务。 微任务 Microtasks 就是没有参与事件循环的“异步”任务。

执行顺序

1、先执行主线程 2、遇到宏队列(macrotask)放到宏队列(macrotask) 3、遇到微队列(microtask)放到微队列(microtask) 4、主线程执行完毕 5、执行微队列(microtask),微队列(microtask)执行完毕 6、执行一次宏队列(macrotask)中的一个任务,执行完毕 7、执行微队列(microtask),执行完毕 8、依次循环。。。

Event Loop(事件循环)

  js是单线程的,执行较长的js时候,页面会卡死,无法响应,但是所有的操作都会被记住到另外的队列。比如:点击了一个元素,不会立刻的执行,但是等到js加载完毕后就会执行刚才点击的操作,能够知道有一个队列记录了所有有待执行的操作,这个队列分为微观和宏观。微观会比宏观执行得更快。

  event loop它最主要是分三部分:主线程、宏队列(macrotask)、微队列(microtask) js的任务队列分为同步任务和异步任务,所有的同步任务都是在主线程里执行的,异步任务可能会在macrotask或者microtask里面。

  事件循环就是多线程的一种工作方式,Chrome里面是使用了共享的task_runner对象给自己和其它线程post task过来存起来,用一个死循环不断地取出task执行,或者进入休眠等待被唤醒。Mac的Chrome渲染线程和浏览器线程还借助了Mac的sdk Cococa的NSRunLoop来做为UI事件的消息源。Chrome的多进程通信(不同进程的IO线程的本地socket通信)借助了libevent的事件循环,并加入了到了主消息循环里面。

称为事件循环的原因大多来源于源码:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

宏任务 > 所有微任务 > 宏任务,如下图所示:

事件循环

事件循环中,每一次循环称为 tick, 每一次tick的任务如下:

执行栈选择最先进入队列的宏任务(通常是script整体代码),如果有则执行 检查是否存在 Microtask,如果存在则不停的执行,直至清空 microtask 队列 更新render(每一次事件循环,浏览器都可能会去更新渲染) 重复以上步骤

Event Loop整体流程

事件循环

题目解析

题目一:

答案:2 4 3 1

(1)setTimeout丢给浏览器的异步线程处理,因为时间是0,马上放入消息队列 (2)new Promise里面的console.log(2)加入执行栈,并执行,然后退出 (3)直接resolve,then后面的内容加入微任务队列 (4)console.log(4)加入执行栈,执行完成后退出 (5)检查微任务队列,发现有任务,执行console.log(3) (6)发现消息队列有任务,执行下一次宏任务console.log(1)

题目二:

答案:2 4 3 1 5

(1)setTimeout丢给浏览器的异步线程处理,因为时间是0,马上放入消息队列 (2)new Promise里面的console.log(2)加入执行栈,并执行 (3)setTimeout给浏览器的异步线程处理,因为时间是0,马上放入消息队列,然后退出 (4)直接resolve,then后面的内容加入微任务队列 (5)console.log(4)加入执行栈,执行完成后退出 (6)检查微任务队列,发现有任务,执行console.log(3) (7)发现消息队列有任务,执行下一次宏任务console.log(1) (8)发现消息队列有任务,执行下一次宏任务console.log(5)

题目三:

答案:2 4 3 1

(1)先执行script同步代码: 先执行new Promise中的console.log(2),then后面的不执行属于微任务然后执行console.log(4) (2)执行完script宏任务后,执行微任务,console.log(3),没有其他微任务了 (3)执行另一个宏任务,定时器,console.log(1)

题目四:

答案:
(5000)a的for循环
a事件执行完
(5000)b的for循环
b事件执行完
(5000)c的for循环
c事件执行完
任务队列函数1
任务队列函数2
任务队列函数3

结果是当a、b、c函数都执行完成之后,三个setTimeout才会依次执行

node环境中的事件机制

node环境中的事件机制要比浏览器复杂很多,node的事件轮询有阶段的概念。每个阶段切换的时候执行,process.nextTick之类的所有微任务。

node环境中的事件机制

timer阶段

执行所有的时间已经到达的计时事件

peding callbacks阶段

这个阶段将执行所有上一次poll阶段没有执行的I/O操作callback,一般是报错。

idle.prepare

可以忽略

poll阶段

这个阶段特别复杂

阻塞等到所有I/O操作,执行所有的callback. 所有I/O回调执行完,检查是否有到时的timer,有的话回到timer阶段 没有timer的话,进入check阶段.

check阶段

执行setImmediate

close callbacks阶段

执行所有close回调事件,例如socket断开。