大家都知道 javascript 是一门单线程、非阻塞的脚本语言,那它它是如何非阻塞的?
一 执行栈与任务队列
单线程,javascript 代码在执行的任何时候,都只有一个主线程来处理所有的任务。这就意味着任务按顺序逐个进行,前一个任务结束,才会执行后一个任务。
阻塞是指,如果前一个任务耗时很长,后一个任务就不得不一直等着。而非阻塞的原理是,当代码进行异步任务、需要花一定时间才能返回的结果的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。
1. 执行栈(execution context stack)
当我们调用一个方法时,js 会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域、上层作用域的指向、方法的参数、这个作用域中定义的变量以及这个作用域的 this 对象。 而当一系列方法被依次调用的时候,因为 js 是单线程的,同一时间只能执行一个方法,这些方法被排队在一个单独的地方,这个地方被称为执行栈。
当一个脚本第一次执行的时候,js 引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么 js 会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码执行完毕返回结果后,js 会退出这个执行环境,并把这个执行环境销毁,回到上一个方法的执行环境(如果有嵌套方法)。这个过程反复进行,直到执行栈中的代码全部执行完毕。
2. 任务队列(Task Queue)。
js 引擎遇到异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果后,js 会将这个事件加入与当前执行栈不同的另一个队列,我们称之为任务队列。被放入任务队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕,主线程处于闲置状态时会去查找任务队列中是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码,如此反复,形成了一个循环。这个过程就是“事件循环(Event Loop)”的由来。
“任务队列”中的事件,除了一般的异步事件外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。
二 宏任务(macro task)与微任务(micro task)
异步任务被分为两类:宏任务(macro task)和 微任务(micro task)。
属于宏任务的有:setTimeout,setInterval
属于微任务的有:Promise (Async/Await)中的 then 回调,MutaionObserver
异步事件返回结果后会被放到一个任务队列中,根据这个异步事件的类型,实际上又会被分配到宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回调加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈。如此循环。
当前执行栈执行完毕时,会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出靠前的事件执行。同一次事件循环中,微任务永远在宏任务之前执行,这是因为微任务是 ES6 语法规定的,而宏任务是浏览器规定的。
三 测试题
1. XMLHttpRequest 请求属于宏任务还是微任务?
async function f1() { let p = await 100; console.log(1); } function f2() { new Promise(resolve => { console.log(2); let xhr = new XMLHttpRequest(); xhr.open('GET', 'xxx.json'); xhr.onreadystatechange = () => { if(xhr.readyState === 4 && xhr.status === 200) { console.log(3); } } xhr.send(); resolve(); }).then(() => { console.log(4) }) } setTimeout(() => { console.log(5); }, 5000); let xhr = new XMLHttpRequest(); xhr.open('GET', 'xxx.json'); xhr.onreadystatechange = () => { if(xhr.readyState === 4 && xhr.status === 200) { console.log(6); } } xhr.send(); setTimeout(() => { console.log(7); }) f1(); f2(); console.log(8)
输出的顺序为:2 8 1 4 7 6 3 5,也可能是 2 8 1 4 7 3 6 5。
XMLHttpRequest
回调在微任务之后,结合微任务是 ES6 语法规定的,而宏任务是浏览器规定的,推测其应该属于宏任务。并且XMLHttpRequest
有抢时的特性,先达到执行条件时先执行。
2. async / await
async
隐式返回 Promise 作为结果的函数,那么可以简单理解为,await
后面的函数执行完毕时,await 会产生一个微任务(Promise.then)。但是我们要注意这个微任务产生的时机,它是执行完 await 之后,直接跳出 async 函数,执行其他代码(此处就是协程的运作,A 暂停执行,控制权交给 B)。其他代码执行完毕后,再回到 async 函数去执行剩下的代码,然后把 await 后面的代码注册到微任务队列当中。
console.log('script start') async function async1() { await async2() console.log('async1 end') } async function async2() { console.log('async2 end') return Promise.resolve().then(()=>{ console.log('async2 end1') }) } 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')
以上输出顺序为:script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout。
总之,事件循环是 JavaScript 异步编程的核心机制之一,它确保了异步任务的有序执行,使得 JavaScript 单线程环境下的异步操作得以实现。