事件循环

2025-01-3127分钟 24秒

前言

1. 进程和线程

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。

我的理解,一个程序运行,至少有一个进程,一个进程至少有一个线程,进程是操作系统分配内存资源的最小单位,线程是 cpu 调度的最小单位。

打个比方,进程好比一个工厂,线程就是里面的工人,工厂内有多个工人,里面的工人可以共享里面的资源,多个工人可以一起协调工作,类似于多线程并发执行。

2. 浏览器是多进程的

打开 windows 任务管理器,可以看到浏览器开了很多个进程,每一个 tab 页都是单独的一个进程,所以一个页面崩溃以后并不会影响其他页面

浏览器包含下面几个进程:

  • Browser 进程:浏览器的主进程(负责协调、主控),只有一个
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU 进程:最多一个,用于 3D 绘制等
  • 浏览器渲染进程(浏览器内核)(Renderer 进程,内部是多线程的):默认每个 Tab 页面一个进程,互不影响

3. 浏览器渲染进程

浏览器渲染进程是多线程的,也是一个前端人最关注的,它包括下面几个线程:

  • GUI 渲染线程
    • 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
  • JS 引擎线程
    • 也称为 JS 内核,负责处理 Javascript 脚本程序。(例如 V8 引擎)
    • JS 引擎线程负责解析 Javascript 脚本,运行代码。
    • JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序
    • 同样注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  • 事件触发线程
    • 归属于浏览器而不是 JS 引擎,用来控制事件循环(可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程, 如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理
    • 注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)
  • 定时触发器线程
    • 传说中的 setInterval 与 setTimeout 所在线程
    • 浏览器定时计数器并不是由 JavaScript 引擎计数的, (因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)
    • 注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。
  • 异步 http 请求线程
    • 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求
    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

4. JS 引擎是单线程的

为什么 js 引擎是单线程的,一个原因是多线程复杂度会更高,另一个问题是结果可能是不可预期的:假设 JS 引擎是多线程的,有一个 div,A 线程获取到该节点设置了属性,B 线程又删除了该节点,so what?多线程并发执行下该怎么操作呢?

或许这就是为什么 JS 引擎是单线程的,代码从上而下顺序的预期执行,虽然降低了编程成本,但也有其他问题,如果某个操作很耗时间,比如,某个计算操作 for 循环遍历 10000 万次,就会阻塞后面的代码造成页面卡顿... ...

GUI 渲染线程与 JS 引擎线程互斥的,是为了防止渲染出现不可预期的结果,因为 JS 是可以获取 dom 的,如果修改这些元素属性同时渲染界面(即 JS 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。所以 JS 线程执行的时候,渲染线程会被挂起;渲染线程执行的时候,JS 线程会挂起,所以 JS 会阻塞页面加载,这也是为什么 JS 代码要放在 body标签之后,所有html内容之前;为了防止阻塞页面渲造成白屏

5. WebWorker

上面说了,JS 是单线程的,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

Web Worker,是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

Web Worker 有几个特点:

  • 同源限制:分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
  • DOM 限制:不能操作 DOM
  • 通信联系:Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。
  • 脚本限制:不能执行 alert()方法和 confirm()方法
  • 文件限制:无法读取本地文件

6. 浏览器渲染流程

下面是浏览器渲染页面的简单过程,详细讲又可以开一篇文章了~. ~:《从输入 URL 到页面渲染完成发生了什么》

  1. 用户输入 url ,DNS 解析成请求 IP 地址
  2. 浏览器与服务器建立连接(tcp 协议、三次握手),服务端处理返回html代码块
  3. 浏览器接受处理,解析 html 成 dom 树、解析 css 成 cssobj
  4. dom 树、cssobj 结合成 render 树
  5. JS 根据 render 树进行计算、布局、重绘
  6. GPU 合成,输出到屏幕

JavaScript是单线程,非阻塞的

单线程:

JavaScript的主要用途是与用户互动,以及操作DOM。如果它是多线程的会有很多复杂的问题要处理,比如有两个线程同时操作DOM,一个线程删除了当前的DOM节点,一个线程是要操作当前的DOM阶段,最后以哪个线程的操作为准?为了避免这种,所以JS是单线程的。即使H5提出了web worker标准,它有很多限制,受主线程控制,是主线程的子线程。

非阻塞:通过 event loop 实现。

浏览器的事件循环

执行栈和事件队列

为了更好地理解Event Loop,请看下图(转引自Philip Roberts的演讲 《Help, I'm stuck in an event-loop》

执行栈: 同步代码的执行,按照顺序添加到执行栈中

function a() {
  b();
  console.log('a');
}
function b() {
  console.log('b')
}
a();

我们可以通过使用 Loupe(Loupe是一种可视化工具,可以帮助您了解JavaScript的调用堆栈/事件循环/回调队列如何相互影响)工具来了解上面代码的执行情况。

  1. 执行函数 a()先入栈
  2. a()中先执行函数 b() 函数b() 入栈
  3. 执行函数b(), console.log('b') 入栈
  4. 输出 b, console.log('b')出栈
  5. 函数b() 执行完成,出栈
  6. console.log('a') 入栈,执行,输出 a, 出栈
  7. 函数a 执行完成,出栈。

事件队列: 异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

我们再上面代码的基础上添加异步事件,

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
    setTimeout(function() {
        console.log('c');
    }, 2000)
}
a();

此时的执行过程如下

我们同时再加上点击事件看一下运行的过程

$.on('button', 'click', function onClick() {
  setTimeout(function timer() {
    console.log('You clicked the button!');    
  }, 2000);
});
 
console.log("Hi!");
 
setTimeout(function timeout() {
  console.log("Click the button!");
}, 5000);
 
console.log("Welcome to loupe.");

简单用下面的图进行一下总结

宏任务和微任务

为什么要引入微任务,只有一种类型的任务不行么?

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

不同的异步任务被分为:宏任务和微任务
宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任务:

  • new Promise().then(回调)
  • MutationObserver(html5 新特性)

运行机制

异步任务的返回结果会被放到一个任务队列中,根据异步事件的类型,这个事件实际上会被放到对应的宏任务和微任务队列中去。

在当前执行栈为空时,主线程会查看微任务队列是否有事件存在

  • 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。
  • 如果不存在,那么再去宏任务队列中取出一个事件并把对应的回调加入当前执行栈;

当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

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

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

简单总结一下执行的顺序:
执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

深入理解js事件循环机制(浏览器篇) 这边文章中有个特别形象的动画,大家可以看着理解一下。

console.log('start')
 
setTimeout(function() {
  console.log('setTimeout')
}, 0)
 
Promise.resolve().then(function() {
  console.log('promise1')
}).then(function() {
  console.log('promise2')
})
 
console.log('end')

  1. 全局代码压入执行栈执行,输出 start
  2. setTimeout压入 macrotask队列,promise.then 回调放入 microtask队列,最后执行 console.log('end'),输出 end
  3. 调用栈中的代码执行完成(全局代码属于宏任务),接下来开始执行微任务队列中的代码,执行promise回调,输出 promise1, promise回调函数默认返回 undefined, promise状态变成 fulfilled ,触发接下来的 then回调,继续压入 microtask队列,此时产生了新的微任务,会接着把当前的微任务队列执行完,此时执行第二个 promise.then回调,输出 promise2
  4. 此时,microtask队列 已清空,接下来会会执行 UI渲染工作(如果有的话),然后开始下一轮 event loop, 执行 setTimeout的回调,输出 setTimeout

最后的执行结果如下

  • start
  • end
  • promise1
  • promise2
  • setTimeout

node环境下的事件循环

和浏览器环境有何不同

表现出的状态与浏览器大致相同。不同的是 node 中有一套自己的模型。node 中事件循环的实现依赖 libuv 引擎。Node的事件循环存在几个阶段。

如果是node10及其之前版本,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask队列中的任务。

node版本更新到11之后,Event Loop运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,跟浏览器趋于一致。下面例子中的代码是按照最新的去进行分析的。

事件循环模型

   ┌───────────────────────┐
┌─>timers
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks
   └───────────────────────┘

事件循环各阶段详解

node中事件循环的顺序

外部输入数据 --> 轮询阶段(poll) --> 检查阶段(check) --> 关闭事件回调阶段(close callback) --> 定时器检查阶段(timer) --> I/O 事件回调阶段(I/O callbacks) --> 闲置阶段(idle, prepare) --> 轮询阶段...

这些阶段大致的功能如下:

  • 定时器检测阶段(timers): 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。
  • I/O事件回调阶段(I/O callbacks): 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • 闲置阶段(idle, prepare): 这个阶段仅在内部使用,可以不必理会
  • 轮询阶段(poll): 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • 检查阶段(check): setImmediate()的回调会在这个阶段执行。
  • 关闭事件回调阶段(close callbacks): 例如socket.on('close', ...)这种close事件的回调

poll:
这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
check:
该阶段执行setImmediate()的回调函数。

close:
该阶段执行关闭请求的回调函数,比如socket.on('close', ...)。

timer阶段:
这个是定时器阶段,处理setTimeout()和setInterval()的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。

I/O callback阶段:
除了以下的回调函数,其他都在这个阶段执行:

  • setTimeout()和setInterval()的回调函数
  • setImmediate()的回调函数
  • 用于关闭请求的回调函数,比如socket.on('close', ...)

宏任务和微任务

宏任务:

  • setImmediate
  • setTimeout
  • setInterval
  • script(整体代码)
  • I/O 操作等。

微任务:

  • process.nextTick
  • new Promise().then(回调)

Promise.nextTick, setTimeout, setImmediate的使用场景和区别

Promise.nextTick
process.nextTick 是一个独立于 eventLoop 的任务队列。
在每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。
是所有异步任务中最快执行的。

setTimeout:
setTimeout()方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。

setImmediate:
setImmediate()方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即poll阶段之后。

经典题目分析

一. 下面代码输出什么

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});
console.log('script end');

先执行宏任务(当前代码块也算是宏任务),然后执行当前宏任务产生的微任务,然后接着执行宏任务

  1. 从上往下执行代码,先执行同步代码,输出 script start
  2. 遇到setTimeout,现把 setTimeout 的代码放到宏任务队列中
  3. 执行 async1(),输出 async1 start, 然后执行 async2(), 输出 async2,把 async2() 后面的代码 console.log('async1 end')放到微任务队列中
  4. 接着往下执行,输出 promise1,把 .then()放到微任务队列中;注意Promise本身是同步的立即执行函数,.then是异步执行函数
  5. 接着往下执行, 输出 script end。同步代码(同时也是宏任务)执行完成,接下来开始执行刚才放到微任务中的代码
  6. 依次执行微任务中的代码,依次输出 async1 end、 promise2, 微任务中的代码执行完成后,开始执行宏任务中的代码,输出 setTimeout

最后的执行结果如下

  • script start
  • async1 start
  • async2
  • promise1
  • script end
  • async1 end
  • promise2
  • setTimeout

二. 下面代码输出什么

console.log('start');
setTimeout(() => {
  console.log('children2');
  Promise.resolve().then(() => {
    console.log('children3');
  })
}, 0);
 
new Promise(function(resolve, reject) {
  console.log('children4');
  setTimeout(function() {
    console.log('children5');
    resolve('children6')
  }, 0)
}).then((res) => {
  console.log('children7');
  setTimeout(() => {
    console.log(res);
  }, 0)
})

这道题跟上面题目不同之处在于,执行代码会产生很多个宏任务,每个宏任务中又会产生微任务

  1. 从上往下执行代码,先执行同步代码,输出 start
  2. 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列①中
  3. 接着往下执行,输出 children4, 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列②中,此时.then并不会被放到微任务队列中,因为 resolve是放到 setTimeout中执行的
  4. 代码执行完成之后,会查找微任务队列中的事件,发现并没有,于是开始执行宏任务①,即第一个 setTimeout, 输出 children2,此时,会把 Promise.resolve().then放到微任务队列中。
  5. 宏任务①中的代码执行完成后,会查找微任务队列,于是输出 children3;然后开始执行宏任务②,即第二个 setTimeout,输出 children5,此时将.then放到微任务队列中。
  6. 宏任务②中的代码执行完成后,会查找微任务队列,于是输出 children7,遇到 setTimeout,放到宏任务队列中。此时微任务执行完成,开始执行宏任务,输出 children6;

最后的执行结果如下

  • start
  • children4
  • children2
  • children3
  • children5
  • children7
  • children6

三. 下面代码输出什么

const p = function() {
  return new Promise((resolve, reject) => {
    const p1 = new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(1)
      }, 0)
      resolve(2)
    })
    p1.then((res) => {
      console.log(res);
    })
    console.log(3);
    resolve(4);
  })
}
 
 
p().then((res) => {
  console.log(res);
})
console.log('end');
  1. 执行代码,Promise本身是同步的立即执行函数,.then是异步执行函数。遇到setTimeout,先把其放入宏任务队列中,遇到p1.then会先放到微任务队列中,接着往下执行,输出 3
  2. 遇到 p().then 会先放到微任务队列中,接着往下执行,输出 end
  3. 同步代码块执行完成后,开始执行微任务队列中的任务,首先执行 p1.then,输出 2, 接着执行p().then, 输出 4
  4. 微任务执行完成后,开始执行宏任务,setTimeout, resolve(1),但是此时 p1.then已经执行完成,此时 1不会输出。

最后的执行结果如下

  • 3
  • end
  • 2
  • 4

你可以将上述代码中的 resolve(2)注释掉, 此时 1才会输出,输出结果为 3 end 4 1。

const p = function() {
  return new Promise((resolve, reject) => {
    const p1 = new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(1)
      }, 0)
    })
    p1.then((res) => {
      console.log(res);
    })
    console.log(3);
    resolve(4);
  })
}
 
 
p().then((res) => {
  console.log(res);
})
console.log('end');
  • 3
  • end
  • 4
  • 1