JavaScript 中单线程的概念
所谓单线程指的是同一个时间只能做一件事。那么为什么如此设计呢?
JavaScript 中的单线程主要与这门语言的用途有关,作为浏览器脚本语言,JavaScript 主要用来与用户互动以及操作 DOM 元素,这一特性决定了它只能是单线程,如果它是多线程的那么会带来很多复杂的同步问题,比如有两个线程,一个线程在给某个 DOM 节点上添加内容,而另一个线程删除了这个 DOM 节点,此时该以哪个线程的操作为准?所以为了避免复杂性,JavaScript 只能是单线程。但为了利用多核 CPU 的计算能力,H5 提出 Web Worker 标准,允许 JavaScript 创建多个线程,但子线程完全受主线程控制且不得操作 DOM,因此这个新标准并没有改变 JavaScript 单线程的本质。
任务队列
由于 JavaScript 语言的单线程,所以所有任务都需要排队,必须等到前一个任务结束,才会执行后一个任务。
但如果前一个任务耗时很长那么后一个任务就得一直等待,如果是因为前一个任务的计算量大 CPU 需要时间处理那么后一个任务的等待倒也可以接受,但有的时候是因为 IO 设备很慢,比如 常见的 Ajax 操作,需要从网络请求数据,不得不等待结果然后再往下执行。
所以 JavaScript 的设计者也意识到,这时主线程完全可以不管 IO 设备,将等待中的任务挂起,先执行后面的任务,等 IO 设备返回了结果,再回过头执行挂起的任务。
于是所有的任务可以分成两种,一种是同步任务(synchronous),一种是异步任务(asynchronous)。同步任务指的是在主线程上排队执行的任务,只有前一个任务执行完毕,后一个任务才能被执行;异步任务指的是不进入主线程,而进入任务队列(task queue)的任务。只有任务队列通知主线程某个任务可以执行了,该任务才会进入主线程。
异步执行的运行机制如下:
- 所有同步任务都在主线程上执行,形成一个执行栈(excution context stack)
- 主线程之外存在一个任务队列(task queue),只要异步任务有了运行结果,就在任务队列之中放置一个事件
- 一旦执行栈中的所有同步任务执行完毕,就会读取任务队列,看看里面有哪些事件,那些对应的异步任务结束等待状态,进入执行栈开始执行
- 主线程不断重复上面的第三步
下图源自网络:
事件循环(Event Loop)
同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。当指定的事情完成时,Event Table会将这个函数移入Event Queue。主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。上述过程会不断重复,也就是常说的 Event Loop(事件循环)。
事件和回调函数
任务队列中的事件,除了 IO 设备的事件以外,还包括一些用户产生的事件,比如鼠标点击,页面滚动等等,只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
宏任务和微任务
宏任务(macrotask)和微任务(microtask)表示异步任务的两种分类。
宏任务包括:script(整体的代码块), setTimeout , setInterval , setImmediate , requestAnimationFranme;
微任务包括:promise.then , promise.catch , promise.finally , process.nextTick , MutationObserver;
执行顺序:
先执行同步代码,遇到异步宏任务将宏任务放入宏任务队列中,遇到异步微任务将微任务放入微任务队列中,当所有同步代码执行完毕后,再将异步微任务从队列中调入主线程执行,微任务执行完毕后将宏任务调入主线程执行,一直循环直至所有任务执行完毕。
下图源自网络
一个小例子:
setTimeout(() => {
//执行后 回调一个宏事件
console.log('内层宏事件3')
}, 0)
console.log('外层宏事件1');
new Promise((resolve) => {
console.log('外层宏事件2');
resolve()
}).then(() => {
console.log('微事件1');
}).then(()=>{
console.log('微事件2')
})
最后输出: 外层宏事件1 外层宏事件2 微事件1 微事件2 内层宏事件3