进程与线程
进程
进程是操作系统进行任务调度和资源分配的基本单位。
- 进程是操作系统对程序的一种抽象,它包含了程序执行所需的资源和状态信息。
- 操作系统可以同时运行多个进程,每个进程都是相互独立的执行环境。
- 进程之间通过进程间通信(IPC,Interprocess Communication)机制来进行交互和数据传输。
线程
线程是进程内的执行单元,是操作系统调度的最小执行单位。
- 一个进程至少有一个线程。在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。
- 一个进程可以包含多个线程,这些线程共享进程的系统资源,可以并发执行不同的任务。
- 线程之间的切换更加高效,因为它们共享相同的上下文。
浏览器中的进程与线程
浏览器是一个多进程多线程的应用程序。
浏览器内部的工作极其复杂,为了减少连环崩溃的几率,浏览器会启动多个进程,去完成不同的功能。
可以在浏览器的任务管理器中查看当前的所有进程,可以看见有:浏览器进程、网络进程、渲染进程…
- 浏览器进程:浏览器的主进程,负责管理和协调其他进程的工作。
- 网络进程:负责处理网络请求和响应。
- 渲染进程:负责处理并呈现网页内容。
渲染进程
渲染进程是一个多线程的环境,其中包括主线程和其他辅助线程。主线程负责处理文档解析、样式计算、布局和绘制等任务。而其他线程,如网络线程、定时器线程、事件线程等则负责处理网络请求、定时器操作、事件处理等功能,以提高浏览器的并发处理能力。
默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证标签页之间不会相互影响。
渲染主线程
渲染主线程有很多工作需要完成:
- 解析 HTML,构建 DOM(文档对象模型)树。
- 解析 CSS,构建 CSSOM(CSS 对象模型)树
- 合并 DOM 树和 CSSOM 树,构建 Render 树(渲染树)。
- 布局(Layout):计算每个元素在页面中的位置和尺寸。
- 绘制(Painting):将页面元素转换为实际的像素信息。
- 合成(Compositing):将绘制的图像元素合成为最终的页面图像,并将其提交给合成线程进行显示。
- 每秒把页面画 60 次
- 执行全局 JS 代码
- 执行事件处理函数
- 执行计时器的回调函数
- …
渲染主线程通过排队的方式一个个去完成这些工作:
- 最开始时,渲染主线程会进入一个无限循环。
- 每一次循环都会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完再进入下一次循环;如果没有,则进入休眠状态。
- 其他所有线程(包括其他进程的线程)随时可以向消息队列添加任务。新任务会加到消息队列的末尾。添加新任务时,如果渲染主线程处于休眠状态,则会将其唤醒以继续循环拿取任务。
这一整个过程,就是事件循环。
线程阻塞
每个页面只有一个渲染主线程,而渲染主线程又承担着诸多工作。如果执行大量的 JavaScript 计算或其他耗时操作,可能会阻塞渲染主线程,导致页面的渲染和响应变慢。
name: <span>superman</span>
<button>change</button>
<script>
const span = document.querySelector('span');
const btn = document.querySelector('button');
// 死循环指定的时间
function delay(duration) {
const start = Date.now();
while (Date.now() - start < duration) {}
}
btn.onclick = function () {
span.textContent = 'monster';
delay(2000);
};
</script>
上例中,点击 “change” 可以看见,页面卡顿了 2s 后 “superman” 才变成 “monster”。这是因为 “delay” 函数耗时太久了,导致线程阻塞,页面无法重新渲染了。
任务队列
根据 W3C 的最新解释:
- 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。
在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。 - 浏览器必须准备好一个微任务队列,微任务队列中的任务优先级最高。
在目前 chrome 的实现中,至少包含了下面的队列:
- 延时队列:用于存放计时器到达后的回调任务,优先级「中」。
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」。
- 微任务队列:用户存放需要最快执行的任务,优先级「最高」。
同步任务 & 异步任务
- 同步任务:即 “非耗时程序”,都是在渲染主线程上排队执行的任务。
- 异步任务:即 “耗时程序” (事件、Ajax、定时器等),由 JS 委托宿主环境 (Node / 浏览器) 执行。
常见的微任务:① process.nextTick
(node.js)、② MutationObserver
(浏览器)、③ Promise实例.then catch finally
、async-await
…
其他异步任务:① 事件、② 定时器、③ Ajax、④ I/O 操作、⑤ requestAnimationFrame
(浏览器)
根据 JS 单线程异步模型的规则,异步操作得通过 [回调函数] 实现。
∴ 异步任务一定有回调函数!但请注意,有回调函数的不一定是异步任务!
setTimeout(function () {
console.log('异步任务');
}, 0);
[1].forEach(item => {
console.log('同步任务');
});
在主线程中,JS 代码会被从上往下地一行行执行;遇到异步任务,则委托给宿主环境开辟新线程执行;
异步任务执行完后,会将其 [回调函数] 放入任务队列 Task Queue 等待;
等主线程将同步任务都执行完,就从 Task Queue 中获取最先入队的 [回调函数] 执行。
setTimeout(() => {
console.log('异步任务');
}, 0);
for (let i = 0; i < 5000; i++) {
console.log('一个耗时很长的同步任务');
}
// for 循环执行完后,才会执行 setTimeout 里面的代码
在异步操作完成时间不同的情况下,先完成的异步操作会先进入 Task Queue:
下例输出顺序:sync
- setTimeout 2
- setTimeout 1
setTimeout(function () {
console.log('setTimeout 1'); // 这个耗时 2s
}, 2000);
setTimeout(function () {
console.log('setTimeout 2'); // 这个耗时 1s
}, 1000);
console.log('sync');
在异步操作完成时间相同的情况下,先注册的异步操作会先进入 Task Queue:
下例输出顺序:sync
- setTimeout 1
- setTimeout 2
setTimeout(function () {
console.log('setTimeout 1'); // 前面的代码先注册
}, 2000);
setTimeout(function () {
console.log('setTimeout 2'); // 后面的代码后注册
}, 2000);
console.log('sync');
任务队列前面的异步操作被阻塞时,后面的异步操作也会被阻塞:
下例的输出顺序:sync
- setTimeout 1 start
- setTimeout 1 end
- setTimeout 2
setTimeout(function () {
// 先进入执行栈
console.log('setTimeout 1 start');
// 被阻塞了
for (let i = 0; i < 5000; i++) {
console.log('一个耗时很长的同步任务');
}
console.log('setTimeout 1 end');
}, 1000);
// 后面的异步操作也会被阻塞
setTimeout(function () {
console.log('setTimeout 2');
}, 1000);
console.log('sync');
面试题
如何理解 JS 的异步?
JS 是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。
渲染主线程承担着诸多的工作,渲染页面、执行 JS 都在其中运行。
如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。所以浏览器采用异步的方式来避免。
具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。
这种异步模式能最大限度的保证单线程的流畅运行。
阐述一下 JS 的事件循环
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。
根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。
JS 中的计时器能做到精确计时吗?为什么?
不行,因为:
- 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差。
- 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级 >= 5 层,计时器都会带有 4 毫秒的最少时间。
- 受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差。
面试题 1
console.log("A");
setTimeout(() => {
console.log("B");
new Promise<void>(resolve => {
console.log("C");
resolve();
}).then(() => {
console.log("D");
});
}, 0);
new Promise<void>(resolve => {
console.log("E");
resolve();
}).then(() => {
console.log("F");
setTimeout(() => {
console.log("G");
}, 0);
});
输出顺序:A
E
F
B
C
D
G
- 主线程中有俩同步执行的 log `A` `E`;
- 然后是微任务 then():
- 其回调函数中有同步执行的 log `F`;
- 没有微任务;
- 有 setTimeout,异步执行完后,将其 [回调函数] 放置 Queue 的最后
- 然后是第 1 个 setTimeout:
- 其回调函数中有同步执行的 log `B` `C`;
- 然后是微任务;
- 其回调函数中有同步执行的 log `D`;
- 没有微任务;
- 没有其他异步任务;
- 没有其他异步任务;
- 然后是第 2 个 setTimeout:
- 其回调函数中有同步执行的 log `G`;
- 没有微任务;
- 没有其他异步任务;
面试题 2
document.addEventListener('click', function () {
Promise.resolve().then(() => console.log(1));
console.log(2);
});
document.addEventListener('click', function () {
Promise.resolve().then(() => console.log(3));
console.log(4);
});
输出顺序:2
1
4
3
解析:① 两个事件的完成时间一样,先注册的先执行,所以会先执行第 1 个事件;
② 在第 1 个事件中,有 then 操作,属于异步操作中的微任务,所以输出顺序是 2
1
;
③ 在第 2 个事件中,同理可得输出顺序 4
3
面试题 3
const fs = require('fs');
const util = require('util');
const myReadFile = util.promisify(fs.readFile);
console.log('A');
myReadFile('./1.txt')
.then(_ => console.log('B'))
.then(_ => console.log('C'));
myReadFile('./2.txt').then(_ => console.log('D'));
setTimeout(() => console.log('E'), 0);
console.log('F');
输出顺序:A
F
E
B
C
D
/ A
F
E
D
B
C
解析:① 先执行同步任务,所以先输出 A
F
② [读取操作] & [事件]:[事件] 完成的时间较短,先进入任务队列,所以先输出 E
③ 先完成的 [读取操作] 先进入任务队列;如果先读取完 1.txt 文件,则先执行该任务下的微任务,输出 B
C
;否则先输出 D
面试题 4
setTimeout(function () {
console.log('timer 1');
}, 0);
requestAnimationFrame(function () {
console.log('UI update');
});
setTimeout(function () {
console.log('timer 2');
}, 0);
new Promise<void>(function executor(resolve) {
console.log('promise 1');
resolve();
console.log('promise 2');
}).then(function () {
console.log('promise then');
});
执行顺序 1:promise 1
- promise 2
- promise then
- timer1
- timer2
- UI update
执行顺序 2:promise 1
- promise 2
- promise then
- UI update
- timer1
- timer2
解析:① 先执行同步任务:构造函数 Promise 的执行器 executor 是同步执行的
所以前 3 的输出顺序一定是 promise 1
- promise 2
;
② Promise 实例的 then 操作是异步操作中的微任务,所以后面一定是先打印 promise then
;
③ 对于两个 setTimeout 异步操作,因为完成的时间一样,所以是先注册的先执行,所以一定是 timer1
- timer2
;
④ 对于 setTimeout 和 requestAnimationFrame,setTimeout 是人为设置的异步操作,requestAnimationFrame 是浏览器自动执行的异步操作,我们每次执行定时器时,都不知道 requestAnimationFrame 执行到哪里,所以有可能是 requestAnimationFrame 先输出,也有可能是 setTimeout 先输出;
∴ 输出的顺序有如上两种情况!!!
另外,requestAnimationFrame 用于浏览器画面的渲染,是按照浏览器的刷新率来执行的,也就是屏幕刷新⼀次 函数就触发⼀次,每秒约执行 60 次;setTimeout 是定时器,默认每秒约执行 200 次;因为 setTimeout 的执行速度较快,所以它先输出的概率较高!!
面试题 5
setTimeout(() => {
console.log(0);
}, 0);
new Promise(resolve => {
console.log(1);
resolve();
})
.then(() => {
console.log(2);
new Promise(resolve => {
console.log(3);
resolve();
})
.then(() => {
console.log(4);
})
.then(() => {
console.log(5);
});
})
.then(() => {
console.log(6);
});
new Promise(resolve => {
console.log(7);
resolve();
}).then(() => {
console.log(8);
});
1. 先执行同步代码, 输出 1 7
注册 setTimeout; 注册第一, 二个微任务
2. 执行第一个微任务, 输出 2 3
注册第三, 四个微任务
3. 执行第二个微任务, 输出 8
4. 执行第三个微任务, 输出 4
注册第五个微任务
5. 执行第四个微任务, 输出 6
6. 执行第五个微任务, 输出 5
7. 第一轮微任务执行完毕, 执行 setTimeout, 输出 0