likes
comments
collection
share

JavaScript 运行机制(EventLoop)详解:一篇文章全部搞懂事件循环

作者站长头像
站长
· 阅读数 30

事件循环核心原理我们随手一搜能找到一大堆,大多数人只是知其然而不知其所以然,看到别人写的就死记硬背,一段时间之后又忘得差不多了,包括我,所以今天这篇文章的目的就是真正地理解、吃透,我们要知道自己写的代码是如何运行的,而不只是应付面试官。

先来一段感受一下:

JavaScript 是一种单线程的编程语言,只有一个调用栈,决定了它在同一时间只能做一件事。在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。因此JS又是一个非阻塞异步并发式的编程语言。

既然说了要让大家一篇文章全部搞懂,那我们就从头开始捋,一个名词一个名词地去抠,JS是单线程的,那什么是线程呢,首先从进程和线程开始讲起。

进程与线程的区别和联系

当我们启动某个程序时,操作系统会给该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,这样的运行环境就叫做进程

而线程是依附于进程的,在进程中使用多线程并行处理能提升运算效率,进程将任务分成很多细小的任务,再创建多个线程,在里面并行分别执行

  • 进程与进程之间完全隔离,互不干扰,由于进程之间是相互独立的,所以一个进程崩溃不会影响其他进程,如浏览器每一个标签页就是一个独立的进程,关闭其中一个标签页别的标签页并不会受到影响。
  • 线程之间的数据是共享的,一个进程可以有多个线程(一个进程至少有一个线程),当一个进程有多个线程时,每个线程都有一套独立的寄存器和堆栈信息,而代码、数据和文件是共享的
  • 一个进程中的任意一个线程执行出错,会导致这个进程崩溃
  • 当一个进程关闭之后,操作系统会回收该进程的内存空间

浏览器的进程与线程

以大家熟悉的Chrome的内核为例,他不仅是多线程的,而且是多进程的

最新的Chrome浏览器包括:浏览器主进程GPU进程网络进程渲染进程,和插件进程

  • 浏览器进程: 负责控制浏览器除标签页外的界面,包括地址栏、书签、前进后退按钮等,以及负责与其他进程的协调工作,同时提供存储功能
  • GPU进程:负责整个浏览器界面的渲染
  • 网络进程:负责发起和接受网络请求
  • 插件进程:主要是负责插件的运行,因为插件可能崩溃,所以需要通过插件进程来隔离,以保证插件崩溃也不会对浏览器和页面造成影响
  • 渲染进程:负责控制显示tab标签页内的所有内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页,排版引擎Blink和JS引擎V8都是运行在该进程中,默认情况下Chrome会为每个Tab标签页创建一个渲染进程

浏览器打开一个页面至少需要主进程、GPU、网络和渲染进程,后续如果再打开新的标签页的话,已经创建好的浏览器进程,GPU进程,网络进程是共享的,不会重新启动,默认情况下会为每一个标签页配置一个渲染进程,但是也有例外,比如同一站点的页面间跳转就可能重用渲染进程。

我们作为前端最关心的就是渲染进程,那仔细来看一下渲染进程。

渲染进程

上面已经提到渲染进程负责控制显示tab标签页内的所有内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页,排版引擎Blink和JS引擎V8都是运行在该进程中,默认情况下Chrome会为每个Tab标签页创建一个渲染进程,某个选项卡崩溃,其他选项卡并不会受影响。

渲染进程中的线程

  • GUI渲染线程:GUI(图形用户界面),该线程负责渲染页面,解析html和CSS、构建DOM树、CSSOM树、渲染树、布局计算、和绘制页面,重绘重排也是在该线程执行。
  • JS引擎线程:一个tab页中只有一个JS引擎线程(单线程),负责解析和执行JS。这个线程就是负责执行JS的主线程,"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。需要注意的是,这个线程跟GUI线程是互斥的。互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。
  • 计时器线程:指setInterval和setTimeout,因为JS引擎是单线程的,所以如果处于阻塞状态,那么计时器就会不准了,所以需要单独的线程来负责计时器工作。
  • 异步http请求线程:这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行。
  • 事件触发线程:定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是JS主线程。所以当时间到了定时器线程会将这个回调事件给到事件触发线程,然后事件触发线程将它加到任务队列里面去。最终JS主线程从任务队列取出这个回调执行。事件触发线程管理着一个任务队列,事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列,如鼠标点击事件等。

setTimeout、DOM或者 HTTP请求这部分其实并不在 v8 引擎中,这些属于webAPIs,即浏览器的API,不是js引擎提供的。

所谓的事件循环,或者说js能够实现异步非阻塞特性的基础就是因为多线程设计的存在。

消化总结:

用户启动某个应用程序会建立一个或多个进程,如浏览器的tab标签页,一个进程中的任务被划分到多个线程处理,有GUI渲染线程,JS引擎线程,网络线程等,JS的单线程即是指浏览器渲染进程中的JS引擎线程(因为只有一个JS引擎线程)。

了解了JS的单线程特性之后,我们来思考几个问题。

javaScript为什么会是单线程的语言?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。 在《javaScript高级程序设计》一书中有一个很好的解释:如果JS是多线程语言,那么假如当多个线程同时操作同一个DOM的时候,浏览器该如何渲染?浏览器该听哪个线程的指令?渲染结果是否会超出预期?基于这个特性,JS必须只能是单线程语言。

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

JavaScript代码是如何执行的?

JavaScript并不是一行一行的分析并执行代码的,所有的 JS 代码在运行时都是在执行上下文中进行的。执行上下文是一个抽象的概念,JS 中有三种执行上下文:

  • 全局执行上下文,默认的,在浏览器中是 window 对象,并且 this 在非严格模式下指向它
  • 函数执行上下文,JS 的函数每当被调用时会创建一个上下文
  • Eval 执行上下文,eval 函数会产生自己的上下文,这里不讨论

执行上下文在执行栈(调用栈)中被以后进先出的顺序执行。当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入执行栈,每遇到一个函数调用,就会往栈中压入一个新的函数执行上下文。引擎执行栈顶的函数(执行上下文),执行完毕,弹出当前执行上下文,并等待垃圾回收,全局上下文只有唯一的一个,它在浏览器关闭时出栈。

  • 栈,是一种数据结构,具有先进后出的原则。JS 中的执行栈就具有这样的结构。
  • 递归函数对函数的每次递归调用都会创建一个新的执行上下文,这意味着每次函数递归时,都需要更多内存来创建新上下文。

如何理解同步和异步?

  • 同步任务: 指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。可以理解为在执行完一个函数或方法之后,一直等待系统返回值或消息,这时程序是处于阻塞的,只有接收到返回的值或消息后才往下执行其他的命令。
  • 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

举个例子:你在烧水的时候还可以去洗菜切菜,因为烧水你只需要打开开关然后等水自己烧开提醒你就好了,不需要一直等着烧水什么都不做,这里的烧水就是异步任务。

为什么是异步、并发、非阻塞的?

我们在页面中通常会发大量的请求,获取后端的数据去渲染页面。因为浏览器是单线程的,试想一下,当我们发出异步请求的时候,阻塞了,后面的代码都不执行了,那页面可能出现长时间白屏极度影响用户体验

所以JS采取了"异步任务回调通知"的模式,而实现这个“通知”的,正是事件循环,当遇到异步任务时,就将这个任务交给对应的线程,当这个异步任务满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行。

  • 事件循环并不是JavaScript首创的,它是计算机的一种运行机制。
  • 基于JS的用途是浏览器脚本语言,用于操作DOM与用户进行交互,为了避免多个线程同时操作DOM导致渲染结果超出预期,所以JS被设计为一个单线程的语言。
  • 开发时会有很多耗时的异步任务,如果都在主线程中阻塞,那会极度影响用户体验,所以JS是异步、并发、非阻塞的。
  • JavaScript代码的执行过程中,依靠函数调用栈来搞定函数的执行顺序。

说了这么多,终于轮到我们的主角了,下面有请任务队列和事件循环登场。

任务队列和事件循环

事件循环与任务队列是JS中比较重要的两个概念。这两个概念在ES5和ES6两个标准中有不同的实现。

ES5下的概念: 任务队列是一个事件的队列,所谓任务是WebAPIs返回的一个个通知,也可以理解成消息的队列、回调队列,里面存放异步任务的回调,各个异步线程调用webAPI执行完后通过事件触发线程把回调函数放入任务队列,表示相关的异步任务可以进入“执行栈”了,等待被主线程读取

  • 一个事件循环中可以有多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合

浏览器包含3类事件循环:Window (用于运行网页内容的浏览器级容器,包括实际的 window,一个 tab 标签或者一个 frame。)事件循环、Worker 事件循环、Worklet 事件循环

  • 队列里的每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。

  • 同一个任务队列中的任务必须按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行,进入任务队列的是他们指定的具体执行任务(回调本身)

setTimeout/Ajax/Promise/DOM事件(user interaction task source) 等都是任务源,来自同类任务源的任务我们称它们是同源的,比如setTimeout与setInterval就是同源的。

ES5中的事件循环,如图:

JavaScript 运行机制(EventLoop)详解:一篇文章全部搞懂事件循环

图中有三大块:

  • 函数调用栈:即执行栈。
  • WebAPIs: 浏览器的接口,上面所说的浏览器的对应线程会使用这些接口处理,把它们放到相应的任务队列中。
  • 任务队列们: 主线程有多个任务队列,同源的任务被放入在属于自己的任务队列。

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

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

事件循环的大体流程:

  1. 主线程开始执行script代码,同步代码直接执行,遇到异步任务源就将它挂起交给对应的异步线程,自己继续执行同步任务
  2. 异步线程调用相应API处理,满足回调条件后,将异步回调事件放入任务队列
  3. 主线程的执行栈中的同步任务都执行完毕后,就来读取任务队列中的异步任务回调事件
  4. 主线程不断循环上述流程

到了ES6 的标准,由于出现了 Promise ,ES5 时代的"同步任务"与"异步任务"已经没有办法解释其中的原理,因此出现了 task 队列与 job 队列之分。

ES6将任务分为 宏任务(macrotask)微任务(microtask),在新ECMAScript标准中,它们被分别称为 task 与 jobs ; 任务队列则为宏任务队列(Task Queue)和微任务队列(Job Queue)。

事件循环由宏任务和在执行宏任务期间产生的所有微任务组成。宏任务队列可以有多个,微任务队列只有一个,完成当下的宏任务后,会立刻执行所有在此期间入队的微任务。

这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。微任务使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI重绘。

TIPS: 其实并没有宏任务队列一说,人家原名就叫任务队列(Task Queue)。首先要说明宏任务其实一开始就只是任务(task),因为ES6新引入了Promise标准,同时浏览器实现上多了一个microtask微任务概念,作为对照才称宏任务,至于宏任务队列,为了便于理解和区分大家就这么叫了。

宏任务(task)

进入执行栈等待主线程执行的主代码块,包括从异步队列里加入到栈的,如setTimeout()、setInterval()的回调,其中不含异步队列中的微任务如Promise.then回调。

  • 宏任务大概包括:script(整块代码)setTimeoutsetIntervalI/ODOM事件(UI交互事件)setImmediate(node环境)、postMessageMessageChannel,这些也被称作任务源

  • 宏任务是浏览器规定的(W3C)

  • 浏览器为了能够使得JS内部宏任务与DOM任务能够有序的执行,会在一个宏任务执行结束后,在下一个宏任务执行开始前,对页面进行重新渲染(GUI线程接管渲染,更新DOM树,重新绘制)

  • 异步任务可能是宏任务也可能是微任务,而宏任务可能是异步代码也可能是同步代码,被挂起后放到任务队列的是异步的宏任务,同步宏任务会直接执行

  • 宏任务队列可以有多个,微任务队列只有一个

Q:有很多小伙伴不理解为什么“script(整块代码)”是宏任务

A: MDN文档定义中有详细说明。

一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。 除了使用事件,你还可以使用 setTimeout() 或者 setInterval() 来添加任务。

由此可以得出结论,宏任务包含js主代码块,但是有一个争议存在,就是js主代码块是否进入宏任务队列中,或者说任务队列是否只存放异步任务回调关于这个问题,目前主要存在两种看法,

  1. script(整块代码)是宏任务(同步),首先被放入宏任务队列中,一个事件循环从宏任务队列开始,开始执行时宏任务队列中只有script(整块代码)任务,遇到同步代码直接入执行栈执行,异步代码放入对应的任务队列。
  2. 没有把 script(整块代码)放入宏任务队列,而是直接被主线程压入执行栈执行,只有异步任务才会被挂起并放入任务队列。

我个人其实更倾向于第二种说法,因为几乎所有文章都指出任务队列是消息队列、回调队列,我是实在没有找到script(整块代码)是怎么被放入或者是以什么形式被放入任务队列的相关说明,但其实这两种说法在实际代码运行表现上都是一致的,所以你怎么理解并不影响后续的事件循环流程,大家如果找到更官方更明确的说法欢迎交流,解惑。

微任务

可以理解是在当前宏任务执行结束后立即执行的任务(宏任务的小跟班),也就是说,在当前宏任务后,下一个宏任务之前,在重新渲染之前

宏任务->所有微任务->渲染,宏任务->所有微任务->渲染 ,...

微任务大概包括:new promise().then(回调)MutationObserver(html5新特性)、Object.observe(已废弃,proxy替代)、process.nextTick(node环境),这些也被称作任务源

  • 执行宏任务的过程中如果遇到微任务,就把微任务放到微任务队列,这个过程由主线程维护,而非事件触发线程
  • 当执行到script脚本的时候,js引擎会为全局创建一个执行上下文,在该执行上下文中维护了一个微任务队列,这个微任务队列是给 V8 引擎 内部使用的,所以你是无法通过 JavaScript 直接访问的。
  • process.nextTick不在Event Loop的任何阶段,他是一个特殊API,他会立即执行,然后才会继续执行Event Loop,若同时存在promise和nextTick,则先执行nextTick

区别:

任务队列和微任务队列的区别很简单,但却很重要: 1.当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行. 2.每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务

简单概括一下区别

  • 宏任务队列一次循环执行一个宏任务,后面的宏任务下个循环执行,微任务队列一次循环执行所有微任务,即清空微任务队列
  • 微任务可以添加新的微任务到队列中,中途插队执行
  • 宏任务中的事件放在宏任务队列中,由事件触发线程维护;微任务的事件放在微任务队列中,由js引擎线程(主线程)维护

了解了宏任务和微任务的概念之后,我们来补充一下ES6事件循环的具体流程:

  1. 首先,javascript整体代码被作为宏任务放入执行栈中执行,所有同步代码先执行,执行过程中,当遇到任务源时,判断是宏任务还是微任务
  2. 如果是宏任务,加入到宏任务队列中,如果是微任务,加入到微任务队列中
  3. 同步代码执行完成后,执行栈空闲,检查微任务队列中是否有可执行任务,如果有,依次执行微任务队列中的所有任务
  4. 渲染UI,开始下一轮循环
  5. 检查宏任务队列是否有可执行的宏任务,如果有,取出队列中最前面的那个宏任务,加入到执行栈中开始执行,然后重复以上步骤,直到宏任务队列中所有任务执行结束

定时器不准

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

假设我们定义了一个2s的定时器,那么该定时器的执行流程如下:

  1. 主线程执行同步代码
  2. 遇到setTimeout,将它交给定时器线程
  3. 定时器线程开始计时,2秒到了通知事件触发线程
  4. 事件触发线程将定时器回调放入事件队列,异步流程到此结束
  5. 主线程如果有空,将定时器回调拿出来执行,如果没空这个回调就一直放在队列里。

所以,如果在定义了定时器之后,我们又进行了非常耗时的同步代码运算,那即使到了2s,同步代码也会阻塞定时器回调事件的执行,因此,此时回调执行的时间必然是不准确的了,所以再次强调,写代码时一定不要长时间占用主线程

事件循环总结

事件循环(Event Loop) 是让 JavaScript 做到既是单线程,又绝对不会阻塞的核心机制,也是 JavaScript 并发模型的基础,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制,具体的管理方法由它所处的运行环境决定,目前JS的主要运行环境有两个,浏览器和Node.js,这两个环境的事件循环机制还有些区别,Node.js的事件循环我之后会另开一篇文章细说。

  1. 事件循环是让 JS 做到既是单线程,又可以异步并发不会阻塞的核心机制。
  2. 浏览器是不仅是多进程而且是多线程的,如渲染进程中有GUI渲染线程、JS引擎线程、计时器线程、HTTP请求线程、事件触发线程,事件循环就是依靠浏览器底层的多线程实现,所谓JS的单线程指的就是浏览器渲染进程中的JS引擎线程,因为只有一个JS引擎线程,所以是单线程,也被称为主线程。
  3. 主线程执行JS代码的过程中,依靠执行栈来管理执行任务的顺序,遵循后进先出的原则,同步任务直接入栈执行,异步任务被挂起待完成后被放入任务队列,
  4. 任务队列有宏任务队列和微任务队列的区别,宏任务队列中存放宏任务,如setTimeout、setInterval、DOM事件等,微任务队列中存放微任务,如Promise的then回调等。
  5. 当执行栈的任务执行完成后会去读取任务队列中的任务,优先执行微任务队列中的所有任务,微任务队列清空后,重新渲染UI,开始下一轮循环,检查宏任务队列是否有可执行的宏任务,如果有,取出队列中最前面的那个宏任务,加入到执行栈中开始执行,重复以上步骤就是事件循环。

参考文档