当前位置: 首页 > 科技观察

JavaScript运行机制详解(EventLoop)

时间:2023-03-13 03:11:54 科技观察

【.com原创稿件】前言在浏览器中,每个渲染进程都有一个主线程,主线程很忙。它要处理DOM、计算样式和处理布局,还要处理JavaScript任务和各种输入事件。这时候我们就需要一个系统来协调和调度这么多不同类型的任务在主线程中有序的执行,而这个整体的调度系统就是本文要介绍的事件循环系统(EventLoop).看完这篇文章,希望你能明白:进程和线程的区别最新的Chrome浏览器包括哪些进程?浏览器和Node的事件循环(EventLoop)有什么区别?一、进程与线程1、概念我们常说的JavaScript是单线程执行的,那么线程到底是什么?什么是流程?进程是程序的运行实例。详细解释就是当一个程序启动时,操作系统会为程序创建一块内存,用于存放代码、运行数据,以及一个执行任务的主线程。我们称这样的运行环境为进程。线程是操作系统可以进行操作调度的最小单位。线程不能单独存在。它由进程启动和管理。在进程中使用多线程并行处理可以提高计算效率。我们通过下图来加深对两者的理解:进程就像图中的工厂,拥有自己的工厂资源。当一个进程关闭时,操作系统会回收该进程占用的内存。线程就像图中的工人。多个工人在一个工厂里一起工作,工厂和工人之间是1:n的关系。这意味着一个进程是由一个或多个线程组成的,进程中任何一个线程的任何执行错误都会导致整个进程的崩溃。factory的空间??是worker共享的,也就是说一个进程的内存空间是共享的,每个线程都可以使用这块共享内存。多个工厂独立存在。这意味着进程之间的内容是相互隔离的。2、多进程和多线程Multi-process:允许两个或多个进程同时运行在同一个计算机系统中。以最新的Chrome浏览器为例,当我打开掘金编辑文章页面时,出现如下5个进程:1个网络进程、1个浏览器进程、1个GPU进程和1个渲染进程,共4个;如果打开的页面有插件运行,则需要再添加一个插件进程(番茄闹钟插件如下图所示)。多线程:程序中包含多个执行流,即一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行线程来完成各自的任务。2、最新的Chrome进程架构最新的Chrome浏览器包括:1个浏览器(Browser)主进程、1个GPU进程、1个网络(NetWork)进程、多个渲染进程和多个插件进程。接下来我们介绍这几个进程的功能:Browser进程。主要负责界面展示、用户交互、子进程管理,并提供存储等功能。渲染过程。核心任务是将HTML、CSS和JavaScript转换成用户可以与之交互的网页。排版引擎Blink和JavaScript引擎V8都运行在这个过程中。默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全原因,渲染过程以沙盒模式运行。渲染进程主要包括以下几个线程:Main线程、Worker线程、Compositor线程和Raster线程。显卡进程。事实上,在Chrome刚发布的时候,并没有GPU进程。使用GPU的初衷是为了实现3DCSS的效果,但是后来的网页和Chrome的UI界面都选择使用GPU来绘制,这使得GPU成为了浏览器的共同需求。最后,Chrome还在其多进程架构之上引入了GPU进程。网络过程。主要负责加载页面的网络资源。它曾经作为一个模块运行在浏览器进程中,但直到最近才独立出来,成为一个单独的进程。插件进程。主要负责插件的运行。因为插件很容易崩溃,所以需要通过插件进程进行隔离,保证插件进程的崩溃不会对浏览器和页面造成影响。页面中的大部分任务都在渲染进程的主线程上执行,包括:渲染事件(如解析DOM、计算布局、绘制);用户交互事件(如鼠标点击、滚动页面、放大缩小等);JavaScript脚本执行事件;网络请求完成,文件读写完成事件。那么,如何协调这些任务在主线程上有序执行呢?这就需要一个事件循环系统(EventLoop)3.浏览器中的事件循环1.什么是事件循环?消息通信。在Chrome中,进程间的任务也经常发生,那么如何处理其他进程发送的任务呢?可以参考下图(来源极客时间):消息队列是一种数据结构,可以存储待执行的任务。它符合队列的“先进先出”特性,也就是说,如果要添加一个任务,就把它添加到队列的尾部;如果你想取出一个任务,从队列的头部取出它。从图中可以看出,渲染进程有专门的IO线程接收其他进程的消息。收到消息后,它会将这些消息组装成任务发送给主渲染线程。主线程从“消息队列”中读取事件,这个过程是连续的,所以整个运行机制也称为EventLoop(事件循环)。2.同步任务和异步任务同步任务是可以立即执行的任务,比如声明一个变量或者执行一个加法运算。同步任务是宏任务。异步任务是不会立即执行的事件任务。异步任务包括宏任务和微任务。浏览器端常见的宏任务包括:setTimeout、setInterval、script(整体代码)、I/O操作、UI渲染等;浏览器端常见的微任务包括:newPromise().then(callback)、MutationObserver(html5新特性)等。3.EventLoop流程分析一个完整的浏览器端EventLoop流程,可以概括为以下阶段:一开始,执行栈是空的,我们可以把执行栈看成是一个存放函数调用的栈结构,遵循先进后出的原则。microtask队列为空,macrotask队列中只有一个script脚本(整体代码)。全局上下文(脚本标签)被压入执行栈,同步代码执行。在执行过程中,会先判断是同步任务还是异步任务,会产生新的宏任务和微任务,分别推入各自的任务队列。同步代码执行完毕后,脚本脚本将从宏队列中移除。这个过程本质上就是队列宏任务执行和出队的过程。上一步我们dequeued的是一个宏任务,这一步我们处理的是一个微任务。但需要注意的是,当宏任务出队时,任务是一个一个执行的;当微任务出队时,任务被一个接一个地执行。因此,我们处理微队列这一步,将队列中的任务一个一个执行,并出队,直到队列清空。可以有多个宏任务队列,只有一个微任务队列。执行渲染操作,更新接口检查是否有Webworker任务,有则处理。重复上述过程,直到两个队列都被清除。总结一下,每个循环都是这样一个过程:当一个宏任务执行完后,会检查是否有微任务队列。如果有,则先执行微任务队列中的所有任务,如果没有,则读取宏任务队列中的最前面的任务。macrotask在执行过程中,如果遇到microtask,会依次加入到microtask队列中。栈为空后,再次读取微任务队列中的任务,以此类推。下面我们看一个例子来介绍一下上面的过程:Promise.resolve().then(()=>{console.log('Promise1')setTimeout(()=>{console.log('setTimeout2')},0)})setTimeout(()=>{console.log('setTimeout1')Promise.resolve().then(()=>{console.log('Promise2')})},0)最后输出就是Promise1,setTimeout1,Promise2,setTimeout2开始执行栈的同步任务(这个是宏任务),然后检查是否有microtask队列,在上题中存在(只有一个),然后执行微任务队列中的所有任务任务输出Promise1,同时会产生一个宏任务setTimeout2,然后进入宏任务队列。宏任务setTimeout1会在setTimeout2之前执行宏任务setTimeout1,输出setTimeout1会在宏任务setTimeout1执行时生成微任务Promise2,放入微任务队列,然后先清除微任务队列中的所有任务,输出Promise2。清空微任务队列中的所有任务后,会去宏任务队列中再取一个。这次执行setTimeout2。4.Node中的EventLoop1.Node介绍Node环境中的EventLoop不同于浏览器环境中的EventLoop。Node.js使用V8作为js的解析引擎,使用自己设计的libuv进行I/O处理。libuv是一个事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也在其中实现(下面详细介绍)。注:本文介绍的Node环境下的EventLoop是基于node10及之前的版本。Node.js的运行机制如下:V8引擎解析JavaScript脚本。解析后的代码调用NodeAPI。libuv库负责执行NodeAPI。它将不同的任务分配给不同的线程,形成一个EventLoop(事件循环),并将任务的执行结果以异步的方式返回给V8引擎。然后V8引擎将结果返回给用户。2.六个阶段libuv引擎中的事件循环分为6个阶段,它们会按顺序重复运行。每当进入某个阶段,就会从对应的回调队列中取出函数执行。当队列为空或回调函数执行次数达到系统设定的阈值时,进入下一阶段。从上图我们可以大致看出node中事件循环的顺序:外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(closecallback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/Ocallbacks)-->空闲阶段(idle,prepare)-->轮询阶段(按此顺序重复运行)...timers阶段:该阶段执行定时器的回调I/O回调(setTimeout,setInterval)Phase:处理一些上个周期未执行的I/O回调idle,preparephase:只有节点内部使用pollPhase:获取新的I/O事件,under合适的条件,节点会阻塞在这里检查阶段:执行setImmediate()回调关闭回调阶段:执行套接字关闭事件回调注意:以上六个阶段不包括process.nextTick()(下面会介绍)接下来介绍timers、poll、check三个阶段详解,因为日常开发中处理的大部分是异步任务在这三个阶段。(1)timertimers阶段执行setTimeout和setInterval回调,受poll阶段控制。同样,Node中定时器指定的时间也不是准确时间,只能尽快执行。(2)pollpoll是一个关键阶段。在这个阶段,系统会做两件事:1.回到timer阶段执行回调2.执行I/Ocallback如果进入这个阶段时没有设置如果使用了timer,下面两件事会发生:如果poll队列不为空,会遍历回调队列,同步执行,直到队列为空或者达到系统限制如果poll队列为空,会发生两件事如果有setImmediate回调就需要待执行,poll阶段会停止,进入check阶段执行回调。如果没有setImmediate回调执行,它会等待回调被添加到队列中并立即执行回调。还会有一个超时设置,防止它等待。当然是设置了如果是定时器,poll队列为空,就会判断是否有定时器超时,如果有则返回定时器阶段执行回调。(3)check阶段setImmediate()的回调会加入到check队列中。从事件循环的阶段图可以知道,check阶段的执行顺序是在poll阶段之后。我们先来看一个例子:console.log('start')setTimeout(()=>{console.log('timer1')Promise.resolve().then(function(){console.log('promise1')})},0)setTimeout(()=>{console.log('timer2')Promise.resolve().then(function(){console.log('promise2')})},0)Promise.resolve().then(function(){console.log('promise3')})console.log('end')//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2beginning执行栈的同步任务(这个是宏任务)执行完后(依次打印出startend,依次将2个定时器放入定时器队列),先执行微任务(这个是一样的作为浏览器端),所以打印出promise3,然后进入timers阶段,执行timer1的回调函数,打印timer1,并将promise.then回调放入microtask队列,同样步骤执行timer2,打印timer2;这个和浏览器端有很大的不同,timers阶段有几个setTimeout/setInterval会依次执行,不像浏览器端是在每个macrotask执行完后执行一个microtask(Node和browserEventLoop的区别后面会详细说)以下)。3、Micro-Task和Macro-TaskNode事件循环中的异步队列也分为macro(宏任务)队列和micro(微任务)队列。Node端常见的宏任务,如:setTimeout、setInterval、setImmediate、script(整体代码)、I/O操作等Node端常见的微任务,如:process.nextTick、newPromise().then(回调)等4.注意点(1)setTimeout和setImmediate很相似,主要区别在于调用的时机。setImmediate被设计为在poll阶段完成时执行,即check阶段;setTimeout被设计成在poll阶段空闲并且设置的时间到了时执行,但是在timer阶段执行setTimeout(functiontimeout(){console.log('timeout');},0);setImmediate(functionimmediate(){console.log('立即');});对于上面的代码,setTimeout可能会在之前或之后执行。首先是setTimeout(fn,0)===setTimeout(fn,1),这是源码决定的。进入事件循环也需要成本。如果准备时间超过1ms,则直接在timer阶段执行。如果setTimeout回调准备时间小于1ms,那么先执行setImmediate回调,但是在异步I/O回调内部调用这两者时,总是先执行setImmediate,然后setTimeoutconstfs=require('fs')fs.readFile(__filename,()=>{setTimeout(()=>{console.log('timeout');},0)setImmediate(()=>{console.log('immediate')})})//immediate//timeout上面代码中,setImmediate总是先执行。因为两段代码都写在IO回调中,所以IO回调是在poll阶段执行的。执行回调时,队列为空,发现有setImmediate回调,于是直接跳转到check阶段执行回调。(2)函数process.nextTick实际上是独立于EventLoop的。它有自己的队列。当每个阶段完成后,如果有nextTick队列,队列中的所有回调函数将被清除,并优先于其他微任务执行。setTimeout(()=>{console.log('timer1')Promise.resolve().then(function(){console.log('promise1')})},0)process.nextTick(()=>{console.log('nextTick')process.nextTick(()=>{console.log('nextTick')process.nextTick(()=>{console.log('nextTick')process.nextTick(()=>{console.log('nextTick')})})})})//nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise15.Node和浏览器EventLoop的区别在浏览器环境下,microtask的任务队列在每个macrotask执行完之后执行。在Node.js中,microtask会在eventloop的各个stage之间执行,也就是一个stage执行完之后,microtask队列中的task就会被执行。下面我们用一个例子来说明两者的区别:setTimeout(()=>{console.log('timer1')Promise.resolve().then(function(){console.log('promise1')})},0)setTimeout(()=>{console.log('timer2')Promise.resolve().then(function(){console.log('promise2')})},0)浏览器端运行results:timer1=>promise1=>timer2=>promise2浏览器端处理流程如下:节点端运行结果:取决于第一个定时器是否执行,第二个定时器是否在完成队列中。如果第二个定时器还没有在完成队列中,那么最终结果是timer1=>promise1=>timer2=>promise2如果第二个定时器已经在完成队列中,那么最终结果是timer1=>timer2=>promise1=>promise2(下面的流程解释都是基于这种情况)1.全局脚本(main())执行,依次将两个定时器放入定时器队列,main()执行完后,调用栈空闲,任务队列开始执行;2、首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样步骤执行timer2,打印timer2;3、至此timer阶段执行结束,事件循环进入下一阶段之前,执行完microtask队列中的所有任务,依次打印promise1和promise2的处理过程如下:6.总结浏览器中EventLoop与Node环境的区别主要体现在Node端微任务队列的执行时机不同。microtask在事件循环的各个阶段之间执行浏览器端,microtask在事件循环的macrotask执行完之后执行。线?傻傻分不清楚!前端性能优化原理与实践深入理解js事件循环机制(Node.js篇)JavaScript事件循环(eventloop)机制详解event-loop-timers-and-nexttick定时器:运行nextTicks之后immediate和timer的作者介绍了乘风破浪:研究生,主攻前端。个人公众号:《前端工匠》,致力于打造一系列适合初中级工程师快速吸收的优质文章!【原创稿件,合作网站转载请注明原作者和出处为.com】