事件循环
进程与线程
进程是对正在运行中的程序的一个抽象。当启动一个程序时,操作系统就会给该程序创建一个内存空间
(当程序被中止时,该内存空间就会被回收),该内存空间就是用来存放程序代码
,运行中的数据
和一个执行任务的主线程
,这样的一个运行环境(内存空间)就被称为进程
。
每个应用至少要有一个进程,进程之间是完全隔离相互独立的,进程的通信需要借助管道IPC
来传递。
一个进程至少包含一个线程,会在进程开启后自动创建,这个线程被称之为主线程
。线程是进程的执行单元,是依附于进程的。进程将任务分为多个细小任务,分配给多个线程单独执行,可以提高运行效率。
浏览器事件循环
浏览器进程模型
现代浏览器是一个多进程多线程
的应用程序,它在启动时会开启多个进程,拥有数个功能模块,会把不同的功能模块放在不同的进程里。
类似与微服务架构,这意味着单个模块的崩溃不会牵连到其他模块。
其中有几个比较重要的进程:
📖 浏览器主进程
它是浏览器的主进程,无论打开多少标签页、浏览器窗口,都只会存在一个浏览器主进程,它主要负责浏览器界面展示、用户的交互、子进程管理
TIP
这里的界面和交互,指的是浏览器本身自带的界面部分(前进后退刷新按钮、导航栏、书签栏),以及浏览器的交互(滚动条、键盘等)
打开浏览器只有一个浏览器主进程,其他的进程是由主进程创建的,用于处理多个不同的任务,这些新开启的进程会交由子进程管理统一管理
📖 网络进程
负责加载网络资源,网络进程会开启多个线程处理不同的任务。
🌟 渲染进程
渲染进程启动后,会开启一个渲染主线程,主线程负责执行解析HTML
、CSS
、JS脚本
和其他资源。默认情况下,浏览器会为每个标签页开启一个新的渲染进程,保证不同的标签页之间不相互影响。
TIP
渲染进程也是前端开发要重点关注的进程
渲染主线程
渲染进程启动后,会开启一个渲染主线程,它是浏览器最繁忙的线程,需要处理的任务有很多:
- 解析
HTML
- 解析
CSS
- 计算样式
- 布局
- 处理图层
- 绘制页面
- 解析
JS
代码并执行 - 执行事件处理回调函数
- 执行各种回调函数
- ......
TIP
为什么浏览器不采用多线程的方式去处理这么多任务?
JavaScript
的解析和执行是由渲染进程的主线程完成的,这个线程是渲染进程的核心线程,负责处理大多数关键任务。所有代码默认在这个线程上顺序执行,这意味着:
- 同一时间只能做一件事,代码逐行顺序执行。
- 没有多线程的复杂性,不需要处理多线程的锁、竞态问题等
- 如果一个任务长时间占用主线程,整个程序会被阻塞
JavaScript
在设计之初,主要用于处理网页的交互,比如表单验证、DOM
操作等。如果使用多线程,可能会带来复杂的同步问题,比如多个线程同时操作DOM
,容易导致竞态条件和不可预测的结果。所以JavaScript
采用了单线程模型来避免这些问题。
虽然主线程是单线程,但单线程≠只能做一件事,通过事件循环和异步队列的机制,JavaScript
可以实现非阻塞的异步操作,比如网络请求、定时器、文件读写(Nodejs
)等。
这就 JavaScript
单线程的 原因
面对这么多的任务,浏览器采用排队轮询
的机制来进行任务调度。
- 浏览器会提供一个
消息队列
,用于存储等待执行的任务 - 渲染主线程会进入无限循环
- 自上而下执行
JavaScript
的同步代码,并将异步任务放入到消息队列
- 每次循环会检查
消息队列
是否有任务存在,如果有,就依次取出一个任务执行,执行完后进行下一次循环;如果没有,则进入休眠状态。 - 其他的线程可以往
消息队列
添加任务,新任务会添加在消息队列
的队尾 - 不断循环这个过程,直到
消息队列
清空,进入休眠,一旦有任务又会被激活
异步任务
JavaScript
是一门单线程语言,如果采用同步的方式,极有可能会导致后面代码产生阻塞,消息队列
中的任务造成堆积无法得到执行。
所以浏览器采用异步的方式来避免。当某些任务发生时,比如网络、计时器、事件这些异步任务,主线程会将任务交给其他线程处理,自身立即结束当前异步任务的执行,转而继续执行后续同步代码。
当其他线程完成的时候,会将事先包装好的回调函数包装成异步任务对象,加入到消息队列
的队尾,等待主线程调度执行。
也就是说,大多数的异步任务其实是回调函数,Promise.then(task)
、setTimeout(task,0)
、fetch(task)
等。
消息队列
中的任务可以存在很多条,同一个任务必须在一个队列,不同类型的任务可以分类为不同的队列,这些队列之间是存在优先级的,W3C
将这些消息队列
分类为以下几种:
队列类型 | 任务内容 | 优先级 |
---|---|---|
微任务队列 | Promise。then() ,MutationObserver 的回调函数,queueMicrotask() 的回调函数等。 | 最高 |
交互队列 | 用户交互事件的回调函数,例如click 、postMessage 的回调等 | 高 |
延时队列 | 计时器的回调函数,比如setTimeout 、setInterval | 中 |
网络请求队列 | 网络请求的回调函数,比如fetch 、XMLHttpRequest 的回调 | 中 |
渲染任务队列 | 页面渲染相关的任务,比如重绘、重排等 | 低 |
下面通过一个具体的代码案例,说明浏览器事件循环的过程:
// 写出下述程序的输出结果
const btn = document.getElementById('button');
function test() {
console.log('function test!');
Promise.resolve().then(() => {
console.log('promise1');
});
}
setTimeout(() => {
console.log('set timer');
Promise.resolve().then(test);
}, 0);
btn.onclick = () => {
console.log('click button');
};
btn.click();
Promise.resolve().then(() => {
console.log('promise2');
});
console.log('script start');
// 输出结果依次为:
// click button
// script start
// promise2
// set timer
// function test!
// promise1
加点Promise
Promise.then
的回调函数在调用resolve() / reject()
之后,状态由pending
转变状态,就会被加入到微队列中排队。
看下面的代码分析输出:
Promise.resolve()
.then(() => {
console.log("promise1");
})
.then(() => {
console.log("promise4");
});
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("promise2");
})
.then(() => {
console.log("promise3");
});
console.log("start");
// start
// promise1
// promise2
// promise4
// promise3
// setTimeout
拆解上面的代码执行时机:
then()
的回调函数被称为thenable
,它的执行时机(注册到微队列)受到两个因素的影响
resolve()
函数的调用,意味着该thenable
已经完成, 需要从队列中取出来执行then
传递的回调,意味该thenable
已经注册到微队列的队尾,等待执行
在promise1
的微任务执行完毕后,它的后面.then(() => {console.log("promise4")})
并不会立即执行,而是会加入到队尾等待执行,promise2
后面的任务也同理。
再来分析下面的代码:
Promise.resolve()
.then(() => {
console.log("promise1");
return Promise.resolve("promise4");
})
.then((res) => {
console.log(res);
});
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("promise2");
})
.then(() => {
console.log("promise3");
})
.then(() => {
console.log("promise5");
})
.then(() => {
console.log("promise6");
});
console.log("start");
// start
// promise1
// promise2
// promise3
// promise5
// promise4
// promise6
这道题跟上一道题的区别就在于微任务内返回了个Promise
,一个PromiseA
接收另外一个PromiseB
的结果,此时的微任务由thenable
变成了包裹微任务的thenable
这是何解?用下面的代码解释就是:
Promise.resolve()
.then(() => {
console.log("1");
return Promise.resolve("2");
})
// ==> 产生了两个thenable
// Promise.resolve().then(thenable1:()=>Promise.resolve().then(thenable2))
Promise.resolve()
.then(() => {
console.log("1");
return new Promise((resolve)=>{
resolve("2")
})
})
async / await
这一对API
是Promise
的语法糖,考察的也是async await
和Promise
之间的转换。
async
会返回一个Promise
jsasync function fn(){ return 'fn' } // 等价于 function fn(){ return Promise.resolve('fn') }
await
为界线,之前的代码为同步executor
代码,之后的代码为thenable
jsasync function fn(){ console.log('1') const result = await fetch() console.log(result,'result') } // 等价于 function fn(){ console.log('1') return new Promise((resolve)=>{ // console.log('1') 或者放在executor中执行 fetch().then((result)=>{ resolve(result) }) }) }
多个
await
会被转换为链式的then
调用jsasync function fn(){ const a = await Promise.resolve('2') const b = await Promise.resolve('3') return a + b } // 等价于 function fn(){ return Promise.resolve('2') .then((a)=>{ return promise.resolve('3') .then((b)=>{ return a + b }) }) }
接下来看代码分析输出:
async function example() {
console.log('1'); // 同步代码
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
const result = await Promise.resolve('3');
console.log(result); // 微任务
setTimeout(() => {
console.log('4'); // 宏任务
}, 0);
await Promise.resolve('5')
.then(res => {
console.log(res); // 微任务
});
console.log('6'); // 微任务
}
console.log('start');
example();
console.log('end');
// 输出顺序:
// start
// 1
// end
// 3
// 5
// 6
// 2
// 4
Node事件循环
Nodejs
中的事件循环则是基于 libuv
实现的,事件循环也可以被称为是 Nodejs
的生命周期。
libuv
是一个用 C
语言实现的高性能解决单线程非阻塞异步 I/O
的开源库,本质上它是对常见操作系统底层异步 I/O
操作的封装。在 nodejs
底层,Node API
的实现其实就是调用的它。
- 宏队列
- timers (重要)
- pending callback:调用上一次事件循环没在 poll 阶段立刻执行,而延迟的 I/O 回调函数
- idle prepare:仅供 nodejs 内部使用
- poll (重要)
- check (重要)
- close callbacks:执行所有注册 close 事件的回调函数
- 微队列
- nextTick
- Promise
每一个 event loop
会按照下面的循环顺序:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
我们只需要注意其中几个比较重要的队列:
- 📎 timers
计时器队列,负责处理setTimeout
和setInterval
的回调函数
值得注意的是,不管在浏览器中还是 nodejs
中,所有的定时器回调函数都不能保证到达时间后立即执行。
一是因为从计算机硬件和底层操作系统来看,计时器的实现本身就是不精准的,
二是因为 poll
阶段对 timers
阶段的深刻影响。在没有满足 poll
阶段的结束条件前,就无法进入下一次事件循环的 timers
阶段,即使 timers
队列中已经有计时器到期的回调函数。
三是因为 timers
需要开启计时器线程去判断计时器是否到时间执行。
- 📦 poll
轮询队列,该阶段会处理除 timers
和 check
队列外的绝大多数 I/O
回调任务,如文件读取、监听用户请求等。
这个队列的运行方式
- 如果
poll
存在回调,会依次执行回调,直到队列清空 - 如果没有回调,会等到其他队列出现回调,会结束当前阶段,进入下一阶段的事件循环
- 如果都没有回调,会持续等到,直到出现回调为止
const http = require('node:http')
setTimeout(function f1(){
// 会在1s后执行times的回调,然后清空队列,进入poll阶段
console.log('f1')
}, 1000)
const server = http.createServer((req, res) => {
// 只要没有新请求进来之前,会一直在这个回调中等待,不会继续执行全局上下文的代码
console.log('server start')
})
server.listen(9527)
关于等待其他队列出现回调,可以用下面的代码分析一下:
const start = Date.now();
setTimeout(function f1(){
console.log(`setTimeout ${Date.now() - start}`)
}, 200)
const fs = require("node:fs");
fs.readFile("./data.json", "utf-8", (err,data)=>{
console.log("readFile");
while(Date.now() - start < 300){}
})
此时会发现打印的 setTimeout
时间远远不止200ms,这是因为在 poll
阶段中执行任务不是一下子就完成的,这需要一个过程,而这个等待的过程结束后,任务才会加入到 poll
队列中
poll
此时存在一个任务 readFile
,这个任务会阻塞执行300ms,完成之后,会继续事件循环
发现计时器的任务到时间了,会加入到 timers
队列中,然后拿出来执行,此时的程序执行耗时时间:poll等待任务执行并加入到队列的时间 + 阻塞的300ms + timers任务所需的200ms
就会造成 timers
的实际执行时间与预期的执行时间有一定的差距,这是因为要等 poll
一直在轮询的动作。
- 🏏 check
检查队列,负责处理 setImmediate
定义的回调函数。
setTimeout
的每次都会在计算机开辟计时器线程,这对于 times
的判断过程是很浪费时间的,但是 setImmediate
不同,它是直接加入到check
队列中,所以总的来说,setImmediate
的执行效率要远高于 setTimeout
,于是也就出现了下面无法预测输出结果的情况
setTimeout(() => {
console.log('setTimeout');
}, 0)
setImmediate(() => {
console.log('setImmediate');
})
// 上述代码是无法预测先输出那个的
// 因为即使 setTimeout(xxx, 0),在计算机运算慢的情况下也不能立刻加入 timers 队列
- 📌 nextTick
在每个阶段的队列执行之前,也就是在 timers、poll、check
的每个阶段,都会先去检查微队列,存在就先拿出来运行。
process.nextTick()
将回调函数加入 nextTick
队列,它的优先级是最高的。
- 📌 Promise
最常见的微队列任务,通过 Promise.resolve、queueMicrotask
加入的任务
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("setTimeout0");
}, 0);
setTimeout(function () {
console.log("setTimeout3");
}, 3);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
console.log("promise2");
}).then(function () {
console.log("promise3");
});
console.log("script end");
// 输出结果依次为:
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick
// async1 end
// promise3
// 剩下的 setTimeout0、setTimeout3、setImmediate 顺序不定
// 唯一能确定的是 setTimeout0 在 setTimeout3 前输出
// 而 setImmediate 可能在 setTimeout0 前也可能在 setTimeout3 之后,也可能在两者中间