JS 魔法堂丨深究 JS 异步编程模型

Posted SegmentFault

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS 魔法堂丨深究 JS 异步编程模型相关的知识,希望对你有一定的参考价值。

前言

上周五在公司作了关于「JS 异步编程模型」的技术分享,可能是内容太干的缘故吧,最后从大家的表情看出“这条粉肠到底在说啥?”的结果:(下面是 PPT 的讲义,具体的PPT和示例代码在:https://github.com/fsjohnhuang/ppt/tree/master/apm_of_js 上,有兴趣就上去看看吧!

重申主题

「异步编程模型」这个名称确实不太直观,其实今天我想和大家分享的就是上面的代码是如何演进成下面的代码而已。

a(function(){
    b(function(){
        c(function(){
            d()
        })
    })
})

TO

;(async function(){
    await a()
    await b()
    await c()
    await d()
}())

写在前面

我们知道 javascript 是单线程运行的(撇开 Web Worker),并且 JavaScript 线程执行时浏览器 GUI 渲染线程无法抢占 CPU 时间片,因此假如我们通过以下代码实现 60 秒后执行某项操作

const deadline = Date.now() + 60000
while(deadline > Date.now());
console.log('doSomething')

那么浏览器将假死 60 秒。正常情况下我们采用异步调用的方式来实现

const deadline = Date.now() + 60000
;(function _(){
   if (deadline > Date.now()){        setTimeout(_, 100)    }    
   else{        
       console.log('doSomething')    } }())

那到底上述两种方式有什么不同呢?

到这里我有个疑问,那就是到底什么才叫做异步呢?既然有异步,那必然有同步,那同步又是什么呢?谈起同步和异步,那必不可少地要提起阻塞和非阻塞,那它们又是什么意思呢?

谈到它们那必须联系到 IO 来说了

阻塞: 就是JS线程发起阻塞 IO 后,JS 线程什么都不做就等则阻塞 IO 响应。非阻塞: 就是 JS 线程发起非阻塞 IO 后,JS 线程可以做其他事,然后通过轮询、信号量等方式通知 JS 线程获取 IO 响应结果。

也就是说阻塞和非阻塞描述的是发起 IO 和获取 IO 响应之间的时间里,JS 线程是否可以继续处理其他任务。

而同步和异步则是描述另一个方面。

JS 魔法堂丨深究 JS 异步编程模型

首先当我们发起网络 IO 请求时,应用程序会向 OS 发起系统调用,然后内核会调用驱动程序操作网卡,然后网卡得到的数据会先存放在内核空间中(应用程序是读取不了的),然后将数据从内核空间拷贝到用户空间。抽象一下就是,发起 IO 请求会涉及到用户空间和内核空间间的数据通信。

同步: 应用程序需要显式地将数据从内核空间拷贝到用户空间中,然后再使用数据。
异步: 将数据从内核空间拷贝到用户空间的操作由系统自动处理,然后通知应用程序直接使用数据即可。

对于如s etTimeout 等方法而已,本来就存在用户空间和内核空间的数据通信问题,因此异步更多是描述非阻塞这一特性。

那么异步调用的特点就是:
1. 非阻塞
2. 操作结果将于不明确的未来返回

从 Callback Hell 说起

举个栗子——番茄炒蛋
- 番茄切块(代号a)
- 鸡蛋打成蛋液(代号b)
- 蛋液煮成半熟(代号c)
- 将蛋切成块(代号d)
- 番茄与鸡蛋块一起炒熟(代号e)

假设个步骤都是同步 IO 时

->番茄切块->鸡蛋打成蛋液->蛋液煮成半熟->将蛋切成块->番茄与鸡蛋块一起炒熟

a()
b()
c()
d()
e()

假设个步骤都是异步 IO 时

情况1——所有步骤均无状态依赖
->番茄切块
->鸡蛋打成蛋液
->蛋液煮成半熟
->将蛋切成块
->番茄与鸡蛋块一起炒熟

a()
b()
c()
d()
e()

情况2——步骤间存在线性的状态依赖
->番茄切块->鸡蛋打成蛋液->蛋液煮成半熟->将蛋切成块->番茄与鸡蛋块一起炒熟

a('番茄', function(v番茄块){
    b('鸡蛋', function(v蛋液){
        c(v蛋液, function(v半熟的鸡蛋){
            d(v半熟的鸡蛋, function(v鸡蛋块){
                e(v番茄块, v鸡蛋块)
            })
        })
    })
})

这就是 Callback Hell 了

情况3——步骤间存在复杂的状态依赖:异步执行:->番茄切块|->番茄与鸡蛋块一起炒熟

->鸡蛋打成蛋液->蛋液煮成半熟->切成蛋块|

异步调用所带来的问题是

  1. 状态依赖关系难以表达,更无法使用if...else,while等流程控制语句。

  2. 无法提供try...catch异常机制来处理异常

初次尝试——EventProxy

EventProxy 作为一个事件系统,通过 after、tail 等事件订阅方法提供带约束的事件触发机制,“约束”对应“前置条件”,因此我们可以利用这种带约束的事件触发机制来作为异步执行模式下的流程控制表达方式。

const doAsyncIO = (value, cb) => setTimeout(()=>cb(value), Math.random() * 1000)
const ep = new EventProxy()

/* 定义任务 */
const a = v番茄 => doAsyncIO('番茄块', ep.emit.bind(ep,'a'))
const b = v鸡蛋 => doAsyncIO('蛋液', ep.emit.bind(ep,'b'))
const c = v蛋液 => doAsyncIO('半熟的鸡蛋', ep.emit.bind(ep,'c'))
const d = v半熟的鸡蛋 => doAsyncIO('鸡蛋块', ep.emit.bind(ep,'d'))
const e = (v番茄块, v鸡蛋块) => doAsyncIO('番茄炒鸡蛋', ep.emit.bind(ep,'e'))

/* 定义任务间的状态依赖 */
ep.once('b',c) ep.once('c',d) ep.all('a', 'd', e)

/* 执行任务 */
a() b()

另外通过error事件提供对异常机制的支持

ep.on('error', err => {
   console.log(err) })

但由于 EventProxy 采用事件机制来做流程控制,而事件机制好处是降低模块的耦合度,但从另一个角度来说会使整个系统结构松散难以看出主干模块,因此通过事件机制实现流程控制必然导致代码结构松散和逻辑离散,不过这可以良好的组织形式来让代码结构更紧密一些。

曙光的出现——Promise

这里的 Promise 指的是已经被 ES6 纳入囊中的 Promises/A+ 规范及其实现.
Promise 相当于我们去麦当劳点餐后得到的小票,在未来某个时间点拿着小票就可以拿到食物。不同的是,只要我们持有 Promise 实例,无论索取多少次,都能拿到同样的结果。而麦当劳显然只能给你一份食物而已。

代码表现如下

const p1 = new Promise(function(resolve, reject){
   /*    工厂函数     *    resolve函数表示当前Promise正常结束, 例子: setTimeout(()=>resolve('bingo'), 1000)     *    reject函数表示当前Promise发生异常, 例子: setTimeout(()=>reject(Error('OMG!')), 1000)     */
})
const p2 = p1.then(
   function fulfilled(val){        
       return val + 1    }    , function rejected(err){
       /*处理p1工厂函数中调用reject传递来的值*/    } )
const p3 = p2.then(
   function fulfilled(val){
       return new Promise(function(resolve){setTimeout(()=>resolve(val+1), 10000)})    }    , function rejected(err){        
       /*处理p1或p2调用reject或throw error的值*/    } ) p3.catch(function rejected(err){        
       /*处理p1或p2或p3调用reject或throw error的值*/    } )

Promises/A+ 中规定 Promise 状态为 pending(默认值)、fulfilled 或 rejected,其中状态仅能从 pending->fulfilled 或 pending->rejected,并且可通过 then 和 catch 订阅状态变化事件。状态变化事件的回调函数执行结果会影响 Promise 链中下一个 Promise 实例的状态。另外在触发 Promise 状态变化时是可以携带附加信息的,并且该附加信息将沿着 Promise 链被一直传递下去直到被某个 Promise 的事件回调函数接收为止。而且 Promise 还提供 Promise.all 和 Promise.race 两个帮助方法来实现与或的逻辑关系,提供 Promsie.resolve 来将 thenable 对象转换为 Promise 对象。

API:
new Promise(function(resolve, reject){}), 带工厂函数的构造函数

Promise.prototype.then(fulfilled()=>{}, rejected()=>{}),订阅 Promise 实例状态从 pending 到 fulfilled,和从 pending 到 rejected 的变化

Promise.prototype.catch(rejected()=>{}),订阅 Promise 实例状态从 pending 到 rejected 的变化。

Promise.resolve(val), 生成一个状态为 fulfilled 的 Promise 实例

Promise.reject(val), 生成一个状态为 rejected 的 Promise 实

Promise.all(array), 生成一个 Promise 实例,当 array 中所有 Promise 实例状态均为 fulfilled 时,该 Promise 实例的状态将从 pending 转换为 fulfilled,若 array 中某个 Promise 实例的状态为 rejected,则该实例的状态将从 pending 转换为rejected

Promise.race(array), 生成一个 Promise 实例,当 array 中某个 Promise 实例状态发生转换,那么该 Promise 实例也随之转

const doAsyncIO = value => resolve => setTimeout(()=>resolve(value), Math.random() * 1000)

/* 定义任务 */
const a = v番茄 => new Promise(doAsyncIO('番茄块'))
const b = v鸡蛋 => new Promise(doAsyncIO('蛋液'))
const c = v蛋液 => new Promise(doAsyncIO('半熟的鸡蛋'))
const d = v半熟的鸡蛋 => new Promise(doAsyncIO('鸡蛋块'))
const e = ([v番茄块, v鸡蛋块]) => new Promise(doAsyncIO('番茄炒鸡蛋'))

/* 执行任务 */
Promise.all([    a('番茄'),    b('鸡蛋').then(c).then(d) ]).then(e)    .catch(err=>{
       console.log(err)    })

最大特点:独立的可存储的异步调用结果
其他特点:fulfilled 和 rejected 函数异步执行

jQuery 作为前端必备工具,也为我们提供类似与 Promise 的工具,那就是jQuery.Deffered

const deffered = $.getJSON('dummy.js')
deffered.then(function(val1){
   console.log(val1)    
   return !val1 },function (err){
   console.log(err) }).then(function(val2){    
   console.log(val2) })

但 jQuery.Deferred 并不是完整的 Promise/A+ 的实现。
如:

  1. jQuery1.8 之前上述代码 val2 的值与 val1 一样,jQuery1.8 及以后上述代码 val2的值就是 !val1 了。

  2. fulfilled 和 rejected 函数采用同步执行

遗留问题!

const a = () => Promise.resolve('a')
const b = (v1) => Promise.resolve('b')
const c = (v2, v1) => console.log(v1) a().then(b).then(c)

真正的光明——Coroutine

Coroutine 中文就是协程,意思就是线程间采用协同合作的方式工作,而不是抢占式的方式工作。由于JS是单线程运行的,所以这里的Coroutine就是一个可以部分执行后退出,后续可在之前退出的地方继续往下执行的函数.

function coroutine(){
   yield console.log('c u later!')    
   console.log('welcome guys!') }

Generator Function

其实就是迭代器,跟 C# 的 IEnumrable、IEnumerator 和 Java 的 Iterable、Iterator 一样。

function* enumerable(){
   yield 1    yield 2
}
for (let num of enumerable()){
   console.log(num) }

 现在我们将1,2替换为代码

function *enumerable(msg){
 console.log(msg)  
 var msg1 = yield msg + ' after ' // 断点  console.log(msg1)  
 var msg2 = yield msg1 + ' after' // 断点  console.log(msg2 + ' over') }

编译器会将上述代码转换成

const enumerable = function(msg){
 var state = -1  return {    next: function(val){
       switch(++state){        
          case 0:                  
                  console.log(msg + ' after')
                  break         case 1:                  
                  var msg1 = val                  
                  console.log(msg1 + ' after')                  break         case 2:                  
                  var msg2 = val                  
                  console.log(msg2 + ' over')                  break      }    }  } }

通过调用 next 函数就可以从之前退出的地方继续执行了。(条件控制、循环、迭代、异常捕获处理等就更复杂了)

其实 Generator Function 实质上就是定义一个有限状态机,然后通过 Generator Function 实例的 next,throw 和 return 方法触发状态迁移。

next(val), 返回{value: val1, done: true|false}
throw(err),在上次执行的位置抛出异常
return(val),状态机的状态迁移至终止态,并返回{value: val, done: true}

现在我们用 Gererator Function 来做番茄炒蛋

const doAsyncIO = value => (resolve) => setTimeout(()=>resolve(value), Math.random() * 1000)

/* 定义任务 */
const a = v番茄 => new Promise(doAsyncIO('番茄块'))
const b = v鸡蛋 => new Promise(doAsyncIO('蛋液'))
const c = v蛋液 => new Promise(doAsyncIO('半熟的鸡蛋'))
const d = v半熟的鸡蛋 => new Promise(doAsyncIO('鸡蛋块'))
const e = (v番茄块, v鸡蛋块) => new Promise(doAsyncIO('番茄炒鸡蛋'))

function* coroutineFunction(){
   try{        
       var p番茄块 = a('番茄')        
       var v蛋液 = yield b('鸡蛋')        
       var v半熟的鸡蛋 = yield c(v蛋液)        
       var v鸡蛋块 = yield d(v半熟的鸡蛋)        
       var v番茄块 = yield p番茄块        
       var v番茄抄鸡蛋 = yield e(v番茄块, v鸡蛋块)    }    
   catch(e){
       console.log(e.message)    } }
const coroutine = coroutineFunction() throwError = coroutine.throw.bind(coroutine) coroutine.next().value.then(function(v蛋液){    coroutine.next(v蛋液).then(function(v半熟的鸡蛋){        coroutine.next(v半熟的鸡蛋).then(function(v鸡蛋块){            coroutine.next().then(function(v番茄块){                coroutine.next(v番茄块).then(function(v番茄抄鸡蛋){                    coroutine.next(v番茄抄鸡蛋)                }, throwError)            }, throwError)        }, throwError)    }, throwError) })

悲催又回到 Callback hell。但我们可以发现 coroutineFunction 其实是以同步代码的风格来定义任务间的执行顺序(状态依赖)而已,执行模块在后面这个让人头痛的 Callback hell 那里,并且这个 Callback Hell 是根据 coroutineFunction 的内容生成,像这种重复有意义的事情自然由机器帮我们处理最为恰当了,于是我们引入个状态管理器得到

const doAsyncIO = value => (resolve) => setTimeout(()=>resolve(value), Math.random() * 1000)

/* 定义任务 */
const a = v番茄 => new Promise(doAsyncIO('番茄块'))
const b = v鸡蛋 => new Promise(doAsyncIO('蛋液'))
const c = v蛋液 => new Promise(doAsyncIO('半熟的鸡蛋'))
const d = v半熟的鸡蛋 => new Promise(doAsyncIO('鸡蛋块'))
const e = (v番茄块, v鸡蛋块) => new Promise(doAsyncIO('番茄炒鸡蛋'))

function* coroutineFunction(){
   try{        
       var p番茄块 = a('番茄')        
       var v蛋液 = yield b('鸡蛋')        
       var v半熟的鸡蛋 = yield c(v蛋液)        
       var v鸡蛋块 = yield d(v半熟的鸡蛋)        
       var v番茄块 = yield p番茄块        
       var v番茄抄鸡蛋 = yield e(v番茄块, v鸡蛋块)    }    
   catch(e){
       console.log(e.message)    } } iPromise(coroutineFunction)

舒爽多了!

async 和 await

ES7 引入了 async 和 await 两个关键字,Node.js7 支持这两货。于是 Coroutine 写法就更酸爽了。

const doAsyncIO = value => (resolve) => setTimeout(()=>resolve(value), Math.random() * 1000)

/* 定义任务 */
const a = v番茄 => new Promise(doAsyncIO('番茄块'))
const b = v鸡蛋 => new Promise(doAsyncIO('蛋液'))
const c = v蛋液 => new Promise(doAsyncIO('半熟的鸡蛋'))
const d = v半熟的鸡蛋 => new Promise(doAsyncIO('鸡蛋块'))
const e = (v番茄块, v鸡蛋块) => new Promise(doAsyncIO('番茄炒鸡蛋'))

async function coroutine(){
    try{
        var p番茄块 = a('番茄')
        var v蛋液 = await b('鸡蛋')
        var v半熟的鸡蛋 = await c(v蛋液)
        var v鸡蛋块 = await d(v半熟的鸡蛋)
        var v番茄块 = await p番茄块
        var v番茄抄鸡蛋 = await e(v番茄块, v鸡蛋块)
    }
    catch(e){
        console.log(e.message)
    }
}
coroutine()

总结

到这里各位应该会想“不就做个西红柿炒鸡蛋吗,搞这么多,至于吗?”。其实我的看法是:

  1. 对于状态依赖简单的情况下,callback 的方式足矣;

  2. 对于状态依赖复杂(譬如做个佛跳墙等大菜时),Promise 或 Coroutine 显然会让代码更简洁直观,更容易测试因此 bug 更少,更容易维护因此更易被优化。

我曾梦想有一天所有浏览器都支持 Promise,async 和 await,大家可以不明就里地写出 coroutine,完美地处理异步调用的各种问题。直到有一天知道世上又多了 Rxjs 这货,不说了继续填坑去:)

尊重原创,转载请注明来自: ^_^肥仔John




-EOF-


【活动推荐】由「开源中国社区」主办的开源技术盛会——源创会年终盛典即将于 12 月 04 日 在北京国际会议中心举行。

大会详细内容可通过「阅读原文」进行查看。


以上是关于JS 魔法堂丨深究 JS 异步编程模型的主要内容,如果未能解决你的问题,请参考以下文章

JS高阶五(JS中的异步编程下)

JS高阶三(JS中的异步编程)

node js中的异步编程通过mongoose模型传递常量/预定义的强制值

深究js

js兵器谱之魔法召唤师:call / apply

JS异步编程1:认识JavaScript的事件循环模型