前言
每个tab页面都有自己的渲染进程,而每个渲染进程又由多个线程组成,每个渲染进程都有一个主线程,主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要⼀个系统来统筹调度这些任务。这个系统就是今天的主角 – 消息队列和事件循环系统。
JS为什么是单线程的?为什么需要异步?既然 JS 是单线程的,只能在⼀条线程上执⾏,⼜是如何实现的异步呢?
本文Event Loop 只针对浏览器,暂不为 node 展开讨论。
进程与线程
概念
JS是单线程执行的,指的是一个进程里有且只有一个主线程负责执行js代码。
进程是CPU资源分配的最小单位。线程是CPU调度的最小单位。
多线程与多进程
多进程是指同一个时间里,同一个计算机系统允许两个或者两个以上的进程处于运行状态。比如可以在听歌的同时写代码。
多线程是指程序中包含多个执行流,一个程序中可以同时运行多个不同的线程来执行不同的任务,允许单个程序创建多个并行执行的线程来完成各自的任务。⽐如打开 QQ 的这个进程,可能同时有接收消息线程、传输⽂件线程、检测安全线程…… 所以⼀个⽹⻚能够正常的运⾏并和⽤户交互,也需要很多个进程之间相互配合。
浏览器打开一个Tab页就是创建了一个进程,一个进程可以有多个线程,JS 引擎线程、HTTP 请求线程、事件触发线程、GUI 渲染线程等等。发起请求是就创建了一个线程,请求结束后线程可能被销毁。
由于单线程原因,主线程一次只能执行一个任务,每次任务执行完成会去消息队列取新的任务执行。
- 一个任务执行时间过长,导致主线程长期被霸占,如何优化?==> 引入异步编程,实现
非阻塞调用 - 如何处理任务优先级? ===》引⼊任务队列,先进先出来管理任务执行顺序
- 紧急任务无法插队?===》引入宏任务、微任务处理不同任务队列的优先级
浏览器
既然 JS 是单线程的,那么诸如 onclick 回调,setTimeout,Ajax 这些都是怎么实现的呢?是因为浏览器或 node(宿主环境)是多线程的,即浏览器搞了⼏个其他线程去辅助 JS 线程的运⾏。
浏览器有很多线程,例如:
- GUI 渲染线程
- JS 引擎线程
- 定时器触发线程 (setTimeout)
- 浏览器事件线程 (onclick)
- http 异步线程
- EventLoop 轮询处理线程
其中,1、2、4 为常驻线程。
分类:
类别 A:GUI 渲染线程
类别 B:JS 引擎线程
类别 C:EventLoop 轮询处理线程
类别 D:其他线程,有 定时器触发线程 (setTimeout)、http 异步线程、浏览器事件线程 (onclick)等等。
JS 引擎线程
JS 引擎线程,我们把它称为主线程,即运⾏ JS 代码的那个线程(不包括异步的那些代码)
1 | var a = 2; |
第 1、4 ⾏代码是同步代码,直接在主线程中运⾏;第 2、3 ⾏代码交给其他线程运⾏。
主线程运⾏ JS 代码时,会⽣成个执⾏栈,可以处理函数的嵌套。
消息队列(任务队列)
可以理解为⼀个静态的队列存储结构,⾮线程,只做存储,⾥⾯存的是⼀堆异步成功后的回调函数字符串,肯定是先成功的异步的回调函数在队列的前⾯,后成功的在后⾯。
注意:是异步成功后,才把其回调函数扔进队列中,⽽不是⼀开始就把所有异步的回调函数扔进队列。⽐如 setTimeout 3 秒后执⾏⼀个函数,那么这个函数是在 3 秒后才进队列的。
其他线程
定时器触发线程 (setTimeout)、http 异步线程、浏览器事件线程 (onclick)
主线程执⾏ JS 代码时,碰到异步代码,就把它丢给各⾃相对应的线程去执⾏,⽐如:
1 | var a = 2; |
主线程在运⾏这段代码时,碰到 2 setTimeout (fun A),把这⾏代码交给定时器触发线程去执⾏;碰到 3 ajax (fun B),把这⾏代码交给 http 异步线程去执⾏;碰到 5 dom.onclick (func C) ,把这⾏代码交给浏览器事件线程去执⾏。
注意: 这⼏个异步代码的回调函数 fun A,fun B,fun C,各⾃的线程都会保存着的,因为需要在未来的某个时候,将回调函数交给主线程去执⾏。
作用:
- 执⾏主线程扔过来的异步代码,并执⾏代码
- 保存回调函数,在未来的某个时刻,通知 EventLoop 轮询处理线程过来取相应的回调函数然后执⾏(下⾯会讲)
区别:
对于 setTimeout 代码,定时器触发线程在接收到代码时就开始计时,时间到了将回调函数扔进队列。
对于 ajax 代码,http 异步线程⽴即发起 http 请求,请求成功后将回调函数扔进队列。
对于 dom.onclick,浏览器事件线程会先监听 dom,直到 dom 被点击了,才将回调函数扔进队列。
EventLoop 轮询处理线程
- 主线程,处理同步代码
- 类别 D 的⼏个异步线程,处理异步代码
- 消息队列,存储着异步成功后的回调函数,⼀个静态存储结构
消息队列作⽤就是存放着未来要执⾏的回调函数
1 | setTimeout(() => { |
在⼀开始,消息队列是空的,在 2 秒后,⼀个 () => { console.log (1) } 的函数进⼊队列,在 3 秒后,⼀个 () => { console.log (2) } 的函数进⼊队列,此时队列⾥有两个元素,主线程从队列头中挨个取出并执⾏。
这需要⼀个中介去专⻔去沟通它们 3 个,⽽这个中介,就是 EventLoop 轮询处理线程
Event Loop(事件循环)
JS是一种单线程语言,一次只能完成一个任务。如果有多个任务,就必须排队,前面一个任务完成再完成下面的任务。如果前面的任务过长,后面的就会一直等待,拖延整个程序。为了解决这个问题,引入Event Loop,将任务分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会交给相应的WebAPIs 线程处理,在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。
事件循环可以理解为由三部分组成:
- 主线程执行栈
- 异步任务等待触发:浏览器为异步任务单独开辟的几个辅佐线程 (事件触发线程、Http异步请求线程、GUI 渲染线程) 可以统⼀理解为 WebAPIs
- 异步任务队列:以队列的数据结构对事件任务进行管理,特点是先进先出,后进后出。
模型特点:
- 所有同步任务都会在主线程上执行,同时会形成一个执行栈(execution context
stack),直至栈空,即任务结束。 - 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,就在
任务队列之中放置⼀个事件。 - 一旦执行栈上的任务执行完毕,系统就会从任务队列读取新的任务,结束等待状态,进入执行栈,开始执行,循环往复。
从C语言角度理解:
1 | TaskQueue task_queue; |
callback
A callback is a function that is passed as an argument to another function and is executed after its parent function has completed.
回调是作为参数传递给另一个函数并在其父函数完成后执行的函数。
1 | // 异步请求的回调函数 |
既可以有同步回调,也可以有异步回调,还可以有事件处理回调和延迟函数回调。
同步vs异步
同步就是后⼀个任务等待前⼀个任务结束,然后再执⾏,程序的执⾏顺序与任务的排列顺序是⼀致的、同步的。
异步则完全不同,从程序⻆度来理解就是改变程序正常执⾏顺序的操作,每⼀个任务有⼀个或多个回调函数(callback),前⼀个任务结束后,不是执⾏后⼀个任务,⽽是执⾏回调函数,后⼀个任务则是不等前⼀个任务结束就执⾏,所以程序的执⾏顺序与任务的排列顺序是不⼀致的、异步的。
JS作为一种单线程的语言如何实现异步的?
JS 异步的实现靠的就是浏览器的多线程,当他遇到异步 API 时,就将这个任务交给对应的线程,当这个异步 API 满⾜回调条件时,对应的线程⼜通过事件触发线程将这个事件放⼊任务队列,然后主线程执⾏完主线任务后从任务队列取出任务事件继续执⾏。
总结:
同步 / 异步指的是各个任务之间执⾏顺序的确定性。同时, 任务≠回调函数 , 不管是同步任务,异步任务都可以通过回调函数去实现。
从同步异步角度理解JS的执行机制
1 | console.log(1) |
输出结果是132,思路(暂不考虑宏任务微任务):
- 整体 script 作为第⼀个任务进⼊主线程,console 输出 1;
- 遇到异步 API setTimeout ,将异步回调函数交给 Web API 处理 (此处为定时器触发线程,200ms 之后,即满⾜触发条件后,将 task_1 推⼊任务队列 task queue )。
- 主线程继续往下执⾏,console 输出3,任务执⾏结束,调⽤栈为空
- 进⼊下⼀个循环,取出任务队列中的下个任务,此时任务队列为空,主线程进⼊等待状态。
- 直到 200ms 之后,发现新推⼊任务队列的 task_1 , 开始执⾏,console 输出 2
异步任务
javascript 是⼀⻔单线程的脚本语⾔,也就意味着同⼀个时间只能做⼀件事,但是单线程有⼀个问题:⼀旦这个线程被阻塞就⽆法继续⼯作了,这肯定是不⾏的。上⾯谈的EventLoop 模型通过异步编程实现⾮阻塞的调⽤效果⽅式解决了⼀个任务⻓时间霸占线程问题,但由于队列是⼀种数据结构,可以存放要执⾏的任务。它符合队列先进先出的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取,但这不能解决任务优先级问题 (紧急任务插队的需求)。为解决这个问题引⼊宏任务,微任务概念。
宏任务VS微任务
ES6 规范中,Microtask 称为 jobs,Macrotask 称为 task。即微任务是 ES 对异步的定义;⽽宏任务是浏览器对异步的定义。
宏任务与微任务都是独⽴于主执⾏栈之外的另外两个队列。
为了处理任务的优先级,权衡效率和实时性。浏览器端事件循环中的异步队列有两种:Macrotask(宏任务)队列和 Microtask(微任务)队列.
宏任务 (Macrotask) | 微任务 (Microtask) | |
---|---|---|
谁发起 | 浏览器、Node | Javascript |
具体事件 | script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering | process.nextTick, Promise 的 then 或 catch, Object.observer, MutationObserver |
在事件循环这个机制当中,我们将进⾏⼀次循环操作称为 tick ,每⼀次 tick 的任务处理模型是⽐较复杂的,但关键步骤如下:
- 进⼊循环,⾸先选择最先进⼊宏任务队列的任务 (oldest task),如果有则执⾏ (⼀次)
- 检查是否存在 Microtasks,如果存在则不停地执⾏,直⾄清空微任务 (Microtasks Queue),此时执⾏栈也为清空了。
- GUI 线程更新 (render) 界⾯(与主线程互斥)
- 进⼊下⼀个 Tick 主线程重复执⾏上述步骤
注意:new Promise 执⾏本身时是属于同步代码,只有.then 才是微任务
Event Loop 的模型 (Macrotask + Microtask)
这个 Event Loop 模型运⾏机制如下:
- 选择当前要执⾏的宏任务队列,选择任务队列中最先进⼊的任务 ( oldest task ),如果
宏任务队列为空即 null,则执⾏跳转到微任务(MicroTask)的执⾏步骤。 - 将事件循环中的任务设置为已选择任务。
- 执⾏任务。当执⾏栈中的函数调⽤到⼀些异步执⾏的 API(例如异步 Ajax,DOM 事件,
setTimeout 等 API),则会开启对应的线程(Http 异步请求线程,事件触发线程和定时
器触发线程)进⾏监控和控制,当异步任务的事件满⾜触发条件时,对应的线程则会把
该事件的回调函数推进任务队列 (task queue) 中,等待主线程读取执⾏。 - 任务结束后,将事件循环中当前运⾏任务设置为 null,同时将已经运⾏完成的任务从任
务队列中删除。 - microtasks 步骤:进⼊ microtask 检查点。⽤户代理会执⾏以下步骤:
5.1 设置 microtask 检查点标志为 true。
5.2 当事件循环 microtask 执⾏不为空时:选择⼀个最先进⼊的 microtask 队列的
microtask,将事件循环的 microtask 设置为已选择的 microtask,运⾏ microtask,将已
经执⾏完成的 microtask 置为 null,移出 microtask 中的 microtask。
5.3 清理 IndexDB 事务
5.4 设置进⼊ microtask 检查点的标志为 false。 - 更新界⾯渲染。
- 返回第⼀步。
流程图:
之前说的所有异步都放进⼀个任务消息队列⾥,现在也就是分为两个任务队列了,⽐较容易理解。
1 | console.log('script start') |
整体 script 作为第⼀个宏任务进⼊主线程,console 输出 script start ,
遇到 new Promise, ⼊栈处理,发现是同步回调,直接执⾏,console 输出 resolve1 ;
遇到 then,⼊栈处理,发现是异步回调函数(创建微任务 micro_1),出栈,移交给对应Web API 处理,将回调函数加⼊微任务队列尾部;遇到 setTimeout ⼊栈处理,发现是异步回调函数(创建宏任务 macro_1),出栈,移交给 Web API(此处为定时器触发线程)处理 (0 秒等待后,将回调函数加到宏任务队列尾部);
遇到 new Promise, ⼊栈处理,发现是同步回调,直接执⾏,console 输出 resolve2;遇到 then,⼊栈处理,发现是异步回调(创建微任务 micro2),出栈,移交给 Web API 处理,将回调函数加⼊微任务队列尾部;
执⾏到 script 任务末尾,console 输出 script end , 此时执⾏栈已清空 (将当前任务从任
务队列移除),进⼊ microtask 检查点,此时任务队列情况如下:任务队列 任务1 任务2 宏任务队列1 macro_1 微任务队列 micro_1 micro_2 取出第⼀个微任务,⼊栈处理,console 直接输出 promise1 , 出栈;
继续从微任务队列中取下⼀个,⼊栈处理,console 直接输出 promise2 ,出栈,
继续从微任务队列中取下⼀个,发现微任务队列已清空,
渲染界⾯,结束第⼀轮事件循环;
从宏任务队列中取出第⼀个宏任务,⼊栈处理,发现是 console 直接输出 timeout,未发现有微任务,再次渲染界⾯,结束本轮事件循环。
任务的优先级
Event Loop事件循环是通过任务队列的机制来协调⼯作的。⼀个 Event Loop 中,可以有⼀个或者多个任务队列 (task queue),⼀个任务队列便是⼀系列有序任务 (task) 的集合;每个任务都有⼀个任务源 (task source),源⾃同⼀个任务源的 task 必须放到同⼀个任务队列,从不同源来的则被添加到不同队列。
一个事件循环有一个或多个任务队列。 任务队列是一组任务。
1 | <div id="outer"> |
点击#outter 输出结果,可以看出:requestAnimationFrame 优先级⽐ setTimeout ⾼
1 | // Tick1 |
点击#inner 输出结果,可以看出:每个 macroTask 队列中的 macroTask 按顺序执⾏,在每
macroTask 之间渲染⻚⾯
1 | // Tick1 |
每个 macroTask 队列中的 macroTask 按顺序执⾏,在每个 macroTask 之间渲染⻚⾯
⼀个 macroTask 执⾏结束 (即 js 执⾏栈中为空),会⽴即处理 macroTask 执⾏过程中产⽣
的 microTask 并且按顺序执⾏。microTask 产⽣的 macroTask 会⾃动加⼊相应的宏任务
队列。
每次循环会把这次宏任务产⽣的所有微任务执⾏完,再进⾏下⼀次 loop。
最后
本⽂回答了渲染进程如何利⽤消息队列和事件循环机制完成⻚⾯协调各个线程⼯作的。
1. JS为什么是单线程的?
想象⼀下,假设浏览器中的 JS 是多线程的(⼀个进程中资源共享),如果现在有 2 个线程,thread1 thread2, 由于是多线程的 JS, 所以他们可以对同⼀个 dom, 同时进⾏操作thread1 删除了该 dom, ⽽ thread2 编辑了该 dom,2 个⽭盾的命令同时下达,浏览器究竟该如何执⾏呢?
虽然 JS 是单线程,但是浏览器总共开了四个线程参与了 JS 的执⾏,其他三个只是辅助,不参与解析与执⾏: 1. JS 引擎线程(主线程,只有这个线程负责解析和执⾏ JS 代码) 2. 事件触发线程 3. 定时器触发线程 4. HTTP 异步请求线程
永远只有 JS 引擎线程在执⾏ JS 脚本程序,其他三个线程只负责将满⾜触发条件的处理函数推进任务队列,等待 JS 引擎线程执⾏
2. 为什么需要异步?
如果 JS 中不存在异步,只能⾃上⽽下执⾏,如果上⼀⾏解析执⾏时间很⻓,那么下⾯的代码就会被阻塞。 对于⽤户⽽⾔,阻塞就意味着 “卡死”, 这样就导致了很差的⽤户体验。
3. 既然 JS 是单线程的,只能在一条线程上执行,又是如何实现的异步呢?
答案就是事件循环(Event loop)
例子
1
1 | new Promise((resolve)=>{ |
2
1 | var a = 111; |
步骤1:
主线程只执⾏了 var a = 111; 和 console.log (555) 两⾏代码,其他的代码分别交给了其他三个线程,因为其他线程需要 2、3、4 秒钟才成功并回调,所以在 2 秒之前,主线程⼀直在空闲,不断的探查队列是否不为空。
此时主线程⾥其实已经是空的了(因为执⾏完那两⾏代码了)
步骤2:
步骤3:
步骤4:
图⾥的队列⾥都只有⼀个回调函数,实际上有很多个回调函数,如果主线程⾥执⾏的代码复杂需要很⻓时间,这时队列⾥的函数们就排着,等着主线程啥时执⾏完,再来队列⾥取
所以从这⾥能看出来,对于 setTimeout,setInterval 的定时,不⼀定完全按照设想的时间的,因为主线程⾥的代码可能复杂到执⾏很久,所以会发⽣你定时 3 秒后执⾏,实际上是 3.5 秒后执⾏(主线程花费了 0.5 秒)
3
1 | console.log('1'); |
每次执⾏完⼀个宏任务后都要去检查微任务就可以了。
4
1 | <script> |
执⾏结果:start > promise4 > end > promise5 > timer1 > promise1 > timer2 > promise2 > timer3 > promise3
5
1 | console.log("script start"); |
宏任务:执行整体代码(相当于