【进阶第7期】任务队列与事件循环(Event Loop)

1,443 阅读15分钟

前言

从浏览器原理我们知道每个tab页面都有自己的渲染进程,而每个渲染进程又有多个线程组成,但是每个渲染进程都有一个主线程,主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入输出事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务。这个系统就是今天的主角 – 事件循环系统:事件循环是在事件驱动模式中来管理和执行事件的一套流程。了解这个系统之前先思考这几个问题:

1636771139(1).png

  • 1.JS为什么是单线程的?
  • 2.为什么需要异步?
  • 3.既然JS是单线程的,只能在一条线程上执行,又是如何实现的异步呢?

注意:本文Event Loop 只针对浏览器,暂不为node展开讨论

进程与线程

1、概念

我们经常说JS 是单线程执行的,指的是一个进程里有且仅有一个主线程负责执行js代码,那到底什么是线程?什么是进程?

官方的说法是:

  • 进程是 CPU资源分配的最小单位;
  • 线程是 CPU调度的最小单位。

2、多进程与多线程

  • 多进程:在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的,比如你可以听歌的同时,打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰。

  • 多线程:程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。

以Chrome浏览器中为例,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如JS 引擎线程、HTTP 请求线程、事件触发线程、GUI渲染线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

而由于单线程原因,主线程一次只能执行一个任务,每次任务执行完成会去消息队列取新的任务执行。接下来我们从以下几个问题来揭开Event Loop神秘面纱

    1. 一个任务执行时间过长,导致主线程长期被霸占,如何优化?==> 引入异步编程,实现非阻塞调用
    1. 如何处理任务优先级? ===》引入任务队列,先进先出来管理任务执行顺序
    1. 紧急任务无法插队?引入宏任务、微任务处理不同任务队列的优先级

Event Loop

js是一门单线程语言,所谓单线程,就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。如果一个任务耗时过长,那么后面的任务就必须一直等待下去,会拖延整个程序。因此,为了解决这个问题,引入Event Loop,将任务分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会交给相应的WebAPIs线程处理,在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。Event Loop模型可以参考下图:

事件循环可以理解成由三部分组成,分别是:

    1. 主线程执行栈
    1. 异步任务等待触发:浏览器为异步任务单独开辟的几个辅佐线程(事件触发线程、Http异步请求线程、GUI渲染线程)可以统一理解为WebAPIs
    1. 异步任务队列:以队列的数据结构对事件任务进行管理,特点是先进先出,后进后出。

这个Event Loop模型有以下特点:

    1. 所有同步任务都会在主线程上执行,同时会形成一个执行栈(execution context stack),直至栈空,即任务结束。
    1. 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,就在任务队列之中放置一个事件。
    1. 一旦执行栈上的任务执行完毕,系统就会从任务队列读取新的任务,结束等待状态,进入执行栈,开始执行,循环往复。

我们可以从C语言角度来理解主线程如何实现事件循环机制

TaskQueue task_queue;
void ProcessTask();
void ProcessDelayTask();
bool keep_running = true;
void MainThread(){
  while (task_queue.waitForTask()) {
    Task task = task_queue.takeTask();// 取出消息队列中任务
    ProcessTask(task);// 执行任务
    ProcessDelayTask()// 执行延迟队列中的任务
    if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}

回调函数(callback)

介绍同步/异步之前,首先来理解下回调函数(callback)的概念,google 解释,非常清晰简明:

A callback is a function that is passed as an argument to another function and is executed after its parent function has completed.

来看几个经典的回调函数代码,我敢保证你一定用过他们

// 异步请求的回调函数
$.get('api/find',function(res){
	console.log(res)
})
// 点击事件的回调函数
$('.btn').click(function(){
	alert('this is a callback of click event')
})
// 数组中遍历每一项调用的回调函数
[1,2,3].forEach(function(item){
	console.log(item)
})
// 同步回调
function getNodes(params,callback){
	var list = JSON.parse(params)
    if(typeof callback === 'function'){
    	callback(list)
    }
}
getNodes('[1,2,3]',function(list){
	// ....
})

总而言之,回调与同步、异步并没有直接的联系,回调只是一种实现方式,既可以有同步回调,也可以有异步回调,还可以有事件处理回调和延迟函数回调。

同步 VS 异步

同步就是后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的。

异步则完全不同,从程序角度来理解就是改变程序正常执行顺序的操作,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

JS是一门单线程语言,同步很容易理解。那么异步是如何实现的?

JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程执行完主线任务后从任务队列取出任务事件继续执行。

从同步异步角度来理解JS的执行机制

console.log(1)
setTimeout(function(){// 200ms后,新任务task_1进入 任务队列
    console.log(2)
},200)
console.log(3)

输出结果很明显: 1>3>2,但是整个思路我们需要理解(这里暂不区分宏任务、微任务)

    1. 整体script作为第一个任务进入主线程,console输出1;
    1. 遇到异步APIsetTimeout,将异步回调函数交给Web API处理(此处为定时器触发线程,200ms之后,即满足触发条件后,将task_1推入任务队列task queue)。
    1. 主线程继续往下执行,console输出1,任务执行结束,调用栈为空
    1. 进入下一个循环,取出任务队列中的下个任务,此时任务队列为空,主线程进入等待状态。
    1. 直到200ms之后,发现新推入任务队列的task_1,开始执行,console输出2

从底层原理分析来看:JS 在解析一段代码的时候,会将同步代码放在某个地方(即执行栈),依次执行里面的函数。当遇到异步任务的时候,就交给其它线程来处理,等待当前执行栈所有同步代码执行完成(即执行栈为空)。它会从某个队列(可能是宏任务队列,也可能是微任务队列)中取出已完成的异步任务的回调。加入执行栈,继续执行,再次遇到异步任务又交给其它线程去完成,通过这样的一个循环,执行完所有代码。

总结一句话:同步/异步指的是各个任务之间执行顺序的确定性。同时,任务≠回调函数,不管是同步任务,异步任务都可以通过回调函数去实现。同步任务(代码)在执行栈中顺序执行,异步任务会交给其它线程来完成,等到执行栈为空会去检查异步任务是否完成来执行完所有代码。

异步任务

javascript 是一门单线程的脚本语言,也就意味着同一个时间只能做一件事,但是单线程有一个问题:一旦这个线程被阻塞就无法继续工作了,这肯定是不行的。上面谈的的EventLoop 模型通过异步编程实现非阻塞的调用效果方式解决了一个任务长时间霸占线程问题,但由于队列是一种数据结构,可以存放要执行的任务。它符合队列先进先出的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取,但这不能解决任务优先级问题(紧急任务插队的需求)。为解决这个问题引入宏任务,微任务概念。

宏任务 VS 微任务

ES6 规范中,Microtask 称为 jobs,Macrotask 称为 task。即微任务是ES对异步的定义;而宏任务是浏览器对异步的定义。

  • 宏任务与微任务都是独立与主执行栈之外的另外两个队列。
  • 为了处理任务的优先级,权衡效率和实时性。浏览器端事件循环中的异步队列有两种:Macrotask(宏任务)队列和 Microtask(微任务)队列.
宏任务(Macrotask)微任务(Microtask)
谁发起的浏览器、Nodejavascript
具体事件script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI renderingprocess.nextTick, Promise的then或catch, Object.observer, MutationObserver

事件循环这个机制当中,我们将进行一次循环操作称为tick,每一次tick的任务处理模型是比较复杂的,但关键步骤如下:

    1. 进入循环,首先选择最先进入宏任务队列的任务(oldest task),如果有则执行(一次)
    1. 检查是否存在 Microtasks,如果存在则不停地执行,直至清空微任务(Microtasks Queue),此时执行栈也为清空了。
    1. GUI线程更新(render)界面(与主线程互斥)
    1. 进入下一个Tick 主线程重复执行上述步骤

Event Loop的模型(Macrotask + Microtask)

这个Event Loop 模型运行机制如下:

    1. 选择当前要执行的宏任务队列,选择任务队列中最先进入的任务(oldest task),如果宏任务队列为空即 null,则执行跳转到微任务(MicroTask)的执行步骤。
    1. 将事件循环中的任务设置为已选择任务。
    1. 执行任务。当执行栈中的函数调用到一些异步执行的API(例如异步Ajax,DOM事件,setTimeout等API),则会开启对应的线程(Http异步请求线程,事件触发线程和定时器触发线程)进行监控和控制,当异步任务的事件满足触发条件时,对应的线程则会把该事件的回调函数推进任务队列(task queue)中,等待主线程读取执行。
    1. 任务结束后,将事件循环中当前运行任务设置为 null,同时将已经运行完成的任务从任务队列中删除。
    1. 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. 更新界面渲染。
    1. 返回第一步。

流程图参考下图:

来个简单的例子加深理解,

console.log('script start')
new Promise((resolve)=>{
    console.log('resolve1')
    resolve()
}).then(()=>{ // 创建微任务 micro_1
	console.log('promise1')
})

setTimeout(()=>{ // 创建了一个setTimeout的宏任务 macro_1
    console.log('timeout') 
})
new Promise((resolve)=>{
    console.log('resolve2')
    resolve()
}).then(()=>{ // 创建微任务 micro_2
	console.log('promise2')
})

console.log('script end')
  • 1、整体script作为第一个宏任务进入主线程,console输出script start

  • 2、遇到new Promise, 入栈处理,发现是同步回调,直接执行,console输出resolve1;遇到then,入栈处理,发现是异步回调函数(创建微任务micro_1),出栈,移交给对应Web API处理,将回调函数加入微任务队列尾部;

  • 3、遇到setTimeout入栈处理,发现是异步回调函数(创建宏任务macro_1),出栈,移交给Web API(此处为定时器触发线程)处理(0秒等待后,将回调函数加到宏任务队列尾部);

  • 4、遇到new Promise, 入栈处理,发现是同步回调,直接执行,console输出resolve2;遇到then,入栈处理,发现是异步回调(创建微任务micro2),出栈,移交给Web API处理,将回调函数加入微任务队列尾部;

  • 5、执行到script任务末尾,console输出script end, 此时执行栈已清空(将当前任务从任务队列移除),进入microtask检查点,此时任务队列情况如下:

    任务队列任务1任务2
    宏任务队列1macro_1
    微任务队列micro_1micro_2
  • 6、取出第一个微任务,入栈处理,console直接输出promise1, 出栈;

  • 7、继续从微任务队列中取下一个,入栈处理,console直接输出promise1,出栈,

  • 8、继续从微任务队列中取下一个,发现微任务队列已清空,

  • 9、渲染界面,结束第一轮事件循环;

  • 10、从宏任务队列中取出第一个宏任务,入栈处理,发现是console直接输出setimeout,未发现有微任务,再次渲染界面,结束本轮事件循环。

任务的优先级

Event Loop事件循环是通过任务队列的机制来协调工作的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。

源自HML规范文档: 有这么一段说明

An event loop has one or more task queues. A task queue is a set of tasks.

来个例子加深对任务源的理解:

<div id="outer">
    <button id="inner">点击我试试</button>
</div>
<script type="text/javascript">
    const $inner = document.querySelector('#inner')
    const $outer = document.querySelector('#outer')

    function handler() {
        console.log('click'// 直接输出  (js同步代码)
        Promise.resolve().then(_ => console.log('promise')) // 注册微任务

        setTimeout(() => {
            console.log('timeout')
            Promise.resolve().then(_ => console.log('timeout>promise'))
        }) // 注册宏任务

        requestAnimationFrame(_ => {
            console.log('animationFrame')
            Promise.resolve().then(_ => console.log('animationFrame>promise'))
        }) // 注册宏任务

        $outer.setAttribute('data-random'Math.random())        // DOM属性修改,触发微任务
    }


    new MutationObserver(_ => {
      console.log('observer')
    }).observe($outer, {
      attributes: true
    })

    $inner.addEventListener('click', handler)
    $outer.addEventListener('click', handler)

    // 左边是高优先级
    // 宏任务: requestAnimationFrame=>setTimeout => setInterval => setImmediate(nodeJS) => I/O => UI Rendering
    // 微任务: process.nextTick(nodeJS) => Promise(Promise.then)/mutationObserver
</script>

点击#outter输出结果,可以看出:requestAnimationFrame 优先级比 setTimeout 高

// Tick1
click
resolve1
promise
observer
// Tick2
animationFrame
animationFrame>promise
// Tick3
timeout
timeout>promise

点击#inner输出结果,可以看出:每个macroTask队列中的macroTask按顺序执行,在每macroTask之间渲染页面

// Tick1
click
resolve1
promise
observer
click
resolve1
promise
animationFrame>promise1
observer
// Tick2
animationFrame
animationFrame>promise
// Tick3
animationFrame
animationFrame>promise
// Tick4
timeout
timeout>promise
// Tick5
timeout
timeout>promise

结论:

  • 每个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)

彩蛋: 最后再来猜猜这个运行结果?

new Promise((resolve)=>{
    console.log('resolve1')
    setTimeout(function(){
		resolve()
    },3000)
}).then(()=>{ // 创建微任务 micro_1
    console.log('promise1')
})

new Promise((resolve)=>{
    console.log('resolve2')
    setTimeout(function(){
		resolve()
    },2000)
}).then(()=>{ // 创建微任务 micro_2
	console.log('promise2')
})

拓展