Event Loop
Event Loop即事件循环,是指浏览器或Node的一种解决javaScript单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
进程和线程基本概念
拿出在教科书里的概念:
- 1、调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位;
- 2、并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可并发执行;
- 3、拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源;
- 4、系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。进程和线程的关系:
一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程;
资源分配给进程,同一进程的所有线程共享该进程的所有资源;
处理机分给线程,即真正在处理机上运行的是线程;
线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。线程是指进程内的一个执行单元,也是进程内的可调度实体。
基本总结,一个进程可以有多个线程,线程之间可以相互通信。
进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位。
图示解析
- 进程好比图中的工厂,有单独的专属自己的工厂资源。
- 线程好比图中的工人,多个工人在一个工厂中协作工作,工厂与工人是 1:n的关系。也就是说一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 工厂的空间是工人们共享的,这象征一个进程的内存空间是共享的,每个线程都可用这些共享内存。
- 多个工厂之间独立存在。
多进程与多线程
- 多进程:在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的,比如你可以听歌的同时,打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰。
- 多线程:程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
以Chrome浏览器中为例,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
浏览器内核
简单来说浏览器内核是通过取得页面内容、整理信息(应用CSS)、计算和组合最终输出可视化的图像结果,通常也被称为渲染引擎。
浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:
- GUI 渲染线程
- JavaScript引擎线程
- 定时触发器线程
- 事件触发线程
- 异步http请求线程
1.GUI渲染线程
主要负责页面的渲染,解析HTML、CSS,构建DOM树,布局和绘制等。
当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
该线程与JS引擎线程互斥,当执行JS引擎线程时,GUI渲染会被挂起,当任务队列空闲时,主线程才会去执行GUI渲染。
2.JS引擎线程
该线程当然是主要负责处理 JavaScript脚本,执行代码。
也是主要负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS引擎线程的执行。
当然,该线程与 GUI渲染线程互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞。
3.定时器触发线程
负责执行异步定时器一类的函数的线程,如: setTimeout,setInterval。
主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。
4.事件触发线程
主要负责将准备好的事件交给 JS引擎线程执行。
比如 setTimeout定时器计数结束, ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS引擎线程的执行。
5.异步http请求线程
负责执行异步请求一类的函数的线程,如: Promise,axios,ajax等。
主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待JS引擎线程执行。
为什么JavaScript是单线程?
JS 执行是单线程的,意味着 JS 在执行代码的时候一次只能处理一个任务,必须按队列顺序逐个执行。JS 的主要功效是处理前端交互,其中就包括操作 DOM 节点。试想若 JS 是多线程,在处理网页交互时,一个线程需要删除 DOM 节点,另一个线程却是要操作同一个 DOM 节点,这样该如何判断先执行哪个线程?但若队列中存在多个任务,上一个任务的执行会阻塞下一个任务,导致代码执行效率低下。就像 AJAX 请求线程,发出请求后需要等待响应结果,期间 CPU 却是空闲的。对此,JS的事件循环机制(Event Loop)很好地解决了问题。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
1 | 这里所谓的单线程指的是主线程是单线程的,所以在Node中主线程依旧是单线程的。 |
任务
JavaScript 将任务分为两种:同步任务和异步任务。
- 同步任务:执行完后能立即得出结果的任务。同步任务在主线程中执行,上一个任务的执行会阻塞下一个任务,在执行过程中产生堆栈。堆中存储复杂数据类型(Object),栈中存储基本数据类型(String、Number、Boolean、Null、Undefined、Symbol)。
- 异步任务:执行后无法立即得出结果,需要等待一段时间获得相应的任务。其中又分为宏任务(Macrotask)和微任务(Microtask)。
- 宏任务:script(整体代码)、setTimeout、setInterval、setImmediate、I/O操作(mouse click、keypress、network event)、UI渲染、requestAnimationTrame等。
- 微任务:Promise.then、MutationObserver、process.nextTick()等。
浏览器 Event Loop 过程解析
Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。
JS调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。
一个完整的 Event Loop 过程,可以概括为以下阶段:
初始状态:调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。
全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。
上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的(如下图所示)。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
执行渲染操作,更新界面(敲黑板划重点)。
检查是否存在 Web worker 任务,如果有,则对其进行处理 。
上述过程循环往复,直到两个队列都清空
举个例子
1 | console.log('script start'); |
执行过程分析:
全局上下文 (script 标签) 被推入调用栈,同步代码执行。打印 script start,遇到 setTimeout,推入到macro 队列。遇到Promise.resolve().then推入到micro 队列,遇到同步代码。打印 script end。
micro 队列 执行出队列操作,打印 promise1,遇到 then,推入到macro 队列。micro 队列 执行出队列操作,打印 promise2。micro 队列 清空。
macro 队列 执行出队列操作,打印 setTimeout,macro 队列 清空。
更复杂的例子
1 | async function async1() { |
执行过程分析:
全局上下文 (script 标签) 被推入调用栈,同步代码执行。打印 script start,遇到 setTimeout,推入到macro 队列。执行 async1 ,打印 async1 start 。执行 await async2(),相当于先执行 async2 这个 Promise 函数,打印 async2 end,然后执行 then,micro 任务 console.log(‘async1 end’) 入队列。遇到 new Promise,同步任务,打印 Promise1,执行 resolve(),micro 任务 console.log(‘promise2’) 入队列。遇到同步任务,打印 script end;
micro 队列 执行出队列操作,打印 async1 end,然后打印 promise2,遇到 then,console.log(‘promise3’),推入到macro 队列。micro 队列 执行出队列操作,打印 promise3。micro 队列 清空。
macro 队列 执行出队列操作,打印 setTimeout,macro 队列 清空。
NodeJS Event Loop 过程解析
Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
Node.js的运行机制如下:
- V8引擎解析JavaScript脚本。
- 解析后的代码,调用Node API。
- libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
- V8引擎再将结果返回给用户。
Node的Event loop一共分为6个阶段,每个细节具体如下:
- timers: 执行setTimeout和setInterval中到期的callback。
- pending callback: 执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare: 仅在内部使用。
- poll: 最重要的阶段,执行pending callback,检索新的 I/O 事件;执行与 I/O 相关的回调,在适当的情况下回阻塞在这个阶段。
- check: 执行setImmediate() 设定的callbacks。
- close callbacks: 执行close事件的callback,例如socket.on(‘close’[,fn])或者http.server.on(‘close, fn)。
timers
执行 setTimeout 和 setInterval 中到期的 callback,执行这两者回调需要设置一个毫秒数,理论上来说,应该是时间一到就立即执行callback 回调,但是由于 system的调度 可能会延时,达不到预期时间。
pending callbacks
此阶段执行某些系统操作的回调,例如TCP错误的类型。 例如,如果TCP套接字在尝试连接时收到ECONNREFUSED,则某些* nix系统希望等待报告错误。 这将在pending callbacks阶段执行。
poll
poll 阶段有两个重要的功能:
- 执行与 I/O 相关的回调
- 然后,处理 poll 队列里的事件
当事件循环进入 poll 阶段并且在 timers 队列中没有可以执行定时器时,将发生以下两种情况之一
- 如果 poll 队列不为空,事件循环将循环访问其回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬限制。遇到 setImmediate 将其回调放入 setImmediate 队列。
- 如果poll队列为空,则会发生以下情况:
- 如果有 setImmediate() 回调,则会立即停止执行 poll 阶段并进入执行 check 阶段以执行 setImmediate 回调,清空 check队列。执行完setImmediate ,事件循环将绕回 timers 阶段
- 如果没有 setImmediate() 回调,一方面将等待 I/O 相关的回调被添加到 poll 队列中,然后立即执行。另一方面如果一个或多个计时器回调已准备就绪,则事件循环将绕回 timers 阶段以执行这些计时器的回调。简言之就是,I/O 相关的回调和 timers 相关的回调谁先有结果就先执行谁。
check
此阶段允许人员在 poll 阶段完成后立即执行回调。如果 poll 阶段变为空闲状态,并且脚本已排队使用 setImmediate(),则事件循环继续到 check 阶段而不是等待。
setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 poll 阶段完成后执行。
通常,在执行代码时,事件循环最终将达到 poll 阶段,等待传入连接、请求等。如果已经调度了回调setImmediate(),并且轮询阶段变为空闲,则它将结束并且到达check阶段,而不是等待poll事件。
close callbacks
如果 socket 或 handle 处理函数突然关闭(例如 socket.destroy()),则’close’ 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出。
setImmediate() 的setTimeout()的区别
setImmediate() 和 setTimeout() 很类似,但何时调用行为完全不同。
- setImmediate():设计用于在当前poll阶段完成后check阶段执行脚本 。
- setTimeout():安排在经过最小(ms)后运行的脚本,在timers阶段执行。
1 | setTimeout(() => { |
上面代码的执行结果,受到进程性能的限制。其结果也不一致。
如果在I / O周期内移动两个调用,则始终首先执行 setImmediate:
1 | const fs = require('fs'); |
主要原因是在 I/O 阶段读取文件后,事件循环会先进入 poll 阶段,发现有 setImmediate 需要执行,会立即进入check阶段执行setImmediate 的回调。
然后再进入 timers 阶段,执行 setTimeout,打印 timeout。
Process.nextTick()
当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
一个完整的 NodeJS Event Loop 过程
一个完整的 NodeJS Event Loop 过程,可以概括为以下阶段:
- 初始状态:调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。
- 全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。
- 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。会逐个执行队列中的任务并把它出队,直到队列被清空。同时请注意, Process.nextTick 的优先级高于 Promise.then
- 进入 timers 阶段,执行 setTimeout 和 setInterval 中到期的 callback。
- 如果 timers 阶段,没有 setTimeout 和 setInterval 到期的回调,会进入 poll 阶段。当事件循环进入poll阶段并且在timers中没有可以执行定时器时,将发生以下两种情况之一。
- 如果 poll 队列不为空,事件循环将循环访问其回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬限制。遇到 setImmediate 将其回调放入 setImmediate 队列。
- 如果poll队列为空,则会发生以下情况:
- 如果有 setImmediate() 回调,则会立即停止执行 poll 阶段并进入执行 check 阶段以执行 setImmediate 回调,清空 check队列。执行完setImmediate ,事件循环将绕回 timers 阶段
- 如果没有 setImmediate() 回调,一方面将等待 I/O 相关的回调被添加到 poll 队列中,然后立即执行。另一方面如果一个或多个计时器回调已准备就绪,则事件循环将绕回 timers 阶段以执行这些计时器的回调。简言之就是,I/O 相关的回调和 timers 相关的回调谁先有结果就先执行谁。
- 注意,pending callback,idle, prepare 这两个阶段我们通常不用考虑。
- 同时请注意,如果node版本为v11.x,会执行一个 macro-task,然后处理 micro-task。会逐个执行 micro-task 队列中的任务并把它出队,直到队列被清空,与浏览器效果类似。如果node版本为v11.x以下版本,会执行完所有的 macro-task,清空 macro-task 队列。然后,执行 macro-task,清空 micro-task 队列。
1 | const fs = require('fs'); |