JavaScript是一个单线程非阻塞的脚本语言。这代表代码是执行在一个主线程上面的。但是JavaScript中有很多耗时的异步操作,例如AJAX,setTimeout等等;也有很多事件,例如用户触发的点击事件,鼠标事件等等。这些异步操作并不会阻塞我们代码的执行。例如:
let a = 1;
setTimeout(() => {
console.log('->', a)
}, 10);
a = 2;
// 输出 -> 2
可以看到,上述代码在浏览器中执行时,遇到setTimeout操作,并没有阻塞等待异步操作的结束再继续执行代码,而是先继续执行后面的代码。等异步操作结束后,浏览器再回来执行异步回调中的代码。因此,上述代码的console.log输出时,a的值已经变为了2。
这些异步非阻塞的实现,就是靠Javascript中的事件循环机制。
上面说到JavaScript是一个单线程的语言,这句话并不完全对。单线程指的是代码在一个主线程中运行,但是代码所触发的任务不一定在主线程运行。除了执行代码的线程之外,执行JavaScript的环境中还包含其他很多线程。其中浏览器的线程与Node.js中的线程也不相同。
其中GUI线程和JS线程是互斥的,即JS线程执行时,GUI线程会被挂起,即不能执行。反之GUI线程执行时,JS线程也不能同时执行。
上面的线程实际上都在浏览器中的渲染进程中包含。一个浏览器要想正常运行,只做上述的操作是不够的。我们以Chrome为例,列举一个浏览器运行所需要的进程。
一个浏览器可以拥有多个标签页,在不同的标签页中,除了渲染进行之外,都是共享的。即我们打开一个新的标签页时,会产生一个新的渲染进程。(当在原标签页中打开新标签页,且属于同一个域则共享一个渲染进程)
上面我们了解了浏览器中的进程和线程,有些同学就会有疑问,为什么要设立这么多的进程和线程?
进程是操作系统分配资源的基本单位,而线程是CPU任务调度和执行的基本单位。
简单理解下就是一个完整的应用程序是以进程为单位的,即至少有一个进程。而一段程序/代码在CPU的独立执行则至少以线程为单位。不同的进程和不同的线程都可以并行运行。
一个进程可以包含很多个线程,多个线程共享一个进程的资源(比如内存)。当一个进程崩溃后不会影响其他进程,但是当一个线程崩溃,它所在的整个进程都会崩溃掉,这个进程内的其他线程也会崩溃。
因此,为了同时并行执行代码和异步请求,浏览器中的渲染进程包含很多线程来并行运行任务。而为了让不同标签页的网页不互相影响,不同标签页拥有独立的渲染进程。这样即使某个网页崩溃,也不会影响其他标签页。
上述这些进程和线程的说明也仅仅是进行了抽象和简化,事实上浏览器和Node.js中的进程和线程数要更多,处理也更复杂。
Javascript中的异步任务大致可以分为两种:宏任务和微任务。宏任务和微任务的执行顺序和优先级是不同的,具体的执行顺序问题我们在事件循环中描述,这里先来看一下,哪些操作属于宏任务,哪些属于微任务。这里仅仅是简单介绍,更详细的要在了解事件循环之后说明。
任务 | 浏览器 | Node.js | 描述 |
---|---|---|---|
setTimeout | ✓ | ✓ | 在指定的毫秒数后调用函数 |
setInterval | ✓ | ✓ | 定时调用函数 |
script标签 | ✓ | 整体代码块 | |
I/O请求 | ✓ | ✓ | 例如文件请求,网络请求等 |
DOM事件 | ✓ | 例如点击事件,hover事件等 | |
requestAnimationFrame | ✓ | 浏览器重绘前更新动画 | |
postMessage | ✓ | iframe跨域通信 | |
MessageChannel | ✓ | ✓ | 管道通信 |
setImmediate | ✓ | 一次事件循环执行完毕调用 |
任务 | 浏览器 | Node.js | 描述 |
---|---|---|---|
Promise中resolve和reject回调 | ✓ | ✓ | |
async函数中的await异步函数 | ✓ | ✓ | |
MutationObserver | ✓ | 监听DOM变动触发 | |
process.nextTick | ✓ | 当前任务结束后执行 |
与上面进程与线程的介绍一样,在浏览器中与Node.js中实现循环的方式也并不相同。下面我们来分别简单介绍一下。注意,这仅仅是对执行逻辑的抽象和总结,实际上浏览器和Node.js中的实现要更复杂。
浏览器中的事件循环可以分为两个队列,宏任务队列和微任务队列。具体的任务执行顺序如下:
在事件循环的流程中,微任务的优先级实际上更高,执行完一个宏任务之后,要执行微任务队列中的所有任务。
因为不同任务的开销不同,有的任务需要调用不同的线程甚至进程,有的任务需要等待请求返回甚至定时。
为什么script标签是宏任务呢?
。WHATWG(网页超文本应用技术工作小组)在官网对事件循环和任务队列做出了更详细的说明和解释,可以作为参考:说明文档。在新的说明中,任务的分类和事件循环已经有了部分区别,这里简要说一下,更多还请直接查看文档:
Node.js的官网给出了事件循环的文档。它的事件循环要比浏览器的看起来复杂一些。下面是Node.js的宏任务队列。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node.js的宏任务队列并不是一整个队列,而是根据事件类型做出了区分,分为了六个队列,依次执行:
timers
定时器队列,执行定时器的回调pending callbacks
挂起的回调函数,用于某些系统回调idle, prepare
仅在内部使用poll
执行I/O事件回调check
setImmediate回调close callbacks
close事件的回调,例如 socket.on('close', ...)
其中我们的大部分宏任务回调都会在poll
阶段执行,除了timers
、check
和close callbacks
阶段的特殊回调。每个宏任务队列都有自己的微任务队列。
process.nextTick
中的回调(如果有)。6个宏任务队列都执行完毕,才叫做一次事件循环执行完毕。
其中,在Node.js的11版本之前,宏任务和微任务的执行关系与上述流程不同:
每个宏任务队列有一个微任务队列。在单个宏任务队列中,首先执行完所有的宏任务,如果遇到微任务就放到微任务队列中。当单个宏任务队列中的所有宏任务执行完毕后,再执行该宏任务队列的微任务队列。
对比执行流程的区别,可以看到Node.js的11版本提高了微任务队列中的优先级,让Node.js中微任务队列的优先级和浏览器中的表现类似。而process.nextTick
可以看做是一个比微任务更高优先级的钩子。
new Promise(fun)
中的回调是同步执行,在回调中遇到resolve(), reject()
等才是微任务异步执行的。