单线程语言
JavaScript 是单线程语言,因为语法层面 JavaScript 根本没有提供创建多线程的 API,像其他语言
- C++ 可以通过引入 pthread 头文件使用
pthread_create()
函数创建新的线程 - Java 通过
new Thread()
可以初始化一个新线程 - Python 提供了
thread
模块创建多线程 - ……
然而 JavaScript 什么也没提供,它所有的函数都是在 主线程 中顺序执行的,同一时间只能执行某一个函数。
这个特性是由它特殊的应用场景决定的:JavaScript 用来处理页面和用户的交互操作,这种交互最终通过修改 DOM 反馈给用户。
DOM 不能同时被多个线程同时修改,这会带来非常不好的用户体验,所以在浏览器中只有执行 JavaScript 的 主线程 能够操作 DOM。
异步靠环境实现
异步 (asynchronous) 就是同一时间能够执行多个任务,显然 JavaScript 本身实现不了这个,因为它根本没有创建多线程的 API。
目前 JavaScript 就两个运行环境:浏览器和 Node.js,JavaScript 的 异步 特性实际上是运行 JavaScript 的运行环境实现的。
浏览器和 Node.js 各自实现 异步 的方案大致相同,又有所区别。
以浏览器为例,浏览器本身就是个多进程、多线程的大型应用,例如
- 浏览器主进程
- CPU 进程
- 第三方插件进程
- 渲染进程
其中 渲染进程 负责执行 JavaScript 代码,渲染页面和处理与用户的交互操作。
浏览器为 渲染进程 实现了多个线程,例如
- GUI 渲染线程
- JS 引擎线程
- 事件触发线程
- 定时器触发线程
- 异步 http 请求线程
浏览器本身提供了一套方案来帮助 JavaScript 实现 异步 操作。
Node.js 大同小异。
消息队列和事件循环
一个 函数(包含普通函数、异步函数、类的构造函数、具名/匿名函数等)可以视为一个 任务。
JavaScript 中的每个函数调用可以分成两类:同步任务 和 异步任务。
同步任务 由 主线程 直接执行,此时如果 同步任务 比较耗时,那后面的其他任务就只能耐着性子等了。
异步任务 需要运行宿主(浏览器或者 Node.js)提供 其他线程 配合着执行,主线程 处理 异步任务 实际上只需要分派一下任务给宿主提供的其他线程即可,这个过程是很快的。
所以主线程能够很快(非阻塞)的执行完第一波主任务。
为什么说是第一波任务?主线程分派出去的异步任务最终还得收回来,由主线程自己执行回调,最终更新页面。
主线程分派任务、在异步任务执行完成之后主线程继续执行回调,这些都需要宿主来实现。
基本上所有语言都用 调用栈(Call Stack)来记录函数的执行过程,每个线程都有自己的调用栈。
setTimeout(() => console.log(0), 0)
console.log(1)
上面使用setTimeout()
函数创建了一个定时器,它是一个异步任务。
JavaScript 主线程执行这段程序调用栈的变化如下:
setTimeout
进栈,执行:- 检测到异步任务,交给宿主其他线程处理
setTimeout
出栈console.log
进栈,执行:- 打印 1
console.log
出栈
上述过程执行了两个任务:setTimeout
和console.log(1)
,显然这个程序还没执行完,setTimeout
还有个回调没有执行。
setTimeout
任务被交给了宿主提供的 定时器触发线程 执行,定时器触发线程拿到这个任务,开始计时,指定的时间一到,就需要将预先设置的 回调任务 返回给 主线程。
定时器触发线程显然不能影响主线程的执行过程,就算你立马可以返回回调任务(例如上面延时为 0 的定时器任务),也得等主线程有空了才能执行。
那么主线程什么时候有空呢?一个简单的方案就是,调用栈 里面没有东西了就说明主线程空闲了。
在此之前,定时器触发线程只能将要执行的回调任务放到一个临时的地方,即 消息队列(Message Queue)中。
消息队列只是一个数据结构,它不能自己判断什么时候需要将活儿派给主线程去干,此时就还需要一个宿主提供一个线程来专门干这个事,这就是 事件触发线程。
事件触发线程 可以循环检测 消息队列 和 调用栈,当消息队列中有待执行的任务以及调用栈中没有任务时,就取出一个任务放入调用栈中,然后主线程从调用栈取出任务开始执行。
执行完成之后将该任务出栈,此时调用栈又空了,然后 事件触发线程 重复这个过程。
宿主提供的第三方线程处理异步任务,处理完成之后将回调任务放入消息队列,通过事件触发线程的循环检测,在调用栈变空之后将消息队列中的任务依次放入调用栈,从而让主线程继续执行回调任务。
这种机制就是 事件循环(Event Loop)。
宏任务和微任务
ES6 引入了 微任务(microtask)的概念,它提高了异步任务执行的优先级。
异步任务 被细化成了 宏任务 和 微任务:
- 原来常见的大部分异步任务都可以看做是一个宏任务
Promise
,``MutationObserver,
process.nextTick`等定义的任务都属于微任务
宏任务和微任务都有自己的任务队列(消息队列),它们执行的时机略有不同。
setTimeout(() => {
console.log(0)
}, 1000)
setTimeout(() => {
console.log(1)
new Promise(resolve => {
console.log(2)
resolve(3)
}).then((v) => {
console.log(4)
console.log(v)
})
}, 0)
console.log(5)
- line1:setTimeout 进栈:它是一个异步函数,它将被交给定时器触发线程计时,1s 后回调任务会被推入 宏任务队列
- line1:setTimeout 出栈
- line5:setTimeout 进栈:它也会被交给定时器触发线程计时,0s 后回调任务被推入 宏任务队列
- line5:setTimeout 出栈
- line16:console.log 入栈:打印 5
- line16:console.log 出栈
然后栈空了。上述过程非常块,因为没有阻塞操作。
事件触发线程从 宏任务队列 中取出 line5:setTimeout 的回调任务
- line5:setTimeout callback 入栈
- line6:console.log 入栈:打印 1
- line6:console.log 出栈
- line7:new Promise() 入栈:它的回调会被立即执行
- line8:console.log 入栈:打印 2
- line8:console.log 出栈
- line9:resolve 入栈:更新 Promise 对象的状态为
'fullFilled'
, 值为3
- line9:resolve 出栈
- line10:then 入栈:设置 Promise 对象的 onFullFilled 回调,因为状态已经改变,line10:then callback 直接被推入 微任务队列
- line10:then 出栈
此时 宏任务队列 中还有一个任务,微任务队列 中也出了一个任务。
在每次执行完 宏任务 之后,都会将 微任务队列 中的 微任务 逐个执行清空。
在清空微任务队列的过程可能有新的微任务进来,也会一并执行清空。
所以接下来
- line10:then callback 入栈
- line11:console.log 入栈,打印 4,出栈
- line12:console.log 入栈,打印 3,出栈
- line10:then callback 出栈
现在调用栈又空了,如果距离 line1:setTimeout 调用已经过去 1s,事件触发线程会将 宏任务队列 中的一个任务取出来放进去
- line1:setTimeout callback 入栈
- line2:console.log 入栈,打印 0,出栈
- line1:setTimeout callback 出栈
现在调用栈又空了,微任务队列也空了,宏任务队列也空了,主线程就空闲了。
所以最后的打印顺序是:5 1 2 4 3 0
总结一下:
- ES6 之后宿主会维护两个消息队列:宏任务队列和微任务队列
- 从宏任务队列中取出一个宏任务执行完成之后,都会立即清空微任务队列中的所有任务,包括清空过程中新增的微任务
- 创建宏任务的异步函数一般有:I/O, setTimeout, setInterval, setImmediate (Node.js), requestAnimationFrame (Browser)
- 创建微任务的异步函数一般有:process.nextTick (Node.js), MutationObserver (Browser), Promise.then/catch/finally
评论区