前言
首先,让我们来看看 JavaScript 单线程的由来。JavaScript 最初是为了处理浏览器中的交互而设计的,而在早期的浏览器中,并没有多线程的概念。为了简化开发,JavaScript 就成为了一种单线程语言。
线程与进程
进程(Process)
让我们以一座小餐厅为例来模拟进程的概念,这个小餐厅就是我们电脑中的一个进程。每个人在餐厅里就餐,就好比我们电脑中运行的一个应用程序。
- 进程就像一个独立的餐厅: 想象一下,你打开了一个电脑游戏,这个游戏就好比是一座小餐厅。这个小餐厅有自己的厨房、服务员、餐具等资源,就像进程有自己的内存空间、文件句柄等。
- 任务管理器就是你的"大掌柜": 在任务管理器中,就像是你是这座餐厅的大掌柜。你可以看到每个餐厅(进程)都在做什么,也可以关闭其中的某一个餐厅(结束进程)。
- 多个进程就像多家餐厅: 如果你同时打开了多个应用程序,就好比同时在经营多家不同的餐厅。每家餐厅都独立运营,互不影响。
- CPU的时间片就是"工作时间": 想象一下,每个餐厅都有一个大钟,每次滴答声就代表了 CPU 的一个时间片。在每个时间片里,餐厅可以进行一些操作,比如准备菜品、接待客人等。
- 死机就像是餐厅"停业": 如果一个餐厅(进程)出现了问题,导致了死机,就好比这家餐厅停业了。其他餐厅(进程)仍然可以运行,不会受到影响。
线程(Thread)
线程是进程中的更小的执行单位,它描述了一段指令执行的时间。一个进程可以包含多个线程,这些线程共享进程的资源。在浏览器中,我们可以将线程想象成执行不同任务的小工人,每个小工人专注于自己的工作,比如渲染页面、处理网络请求、执行 JavaScript 代码等。
为何选择单线程?
生动的比喻:
想象一下,你正在一边看电视一边写作业,突然电话响了,门铃也响了,同时还有人在敲窗户。这时你会变得非常烦躁,因为你只有一双手,一次只能做一件事情。这就是单线程的概念,就像你只有一个手来处理各种事务。
好处之一:简单而高效
单线程带来的第一个好处就是简单而高效。在多线程的环境中,我们需要考虑线程间的同步、锁定等问题,而在单线程中,这些问题都迎刃而解。这就像你一个人在家里,不需要担心别人会和你争抢资源,你可以专心处理手头的事务。
好处之二:避免复杂的同步问题
再来看一个例子,假设你正在编辑一个文档,同时有两个人在不同的线程上修改同一个文档,那么就会涉及到同步的问题。在 JavaScript 中,由于是单线程,你不必担心这样的情况发生。这就像你一个人在房间里编辑文件,不会和其他人发生冲突
异步的魔法
虽然 JavaScript 是单线程的,但它并不意味着无法处理异步操作。异步操作可以通过事件循环(Event Loop)来实现。就像你在玩一个游戏,有一位裁判在负责整个游戏的进行,确保每个人都有机会行动一样。
在 JavaScript 中,Event Loop 会不断地检查任务队列,执行任务。这就像你手头的事务排成一列,裁判会按照顺序一个个处理。这样的设计使得 JavaScript 具有处理大量并发任务的能力。 JavaScript 的设计者们意识到,仅仅有同步和异步的区分是不够的,而需要更细致的控制异步代码的执行顺序。这就引入了宏任务和微任务的概念。
宏任务(Macro-task)
宏任务代表的是一段较大粒度的代码,它会被放入任务队列中等待执行。常见的宏任务包括:
- script: 整体代码块,包括全局代码和函数代码。
- setTimeout 和 setInterval: 定时器任务,指定的代码在将来的某个时间点执行。
- setImmediate: 仅在 Node.js 环境中存在,表示当前事件循环结束时执行,与 setTimeout(fn, 0) 类似。
- I/O 操作: 比如用户点击按钮、HTTP 请求等。
- UI 渲染: 页面渲染相关的任务,与 JavaScript 主线程关系不大,但可能影响 JavaScript 的执行。
微任务(Micro-task)
微任务是一段较小粒度的代码,它会在当前任务执行完成后立即执行。微任务的目的是在宏任务执行前更新某些状态或执行紧急任务。常见的微任务包括:
- promise.then: Promise是同步,Promise.then是异步
- MutationObserver: 观察 DOM 变动的情况,当被观察的节点发生变化时执行。
- process.nextTick: Node.js 中的特殊任务,表示在当前事件循环结束时执行。
执行顺序
- 执行同步代码: 首先执行当前执行栈的同步代码,确保页面的基本渲染和交互逻辑。
- 执行微任务: 在当前执行栈执行完毕后,立即执行所有微任务队列中的任务,确保及时更新状态。
- 执行宏任务: 从宏任务队列中取出一个任务执行,执行过程中可能产生新的微任务。
- 重复: 重复上述步骤(第二轮event-loop),直至所有任务执行完毕。
为什么需要微任务和宏任务?
微任务和宏任务的引入让开发者有了更精细的控制异步代码执行顺序的能力。通过微任务,我们可以在当前任务执行完毕后立即执行一些重要的逻辑,而不必等待下一个宏任务。这对于一些需要尽早执行的操作,比如状态更新、错误处理等,非常有用。
它们的引入让 JavaScript 异步编程更加灵活,能够更细致地控制代码的执行顺序,提高了代码的效率和性能。
在JavaScript中,代码执行的顺序和时间取决于它是同步还是异步的,以及执行该代码的环境(如CPU性能)。
javascript
setTimeout(() => console.log('setTimeout'), 1000);
for(let i = 0; i<10000; i++){
console.log('hello world')
}
这里有两部分代码:一个setTimeout
的调用和一个for
循环。
- setTimeout: 这是一个异步操作。它安排了一个函数在至少1000毫秒后执行。它被放入宏任务队列中,等待指定的时间过去。
- for循环: 这是一个同步操作。循环会立即执行,连续打印"hello world" 10000次。这个过程的耗时取决于执行它的计算机的CPU性能。不同的机器上,这个循环的执行时间可能会有显著差异。
在JavaScript中,同步代码总是在异步代码之前执行。因此,无论for
循环需要多少时间(假设1秒),setTimeout
里的回调函数都会在for
循环之后开始计时。
继续探索
javascript
console.log('start');
setTimeout(() => {
console.log('setTimeout')
setTimeout(() => {
console.log('inner')
})
console.log('end')
}, 1000)
new Promise((resolve, reject) =>{
console.log('Promise')
resolve()
})
.then(() => {
console.log('then1')
})
.then(() => (console.log('then2')))
第一轮事件循环
-
同步代码执行:
- 首先执行
console.log('start')
,立即打印 "start"。
- 首先执行
-
设置第一个 setTimeout:
- 这个
setTimeout
是一个宏任务,它被安排在1000毫秒后执行,但现在还不执行,只是被放入宏任务队列。
- 这个
-
Promise 执行:
new Promise
中的代码是同步执行的,因此打印 "Promise"。
-
处理微任务队列:
- 微任务
.then(() => console.log('then1'))
执行,打印 "then1"。 - 接着,
.then(() => console.log('then2'))
执行,打印 "then2"。
- 微任务
第一轮事件循环的输出
- "start"
- "Promise"
- "then1"
- "then2"
第二轮事件循环
-
执行第一个 setTimeout 的回调:
- 执行
console.log('setTimeout')
,打印 "setTimeout"。 - 然后遇到第二个
setTimeout
,它是一个新的宏任务,被放入宏任务队列,但现在不执行。 - 然后执行
console.log('end')
,打印 "end"。
- 执行
第二轮事件循环的输出
- "setTimeout"
- "end"
第三轮事件循环
-
执行第二个 setTimeout 的回调:
- 执行
console.log('inner')
,打印 "inner"。
- 执行
第三轮事件循环的输出
- "inner"
小总结
通过这个分析,我们可以看到,事件循环是如何处理同步代码、微任务和宏任务的。首先执行所有同步代码,然后处理所有微任务,最后在每轮事件循环中执行一个宏任务。这确保了异步操作的适当顺序和执行时机。
定时器和事件循环
javascript
function A(){
setTimeout(() => {
console.log('异步A完成')
}, 1000)
}
function B(){
setTimeout(() => {
console.log('异步B完成')
}, 500)
}
A();
B();
-
宏任务队列 :在JavaScript中,诸如
setTimeout
和setInterval
这样的函数会将它们的回调函数放入宏任务队列。这个队列负责存储在当前执行堆栈之外、等待执行的任务。 -
依次入队 :当你调用
A()
和B()
时,它们的setTimeout
回调确实是依次被添加到宏任务队列中。首先是A()
的回调,然后是B()
的回调。 -
执行顺序:然而,虽然它们是依次进入队列的,但它们的执行并不仅仅取决于进入队列的顺序。更重要的是,它们的执行取决于各自的延迟时间。浏览器会跟踪每个定时器的延迟时间,并在适当的时间激活它们。
-
示例中的执行情况:
A()
设置了一个1秒后执行的定时器。B()
设置了一个0.5秒后执行的定时器。- 尽管
A()
的定时器先进入队列,但由于B()
的定时器延迟时间更短,所以B()
的回调会先执行。 - 当达到各自的延迟时间后,浏览器会从队列中取出并执行相应的回调函数。
因此,在您的示例中,"异步B完成"会在"异步A完成"之前打印,即使 B()
的定时器后于 A()
的定时器进入队列。
经典面试题
console.log('script
async function async1() {
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1();
setTimeout(function () {
console.log('setTimeout');
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
})
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
})
console.log('script end');
-
同步代码执行:
- 首先执行
console.log('script start')
,立即打印 "script start"。
- 首先执行
-
执行 async1:
-
随后两个函数体声明我们不管,走到
async1()
去执行函数 -
走到
async1()
去执行函数,这其实就是Promise
函数,因此是个同步代码。因此现在要立即去执行async1
这个函数,然后发现里面是个await
,await
,就是相当于一个Promise.then
异步微任务,因此async2
入队列。(**重要的一点来了,浏览器开小灶给await
提速了,await
代码又成同步代码了**) -
也就是说
await async2()
是同步了,然后执行async2
这个函数,也就是输出'async2 end' -
await
语句将后续的console.log('async1 end')
转换为微任务。
-
-
设置 setTimeout:
- 设置了一个0毫秒延迟的
setTimeout
,但它是一个宏任务,会被放入宏任务队列,稍后执行。
- 设置了一个0毫秒延迟的
-
Promise 实例化:
new Promise
中的代码是同步执行的,打印 "Promise"。Promise
的.then()
部分是微任务,被添加到微任务队列。
-
结束同步代码执行:
- 执行
console.log('script end')
,打印 "script end"。
- 执行
-
处理微任务队列:
- 执行微任务队列中的
console.log('async1 end')
,打印 "async1 end"。 - 然后,依次执行
.then(...)
回调,打印 "promise1" 和 "promise2"。
- 执行微任务队列中的
-
处理宏任务队列:
- 在下一个事件循环迭代中,执行
setTimeout
回调,打印 "setTimeout"。
- 在下一个事件循环迭代中,执行
结论
这个过程展示了 async/await
的一个关键特性:虽然 await
将后续操作转换为微任务,但它会导致 await
之前的异步函数(如 async2
)中的代码立即执行,就像同步代码一样。然后,事件循环会继续正常操作:首先处理所有同步代码,然后清空微任务队列,最后处理宏任务队列。
因此,整体的输出顺序是:
- "script start"
- "async2 end"
- "Promise"
- "script end"
- "async1 end"
- "promise1"
- "promise2"
- "setTimeout"
结语
了解事件循环、异步和同步、微任务和宏任务、线程和进程等概念,有助于我们更好地理解JavaScript的运行机制