浏览器事件循环EventLoop

看过很多关于JS Event Loop的文章和视频,在面试的时候,这个问题也逃不脱的,事件循环涉及的内容还是有点多的,这里自己在梳理下,以便加深自己的理解和记忆。

了解进程和线程

每当我们运行应用程序时,操作系统都会创建该应用程序的实例对象,该实例对象就是应用程序的进程,操作系统会按照进程为单位为应用程序分配资源,比如内存,这样程序才能够在计算机的操作系统中运行起来。
监视器详情
线程被包裹在进程之中,是进程中的实际运作单位,一条线程指的就是进程中的一个单一顺序的控制流。也就是说,应用程序要做的事情都存储在线程之中。可以这样认为,一条线程就是一个待办列表,供 CPU 执行。

浏览器就是多进程的
在浏览器中,当打开一个Tab页,就相当于创建了一个独立的浏览器进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

进程与线程的区别
1、进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位
2、一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线

浏览器渲染(Render)进程有哪些线程

浏览器内核是多线程的,一个浏览器通常由GUI 渲染线程JavaScript 引擎线程事件触发线程定时触发器线程异步 http 请求线程这几个常驻线程组成。
在内核控制下各线程相互配合,才能在浏览器窗口可视文档中输出我们能看到的图像。
下面就来说说这些线程的作用:
GUI 渲染线程

  • 主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等。
  • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
  • 该线程与 JS 引擎线程互斥,当执行 JS 引擎线程时,GUI 渲染会被挂起,GUI更新会被保存在一个队列中,当等到JS引擎空闲时才会去执行GUI更新。

JavaScript 引擎线程

  • 该线程也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
  • 浏览器无论什么时候只有一个js线程在运行js程序,JS是单线程的,由于每个Tab页面都是一个独立的进程,且每个页面浏览器渲染进程都会有一个JS引擎线程,因此每个Tab页面之间的JS引擎线程是互不影响的。
  • 主要负责处理 JavaScript 脚本,执行代码。也就是负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS 引擎线程的执行。
  • 该线程与 GUI 渲染线程 互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞,页面会出现白屏页。

事件触发线程

  • 对应的事件符合触发条件被触发时,该线程会将事件添加到待处理队列的队尾,交给JS引擎线程执行。
  • 当setTimeout定时器计数结束、ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将事件依次加入到任务队列的队尾,等待JS引擎线程的执行。

定时触发器线程

  • 也就是setIntervalsetTimeout所在线程。
  • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行。

注意:

setTimeout 如果不设置时间或者设置时间小于4ms,则会默认为 4ms。

异步 http 请求线程

  • 负责执行异步请求,比如ajax等一类的线程。
  • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JS 引擎线程执行。

JavaScript引擎是单线程

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

Js单线程
单线程的含义是js只能在一个线程上运行,也就说,js同时只能执行一个js任务,意味着js任务需要排队,如果前一个任务出现大量的耗时操作,后面的任务得不到执行,任务的积累会导致页面的“假死”。
js是单线程的,并不代表js引擎线程只有一个。js引擎有多个线程,一个主线程,其它的后台配合主线程。

Js执行任务方式
Js任务分两种:同步任务异步任务。浏览器的一个渲染进程只分配给js一个主线程(JS引擎线程),用来执行代码,这意味着所有的任务最终都要进入主线程通过调用栈来执行。
同步任务:同步任务都在主线程上排队执行的任务,形成一个函数调用栈(执行栈),只有前一个任务执行完毕,才能执行后一个任务。
异步任务:不进⼊主线程,⽽是进⼊任务队列的任务,执行完毕之后会产生一个回调函数,并且通知主线程。当主线程上的任务执行完后,就会调取最早通知自己的回调函数,使其进入主线程中执行。

注意:

JS调用栈就是函数执行的地方,是主线程在运行JavaScript代码的过程中形成的,遵循后进先出的规则。当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。

任务队列
JavaScript运行时,除了一个运行线程,引擎还提供一个消息队列,里面是各种需要当前程序处理的消息。新的消息进入队列的时候,会自动排在队列的队尾。

当遇到一个异步事件后,JavaScript 引擎并不会一直等待异步事件返回结果,而是会将这个事件挂在与执行栈不同的队列中,我们称之为任务队列。
任务队列的任务又分为宏任务(macrotask)微任务(microtask)

  • 常见的宏任务
    • script(整体代码)
    • setTimout
    • setInterval
    • setImmediate(node.js环境)
    • 进行异步的 I/O 操作
    • UI交互事件
    • Ajax
  • 常见的微任务
    • Promise.then
    • async、await
    • process.nextTick
    • MutationObserver(HTML5新特性,监控某个节点)

事件循环

JavaScript从诞生就是单线程。但是单线程就导致有很多任务需要排队,只有一个任务执行完才能执行后一个任务。如果某个执行时间太长,就容易造成阻塞;为了解决这一问题,JavaScript引入了事件循环机制
事件循环是js代码所在运行环境(浏览器、nodejs)编译器的一种解析执行规则。事件循环不属于js代码本身的范畴,而是属于js编译器的范畴。

事件循环Event Loop执行顺序

  • 执行同步代码(宏任务),就进入到了第一次事件循环
  • 遇到异步宏任务,放入到宏任务队列里
  • 遇到异步微任务,放入到微任务队列里
  • 执行完所有同步代码
  • 检查微任务任务队列,若不为空,就将微任务从队列中调入主线程执行
  • 微任务代码执行完毕
  • 检查是否需要执行UI重渲染,如果需要则重渲染UI
  • 再次检查有无宏任务,若无宏任务,执行下一事件循环

浏览器中,JavaScript 引擎循环地从任务队列中读取任务并执行,这种运行机制就叫做事件循环。
浏览器事件循环EventLoop

例子

上面说了那么多文绉绉的话,下面来看看实际的代码是怎么运行的?

例1
先来个简单的例子热热身:

console.log("start");
setTimeout(()=>{
    console.log("setTimeout1");
    Promise.resolve().then(data => {
        console.log('Promise2');
    });
},0);
setTimeout(()=>{
    console.log("setTimeout2");
},0);
Promise.resolve().then(data=>{
    console.log('Promise1');
});
console.log("end");

输出

start
end
Promise1
setTimeout1
Promise2
setTimeout2

JS引擎是如何执行这段代码的:

  • 主线程上先执行同步代码,输出start
  • 遇到第一个宏任务setTimeout,把它添加到宏任务队列中
  • 遇到第二个宏任务setTimeout,把它添加到宏任务队列中
  • 然后遇到Promise微任务,把它添加到微任务队列中
  • 最后同步代码(宏任务)执行完成输出end
  • 接下来就会检查微任务队列,发现此队列不为空,执行第一个promise的then回调,输出Promise1
  • 此时微任务队列为空,进入下一个事件循环
  • 检查宏任务队列,发现有setTimeout的回调函数,立即执行回调函数输出setTimeout1
  • 检查微任务队列,发现队列不为空,执行promise的then回调,输出Promise2,这时微任务队列为空,进入下一个事件循环
  • 检查宏任务队列,发现有setTimeout的回调函数, 立即执行回调函数输出setTimeout2

注意:

每一个宏任务执行完成,都回去检查微任务队列是否为空,如果不为空,就将微任务从队列中调入主线程执行,如果为空就进入下一次事件循环。

例2
现在来看个稍微复杂的例子:

console.log('start');
setTimeout(function () {
    console.log('setTimeout1');
}, 0);

setTimeout(function () {
    console.log('setTimeout2');
    setTimeout(function () {
        console.log('setTimeout3');
    },0);
    Promise.resolve().then(function () {
        console.log('promise4');
    });
}, 100);
Promise.resolve().then(function () {
    console.log('promise1');
    Promise.resolve().then(function () {
        console.log('promise2');
    });
}).then(function () {
    console.log('promise3');
});
Promise.resolve().then(function () {
    console.log('promise4');
});
console.log('end');

输出

start
end
promise1
promise4
promise2
promise3
setTimeout1
setTimeout2
promise4
setTimeout3

JS引擎是如何执行这段代码的:

  • 和上面例1一样,先执行完主进程上的同步任务,输出start、end,在执行过程中遇到宏任务和微任务分别添加到各类任务队列中
  • 同步任务执行完成,检查微任务队列是否为空,发现此队列不为空,执行第一个promise的then回调,输出promise1,这个时候发现有个Promise,添加到微任务队列
  • 然后执行第二个promise的then回调,输出promise4
  • 检查微任务队列,不为空,就跟着执行在第一个promise里面添加了的那个Promise,输出promise2
  • 由于第一个promise的.then()后面跟了一个.then()依然是promise,所以后面这个.then()也会放到微任务队列继续执行,输出promise3
  • 此时微任务队列为空,进入下一个事件循环, 检查宏任务队列,发现有setTimeout的回调函数,立即执行回调函数输出setTimeout1
  • 检查微任务队列,队列为空,进入下一次事件循环
  • 检查宏任务队列,发现有setTimeout的回调函数, 立即执行回调函数输出setTimeout2
  • 接着遇到setTimeout 0,它的作用是在 0ms 后将回调函数放到宏任务队列中
  • 检查微任务队列,发现此队列不为空,执行promise的then回调,输出promise4
  • 此时微任务队列,进入下一个事件循环
  • 检查宏任务队列,发现有setTimeout的回调函数,执行回调函数输出setTimeout3
  • 代码执行结束

总结:以上就是我理解的有关浏览器EventLoop的相关内容了,希望对看到的小伙伴有帮助。


 上一篇
Node中的EventLoop Node中的EventLoop
网上有很多关于Node中的EventLoop的文章和视频,但是往往都要看很多才能明白,那么看了我这篇后相信一定是最后一篇,一定会搞明白Node中的EventLoop是个什么东东。 了解CPU 与 存储器的关系 程序运行过程中 CPU 和存储
2023-10-23
下一篇 
Node.js的readline模块实现终端的输入输出 Node.js的readline模块实现终端的输入输出
readline是Node.js里实现标准输入输出的封装好的模块,通过这个模块我们可以以逐行的方式读取数据流。 引入 const readline = require('readline') 创建readline实例 createInte
2023-10-16
  目录