前言
什么要学底层的事件循环Event Loop,不仅仅是因为这是一道面试的常考题。作为一个程序员,了解程序的运行机制是很重要的,这样可以帮助你去输出更优质的代码。前端是一个范围很广的领域,技术一直在更新迭代,掌握了底层的原理可以应对新的技术。
JS初始设计
JavaScript从诞生起就是单线程。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript为一种单线程语言。(虽然 HTML5 增加了 Web Work 可用来另开一个线程,但是该线程仍受主线程的控制,所以 JavaScript 的本质依然是单线程)
什么是Event Loop?
Event Loop就是事件循环,是浏览器和NodeJS用来解决Javascript单线程运行带来的问题的一种运行机制。
针对于浏览器和NodeJS两种不同环境,Event Loop也有不同的实现:
- 浏览器的Event Loop是在html5的规范中明确定义
- NodeJS的Event Loop是基于libuv实现的
- libuv已经对Event Loop做出了实现,而HTML5规范中只是定义了浏览器中Event Loop的模型,具体的实现留给了浏览器厂商
因此浏览器和NodeJS的Event Loop是两种不同的概念。不过在搞清楚Event Loop之前我们先来搞清楚一些其他概念。
概念一:进程、程序、线程
【1】进程(process):是系统分配的独立资源,是 CPU 资源分配的基本单位,进程是由一个或者多个线程组成的。
【2】程序:是指令和数据的有序集合,进程中的文本区域就是代码区就是程序。
【3】线程(thread):是进程的执行流,是CPU调度和分派的基本单位。
程序本身没有任何运行的含义,是一个静态的概念。而进程则是在处理机上的一次执行过程,它是一个动态的概念。同一个程序包含多个进程。简单来说,进程简单理解就是我们平常使用的程序,如 QQ,浏览器,网盘等。进程拥有自己独立的内存空间地址,拥有一个或多个线程,而线程就是对进程粒度的进一步划分。
我们通过以下这张图来加深对进程和线程的理解:
- 进程好比图中的工厂,有单独的专属自己的工厂资源。当一个进程关闭之后,操作系统会回收进程所占用的内存。
- 线程好比图中的工人,多个工人在一个工厂中协作工作,工厂与工人是 1:n的关系。这意味着一个进程由一个或多个线程组成,进程中的任意一线程执行出错,都会导致整个进程的崩溃。
- 工厂的空间是工人们共享的,这意味着一个进程的内存空间是共享的,每个线程都可用这些共享内存。
- 多个工厂之间独立存在。这意味着进程之间的内容相互隔离。
概念二:堆、栈、队列
如果你学过数据结构,就一定会遇到 "堆", "栈","队列",最关键的是即使你去面试,这些还都会问到,如果你不懂对你损失很大的。
【1】堆(Heap)
1、堆是一种经过排序的树形数据结构,每个节点都有一个值,通常我们所说的堆的数据结构是指二叉树。
2、堆分为两种情况,有最大堆和最小堆。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
3、堆是在程序运行时,而不是在程序编译时,申请某个大小的内存空间。即动态分配内存,对其访问和对一般内存的访问没有区别。
4、堆是应用程序在运行的时侯请求操作系统分配给自己内存,而不是在编译的时 ,一般是申请/给予的过程。
5、堆用来存储对象的值,并会用一个地址来记录存储值的位置,该地址存储在命名对象的变量中(即存储在栈内存中)。因此复制这个变量只是复制了地址,而不是复制了对象。
【2】栈(Stack)
1、栈(stack)又名堆栈,它是一种运算受限的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,把另一端称为栈底。不含任何数据元素的栈称为空栈。
2、栈是一种具有后进先出的数据结构,又称为后进先出的线性表,简称 LIFO(Last In First Out)结构。可以想象为一个桶,后放进去的东西会先拿出来,而且只能在桶口操作。
3、栈是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有FIFO的特性,在编译的时候可以指定需要的栈的大小。
4、栈定义了两个方法:PUSH操作在堆栈的顶部加入一个元素,POP操作相反,在堆栈顶部移去一个元素,并将堆栈的大小减一
【3】队列(Queue)
1、队列是一种特殊的线性表,只允许在表的前端进行删除操作,表的后端进行插入操作,进行插入操作的端称为队尾,进行删除操作的端称为队头。
2、队列中没有元素时,称为空队列。
3、队列是一种先进先出的数据结构,又称为先进先出的线性表,简称 FIFO(First In First Out)结构。也就是说先放的先取,后放的后取,就如同行李过安检的时候,先放进去的行李在另一端总是先出来,后放入的行李会在最后面出来。
【4】JS执行栈
JS 执行栈是一种后进先出的数据结构。当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。
概念三:同步任务、异步任务
【1】同步任务:可以立即执行的任务,例如声明一个变量或者执行一次加法操作等。同步任务属于宏任务。
【2】异步任务:是不会立即执行的事件任务。异步任务包括宏任务和微任务。
JavaScript 单线程中的任务分为同步任务和异步任务。同步任务会在调用栈中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到任务队列(消息队列)中等待主线程空闲的时候,也就是栈内被清空的时候,被读取到栈中等待主线程执行。任务队列是先进先出的数据结构。
概念四:宏队列、微队列
在JavaScript中,任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)也叫Jobs。
【1】宏队列:一些异步任务的回调会依次进入宏任务队列,等待后续被调用,这些异步任务包括
# | 浏览器 | Node |
---|---|---|
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
【2】微队列: 另一些异步任务的回调会依次进入微任务队列,等待后续被调用,这些异步任务包括:
# | 浏览器 | Node |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver(html5新特性) | ✅ | ❌ |
Promise.then catch finally | ✅ | ✅ |
概念五:浏览器内核
浏览器是多进程的,浏览器每一个 tab 标签都代表一个独立的进程(也不一定,因为多个空白 tab 标签会合并成一个进程),浏览器内核(浏览器渲染进程)属于浏览器多进程中的一种。浏览器内核有多种线程在工作。
【1】GUI 渲染线程:
- 负责渲染页面,解析 HTML,CSS 构成 DOM 树等,当页面重绘或者由于某种操作引起回流都会调起该线程。
- 和 JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,GUI 更新被放入在 JS 任务队列中,等待 JS 引擎线程空闲的时候继续执行。
【2】JS 引擎线程:
- 单线程工作,负责解析运行 JavaScript 脚本。
- 和 GUI 渲染线程互斥,JS 运行耗时过长就会导致页面阻塞。
【3】事件触发线程:
- 当事件符合触发条件被触发时,该线程会把对应的事件回调函数添加到任务队列的队尾,等待 JS 引擎处理。
【4】定时器触发线程:
- 浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。
- 开启定时器触发线程来计时并触发计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。
【5】http 请求线程:
- http 请求的时候会开启一条请求线程。
- 请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理
概念六:定时器
定时器会开启一条定时器触发线程来触发计时,定时器会在等待了指定的时间后将事件放入到任务队列中等待读取到主线程执行。定时器指定的延时毫秒数其实并不准确,因为定时器只是在到了指定的时间时将事件放入到任务队列中,必须要等到同步的任务和现有的任务队列中的事件全部执行完成之后,才会去读取定时器的事件到主线程执行,中间可能会存在耗时比较久的任务,那么就不可能保证在指定的时间执行。
浏览器中的Event Loop
Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行
javascript是一种单线程语言,所有任务都在一个线程上完成(即采用排队形式:因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务)。一旦前面遇到大量任务或者遇到一个耗时的任务,网页就会出现"假死",因为JavaScript停不下来,也就无法响应用户的行为。所以 JavaScript 有一套机制去处理同步和异步操作,那就是事件循环 (Event Loop)。
一个完整浏览器端的 Event Loop 过程,可以概括为以下阶段:
- 所有同步任务都在主线程上执行,形成一个执行栈 (Execution Context Stack)。我们可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
- 而异步任务会被放置到 Task Table,也就是上图中的异步处理模块,当异步任务有了运行结果,就将该函数移入任务队列。
- 一旦执行栈中的所有同步任务执行完毕,引擎就会读取任务队列,然后将任务队列中的第一个任务压入执行栈中运行。
主线程不断重复第三步,也就是 只要主线程空了,就会读取任务列表,该过程不断重复,这就是所谓的事件循环。
总结:当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
JavaScript代码的具体流程
- 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout)等;
遇到了 setTimeout ,就会等到过了指定的时间后将回调函数放入到宏任务的任务队列中,遇到 Promise,将 then 函数放入到微任务的任务队列中 - 全局Script代码执行完毕后,调用栈Stack会清空;
- 去检测微任务的任务队列中是否存在任务,存在就执行,从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
- 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;
- microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;
- 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行,发现在这次循环中并不存在微任务。存在就进行第三个步骤。不存在就进行第7步骤。
- 宏任务执行完后,Macrotask Queue为空。全部执行完后,Stack Queue为空,Macrotask Queue为空,Micro Queue为空
- 重复第3-8个步骤;
Node中的Event Loop
【1】Node简介
Node 环境下的 Event Loop 与浏览器环境下的 Event Loop并不相同。Node.js 采用 V8 作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现
【2】Node.js的运行机制如下:
- V8引擎解析JS脚本。
- 解析后的代码,调用Node API。
- libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
- V8引擎再将结果返回给用户。
【3】六个阶段
其中libuv引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
node中的事件循环的顺序:外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)
timers 阶段:timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
pending callbacks 阶段****:这个阶段会执行一些和底层系统有关的操作,例如TCP连接返回的错误等。这些错误发生时,会被Node 推迟到下一个循环中执行。
poll 轮询阶段****:poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情:1、回到 timer 阶段执行回调 2、执行 I/O 回调。并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情:
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
- 如果 poll 队列为空时,会有两件事发生
- 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
check 阶段****:这个阶段会执行 setImmediate() 设置的任务。
close callbacks 阶段****:如果一个 socket 或 handle(句柄) 突然被关闭了,例如通过 socket.destroy() 关闭了,close 事件将会在这个阶段发出。
【4】宏队列、微队列
宏队列:在浏览器中,可以认为下面只有一个宏队列,所有的macrotask都会被加到这一个宏队列中,但是在NodeJS中,不同的macrotask会被放置在不同的宏队列中
- Timers Queue
- IO Callbacks Queue
- Check Queue
- Close Callbacks Queue
微队列:在浏览器中,也可以认为只有一个微队列,所有的microtask都会被加到这一个微队列中,但是在NodeJS中,不同的microtask会被放置在不同的微队列中
- Next Tick Queue:是放置process.nextTick(callback)的回调任务的
- Other Micro Queue:放置其他microtask,比如Promise等
【5】NodeJS的Event Loop过程
- 执行全局Script的同步代码
- 执行microtask微任务,先执行所有Next Tick Queue中的所有任务,再执行Other Microtask Queue中的所有任务
- 开始执行macrotask宏任务,共6个阶段,从第1个阶段开始执行相应每一个阶段macrotask中的所有任务,注意,这里是所有每个阶段宏任务队列的所有任务,在浏览器的Event Loop中是只取宏队列的第一个任务出来执行,每一个阶段的macrotask任务执行完毕后,开始执行微任务,也就是步骤2
- Timers Queue -> 步骤2 -> I/O Queue -> 步骤2 -> Check Queue -> 步骤2 -> Close Callback Queue -> 步骤2 -> Timers Queue ......
- 这就是Node的Event Loop
【6】setTimeout 对比 setImmediate
- setTimeout(fn, 0)在Timers阶段执行,并且是在poll阶段进行判断是否达到指定的timer时间才会执行
- setImmediate(fn)在Check阶段执行
两者的执行顺序要根据当前的执行环境才能确定: - 如果两者都在主模块(main module)调用,那么执行先后取决于进程性能,顺序随机
- 如果两者都不在主模块调用,即在一个I/O Circle中调用,那么setImmediate的回调永远先执行,因为会先到Check阶段
【7】setImmediate 对比 process.nextTick
- setImmediate(fn)的回调任务会插入到宏队列Check Queue中
- process.nextTick(fn)的回调任务会插入到微队列Next Tick Queue中
- process.nextTick(fn)调用深度有限制,上限是1000,而setImmedaite则没有
参考链接
- 堆、栈、队列,进程与线程
- 什么是 Event Loop?
- Node中的事件循环
- JS浏览器事件循环机制
- 带你彻底弄懂Event Loop
- 微任务与宏任务的区别