面试必问!一文带你走进异步编程

我心飞翔 分类:javascript
面试必问!一文带你走进异步编程

19c24ddc-99d7-461e-a782-a93b7de9cc5e.gif

陈晨,微医云服务团队前端工程师,一位“生命在于静止”的程序员。

异步的由来

JavaScript 是单线程语言,浏览器只分配了一个主线程执行任务,意味着如果有多个任务,则必须按照顺序执行,前一个任务执行完成之后才能继续下一个任务。

这个模式比较清晰,但是当任务耗时较长的时候,比如网络请求,定时器和事件监听等,这个时候后续任务继续等待,效率比较低。我们常见的页面无响应,有时候就是因为任务耗时长或者无限循环等造成的。那现在是怎么解决这个问题呢。。。。

首先维护了一个“任务队列”。JavaScript 虽然是单线程的,但运行的宿主环境(浏览器)是多线程的,浏览器为这些耗时任务开辟了另外的线程,主要包括 http 请求线程,浏览器定时触发器,浏览器事件触发线程。这些线程主要把任务回调,放在任务队列里,等待主线程执行。
简单介绍如下图:
截图.png

这样就实现了 JavaScript 的单线程异步,任务被分为同步任务和异步任务两种:
同步任务:排队执行的任务,后一个任务等待前一个任务结束。
异步任务:放入任务队列的任务,未来才会触发执行的事件。

异步执行机制

异步任务分为宏任务和微任务。

宏任务(macroTask)

宏任务,其实就是标准机制下的常规任务,即”任务队列中“等待被主线程执行的事件,是由浏览器宿主发起的任务,例如:

  • script (可以理解为外层主程序同步代码)。
  • setTimeout,setInterval,requestAnimationFrame。
  • I/O。
  • 渲染事件(解析 DOM,布局,绘制等)。
  • 用户交互事件(鼠标点击,页面滚动,放大缩小等)。

宏任务会被放在宏任务队列里,先进先出的原则,两个宏任务中间可能会被插入其他系统任务,间隔时间不定,效率较低 。

微任务(microTask)

由于宏任务间隔不定,时间颗粒大,对于实时性要求比较高的场景就需要更精确地控制,需要把任务插入到当前宏任务执行,从而产生了微任务的概念。
微任务是 JavaScript 引擎发起的,是需要异步执行的函数。例如:

  • Promise:ES6 的异步编程,Promise 的各种 Api 会产生微任务,下面异步实现会做详细介绍。
  • MutationObserver(浏览器):监视 DOM 树更改,DOM 节点的变化是微任务。

在执行 JavaScript 脚本,创建全局执行上下文的时候,JavaScript 引擎就会创建一个微任务队列,在执行当前宏任务时,产生的微任务都会保存到微任务队列里。在宏任务主函数执行结束之后,宏任务结束之前,清空微任务队列。
微任务和宏任务是绑定的,每个宏任务都会创建自己的微任务:
image.png

事件循环(Event loop)

主线程运行 JavaScript 代码时,会生成个执行栈(先进后出),管理主线程上函数调用关系的数据结构。
当执行栈中的所有同步任务执行完毕,系统就会不断的从"任务队列"中读取事件,这个过程是循环不断的,称为 Event Loop(事件循环)。
事件循环机制调度宏任务和微任务,机制如下:

  1. 执行一个宏任务(第一次是最外层同步代码),执行过程中如果遇到微任务会加入微任务队列;
  2. 代码执行完成后,查看是否有微任务,如果有的执行第 3 步,没有则执行第 4 步;
  3. 依次执行所有微任务,在执行微任务的过程中产生的新的微任务也会被事件循环处理,直到队列清空,宏任务完成,执行第 4 步;
  4. 查看是否有下一个宏任务,有的话则执行第 1 步,没有则结束。

因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。

image.png

异步的实现历程

回调函数

回调函数是一个函数被当做参数传递给另一个函数,另一个函数完成之后执行回调。比如 Ajax 请求、IO 操作、定时器的回调等。
下面是 setTimeout 例子:

console.log('setTimeout 调用之前')
setTimeout(() => {console.log('setTimeout 输出')}, 0);
console.log('setTimeout 调用之后')
// 结果
setTimeout 调用之前
setTimeout 调用之后
setTimeout 输出
 

setTimeout 回调放入任务队列中,当主线程的同步代码执行完之后,才会执行任务队列的回调,所以是如上的输出结果。

优缺点

优点:回调函数相对比较简单、容易理解。
缺点:不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱,而且每个任务只能指定一个回调函数,易形成回调函数地狱。如下:

setTimeout(function(){
    let value1 = step1()
    setTimeout(function(){
        let value2 = step2(value1)
        setTimeout(function(){
            step3(value2)
        },0);
    },0);
},0);
 

Promise

Promise 是 ES6 新增的异步编程的方式,在一定程度上解决了回调地域的问题。简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。
使用 Promise 首先要明白以下特点:

  1. Promise 有三种状态 pending、rejected、resolved,状态一旦确定就不能改变,且只能够由 pending 状态变成 rejected 或者 resolved 状态;
  2. Promise 实例最主要的方法就是 then 的实现,有两个参数。 Promise 执行成功时,调用 then 方法的第一个回调函数,失败则调用第二个回调函数,而且 then 方法会返回一个新的 Promise 实例。
  3. 其次常用的就是 catch 方法,catch 方法实际是 then 方法第一个参数是 null 的情况,用于指定发生错误时的回调函数。
  4. 还有很多其他的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。

下面的例子就是常见的异步操作,主要是使用的 then 和 catch:

new Promise((resolve) => {
    resolve(step1())
}).then(res => {
    return step2(res)
}).catch(err => {
    console.log(err)
})
 

step1 和 step2 是异步操作,step1 执行完之后的返回值会透传给 then 回调,当做 step2 的入参,通过 then 一层层的代替回调地域。其中 then 的回调会加入微任务队列。

Promise 为什么是微任务呢?

当 Promise 入参是同步代码时:

console.log('start')
new Promise((resolve) => {
    console.log('开始 resolve')
    resolve('resolve 返回值')
}).then(data => {
    console.log(data)
})
console.log('end')

// 原生 promise 输出结果
start
开始 resolve
end
resolve 返回值

 

首先看下 Promise 的极简实现:

class Promise {
    constructor (executor) {
        // 回调值
        this.value = ''
        // 成功的回调
        this.onResolvedCallbacks = []
        executor(this.resolve.bind(this))
    }
    resolve (value) {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    }
    then (onResolved, onRejected) {
        this.onResolvedCallbacks.push(() => {
            onResolved(this.value)
        })
    }
}

// 此时上面例子执行结果如下
start
开始 resolve
end

 

由于 Promise 是延迟绑定机制(回调在业务代码的后面),executor 是同步代码时,在执行到 resolve 的时候,还没有执行 then,所以 onResolvedCallbacks 是空数组。这个时候需要让 resolve 延后执行,可以先加一个定时器。如下:

resolve (value) {
    setTimeout(() => {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    })
}
 

输出结果和预期是一致的,这里使用 setTimeout 来延迟执行 resolve。但是 setTimeout 是宏任务,效率不高,这里只是用 setTimeout 代替,在浏览器中,JavaScript 引擎会把 Promise 回调映射到微任务,既可以延迟被调用,又提升了代码的效率。

优缺点

优点:

  • 将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。
  • 提供统一的接口,使得控制异步操作更加容易。

缺点:

  • 无法取消 Promise,一旦新建它就会立即执行,无法中途取消。
  • 如果不设置回调函数,Promise 内部抛出的错误,不会反应到外面。
  • 当处于 pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

Generator/yield

Generator 是 ES6 提供的异步解决方案,其最大的特点就是可以控制函数的执行。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器,异步操作需要暂停的地方,都用 yield 语句注明。
Generator 函数的特征:

  1. function 关键字与函数名之间有一个星号;
  2. 函数体内部使用 yield 表达式,定义不同的内部状态;
  3. 通过 yield 暂停执行;
  4. next 恢复执行,并且返回一个包含 value 和 done 属性的对象,其中 value 表示 yield 表达式的值,done 表示遍历器是否完成;
  5. next 方法也可以接受参数, 作为上一次 yield 语句的返回值。
function* getData () {
  let value1 = yield 111
  let value2 = yield value1 + 111 // 这里的 value1 就是下面传入的 val1.value
  return value2
}
let meth = getData()
let val1 = meth.next() 
console.log(val1) // { value: 111, done: false }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, done: false }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, done: true }

 
  1. 调用 getData 函数,会返回一个内部指针 meth(即遍历器);
  2. 调用指针 meth 的 next 方法,移动内部指针,指向第一个遇到的 yield 语句,输出返回值为 {value: 111, done: false}
  3. 再次调用指针 meth 的 next 方法,入参为 111,赋值给 value1,移动内部指针,指向下一个 yield 语句,输出表达式的返回值为 {value: 222, done: false}
  4. 持续调用指针 meth 的 next 方法,入参为 222,赋值给 value2,遇到 return 结束遍历器,输出返回值{ value: 222, done: true }

Generator 是怎么实现暂停和恢复执行的呢?

Generator 是协程的一种实现方式。
协程:协程是一种比线程更加轻量级的存在,协程处在线程的环境中,一个线程可以存在多个协程,可以将协程理解为线程中的一个个任务。通过应用程序代码进行控制。
上面的例子中协程具体流程如下:

  1. 通过生成器函数 getData 创建一个协程 meth,创建之后没有立即执行;
  2. 调用 meth.next() 让协程执行;
  3. 协程执行时,通过关键字 yield 暂停协程;
  4. 协程执行时,遇到 return,JavaScript 引擎结束当前协程,并把结果返回给父协程。

image.png

meth 协程和父协程在主线程上交替执行,通过 next() 和 yield 进行控制,只有用户态,切换效率高。

优缺点

优点:Generator 是以一种看似顺序、同步的方式实现了异步控制流程,增强了代码可读性。
缺点:需要手动 next 执行下一步。

async/await

async/await 将 Generator 函数和自动执行器,封装在一个函数中,是 Generator 的一种语法糖,简化了外部执行器的代码,同时利用 await 替代 yield,async 替代生成器的(*)号。

async 和 Generator 相比改进的地方:

  • 内置执行器,不需要使用 next() 手动执行。
  • await 命令后面可以是 Promise 对象或原始类型的值,如果是原始值,会 Promise 化。
  • async 返回值是 Promise。返回非 Promise 时,async 函数会把它包装成 Promise 返回。

下面来看个 sleep 的例子:

function sleep(time) {
    return new Promise((resolve, reject) => {
        time+=1000
        setTimeout(() => {
            resolve(time);
        }, 1000);
    });
}
    
async function test () {
    let time =  0 
    for(let i = 0; i < 4; i++) {
        time = await sleep(time);
        console.log(time);
    }
}

test()

// 输出结果
1000
2000
3000
 

执行结果每隔一秒会输出 time,await 是等待的意思,等待 sleep 执行完毕后通过 resolve 返回,才会继续执行,间隔至少一秒。

把 async/await 转成 Generator 和 Promise 来实现。

function test () {
    let time =  0 
    // stepGenerator 生成器
    function* stepGenerator() {
        for (let i = 0; i < 4; i++) {
            let result = yield sleep(time);
            console.log(result);
        }
    }
    let step = stepGenerator()
    let info
    return new Promise((resolve) => {
        // 自执行 next()
        function stepNext ()  {
            info = step.next(time)
            //  执行结束则返回 value
            if (info.done) {
                resolve(info.value)
            } else {
            // 遍历没有结束 ,继续执行
                return Promise.resolve(info.value).then((res) => {
                    time = res
                    return stepNext()
                })
            }
        }
        stepNext()
    })
}
test()
 
  1. 首先把 async 包装成 Promise,async/await 转换成 stepGenerator 生成器,yield 替换 await;
  2. 执行 stepNext();
  3. stepNext 里,step 遍历器会执行 next()。done 为 false 时,说明遍历没有完成,通过 Promise.resolve 等待执行结果,获取结果之后继续执行 next(),直到 done 为 true,async 的 resolve 把最终返回。

优缺点

优点:是 Generator 更简化的方式,相当于自动执行 Generator,代码更清晰,更简单。
缺点:滥用 await 可能会导致性能问题,因为 await 会阻塞代码,非依赖代码失去并发性。

多个异步的执行顺序问题

多个异步的执行顺序问题是很考验对异步的理解的。下面我们把 setTimeout、Promise、async/await 放在一起,看下返回结果和预想的是否一致:

console.log('start')
setTimeout(function() {
    console.log('setTimeout')
}, 0);
async function test () {
    let a = await 'await-result'
    console.log(a)
}
test()
new Promise(function(resolve) {
    console.log('promise-resolve')
    resolve()
}).then(function() {
    console.log('promise-then')
})
console.log('end')

//执行结果
start
promise-resolve
end
await-result
promise-then
setTimeout
 

上述例子中,外层主程序 和 setTimeout 都是宏任务,Promise 和 async/await 是微任务,所以整个流程如下:

  1. 第一个宏任务(主程序)开始执行 ------ 输出 start
  2. setTimeout 加入宏任务队列
  3. 执行 test(),async/await 加入微任务队列
  4. Promise 初始入参是同步代码,主程序一起执行 ------ 输出 promise-resolve
  5. Promise 的 then 回调加入微任务队列
  6. 继续执行主程序 ------ 输出 end
  7. 执行第一个微任务 ------ 输出 await-result
  8. 执行第二个微任务 ------ 输出 promise-then
  9. 再执行下一个宏任务(setTimeout) ------ 输出 setTimeout

总结

前端程序员日常代码经常会用到异步编程,了解异步运行的机制和顺序有助于更流畅清晰的实现异步代码,这里主要分析了异步的由来和异步代码实现,可结合不同的场景和要求进行选择。

参考资料

  • es6.ruanyifeng.com/
  • www.imooc.com/article/287…
  • www.ruanyifeng.com/blog/2012/1…
  • 极客时间 - 李兵(time.geekbang.org/column/intr…)

e9a30897-8c14-4881-9e33-5428ee948e53.gif

回复

我来回复
  • 暂无回复内容