资讯专栏INFORMATION COLUMN

和少妇白洁一起学JavaScript之Async/Await II

番茄西红柿 / 3506人阅读

摘要:的科学定义是或者,它的标志性原语是。能解决一类对语言的实现来说特别无力的状态机模型流程即状态。容易实现是需要和的一个重要原因。

前面写了一篇,写的很粗,这篇讲讲一些细节。实际上Fiber/Coroutine vs Async/Await之争不是一个简单的continuation如何实现的问题,而是两个完全不同的problem和solution domain。

Event Model

我们回顾一下最纯粹的Event Model。这曾经在UI编程,和现在仍然在microcontroller(MCU)编程中占据主力地位,在系统编程上是thread model了。

用MCU编程来讲解最方便,在传统UI编程上是一样的。

单核的MCU具有硬件的Thread。

Main Thread是CPU的正常运行,Interrupt Thread(一般称为ISR,Interrupt Service Routine)是硬件上的比Main Thread优先级更高的Thread,即所谓的抢先(pre-emptive)。

如果Main Thread在运行,有Interrupt进来,CPU会立刻跳转到ISR入口执行,ISR原则上应该保存现场,运行,然后恢复现场,return。return之后Main Thread重新拿回CPU继续运行。这里压栈弹栈的细节不说了,这就是一个抢先CPU的过程。

在这种模式下编程,ISR里能访问的变量,和Main Thread里访问的变量,很明显存在race,需要锁机制。在系统级编程这种锁机制就是lock,但是上述情况里Main Thread和ISR不是对称的,所以做法略有区别:Main Thread里lock的办法是禁止interrupt,然后开始执行critical section的代码,完成后使能中断;ISR里,如果不考虑ISR之间的抢先的话,不需要这个过程,因为它天生比Main Thread优先级高。

昨天我们说了业界都在把non-blocking叫做asynchronous;这里解释一下asynchronous,asynchronous的正确含义是你写了一个function,这个function在Main Thread和ISR里都能用,它叫做asynchronous function;如果是系统级编程,thread之间是对等的,它叫做thread-safe。

上述的模型在逻辑上没问题,但是有两个实践上的麻烦:

asynchronous function不好写,尤其是出现nested,在ISR有抢先的时候就更加麻烦;

禁止中断的时间不能太长,太长的话会丢失中断处理,逻辑上会出现问题;

ISR里的执行逻辑时间也不能太长,尤其不能等待什么,否则Main Thread会被block太久;

所以聪明人就有一个one-for-all的办法:

在全局构造一个event queue;

任何ISR进来的时候,不去做逻辑处理,只把当时和中断有关的状态保存下来,构造一个event,压入队列;

在Main Thread里一个一个的取event,调用相应的event handler处理。

在这个模式下:

只有event queue是需要lock的;唯一的race发生在存取event的时候,存不用lock,取的时候禁止和使能中断,这个时间不长,避免丢失中断。

中断的真正处理逻辑实际上发生在Main Thread,相当于deferred,它有延迟,但是不会乱序。

所有的代码段都运行在Main Thread,没有race,也就是人们最推崇event model的特性之一:run-to-completion。

它对于硬件抢先的多线程是个非常好的简化。

Event / State Model

那么有了Event,代码模块化的结果就是用State Machine建模。State Machine是一个理论上万能的模型,任何代码模块你都可以给出state/event行为矩阵,称为state transition table。它是对这个模块的完备定义,也极具可测试性,也应用非常广泛。OO编程的本质是State Machine建模,所有的method都可以看作是event,它可能引起Object的状态迁移。在良好设计下,State具有边界,即所谓的封装。每个State都有owner,不是owner不能修改它,避免side-effect。

在实践中,目前没有任何一个流行语言能直接写出简洁的状态机,尤其是状态机组合;所以它存在于Pattern层面而不是语言层面;客观的说这是计算工业的耻辱,但我们只能接受现状。

IO

IO不是象中断一样的自发事件。在通讯编程领域大家发明了一对名词来描述这个问题:solicited和unsolicited;不算特别恰当但有总比没有好。

IO是solicited,即有request才会有response;不需要request的那种自发event,是unsolicited event。

Event Model在处理这个问题上没有理论上的障碍,你可以对调用函数对外界进行一个操作,然后得到结果时构造一个事件。

但是在实践上,即使不考虑效率问题,这里仍然有一个大麻烦:在代码层面上,执行request的地方在一个函数里,被某个event handler直接或间接调用,处理response的event handler在另一个地方。代码的可读性和可维护性都是完全没有保障的,代码质量取决于你的信仰、星座、美食爱好、或者性取向。

Continuation

我们需要一些技术让代码看起来是人类的,不是AI或者外星人的,来对付IO问题。

更宽泛的说,Thread Model,Unix进程,Unix的一切皆IO哲学,Unix的open/read/write/close包打天下,就是我们在解决这类问题上的第一个大范围成功的案例。但是Thread Model这个话题太大了,它还包括系统资源的虚拟化和物理多CPU的并发,所以我们不用这种扩大化的概念来讨论。我们只讨论两个限定在单进程Event Model下的技术:coroutine和callback。

Coroutine

Coroutine也是一个扩大化的概念。我们先讨论广的概念边界,再来说它对io问题的解决办法。

Coroutine的科学定义是stateful或者stackless function,它的标志性原语是yield。

注意原语(primitive)是理解一种编程语言或者编程技术的最关键点。如果一种技术致力于解决某个特定问题,它最好的办法不是用pattern来解决,而是定义原语。OO语言有class,extends,implements;函数式语言允许function作为primitive value写入assignment expression;以解决并发为目标的Go语言把channel作为标志。

yield是什么意思呢?它说的不是io,它说的是cpu,交出cpu。从这个意义上说,coroutine第一解决的问题是调度。它解决其他问题,包括timer/sleep,包括io,包括把一个整体计算切碎成很多单元来实现,都是靠yield。所以正确的表述是:Coroutine不是为特别解决io问题的设计,它首先解决cpu调度,它可以用于解决io问题。

第二点,为什么我们需要coroutine?它最擅长解决的问题是什么?

coroutine本质上仍然是一个event / state model,是一个object,但是不同的是,你不需要把所有的state都显式表达出来,以对付continuation问题,coroutine允许开发者直接用语言提供的原始流程语句,来编码state信息,你运行到哪里,这个时候整个coroutine内的local variable的组合,就是当前的state,每运行一次,就是一次state transition;和对象一样它要从构造开始,到析构结束(return)。

coroutine能解决一类对OO语言的state pattern实现来说特别无力的状态机模型:流程即状态。如果你的状态机model的是一个复杂流程,充满条件分支、循环、和他们的嵌套,用coroutine写起来非常简单,而与之对应的状态机,都不用写代码,定义transition table的时候程序员就要进医院了。

coroutine对付io了吗?yes and no。它是标准的Thread Model,thread model下io什么样,它就什么样了,no more, no less。

Callback, Promise, Async/Await

这几个货本质上是一样的,区别在形式上。当然很多时候形式很重要,但是我们先谈本质。

const myFunction = (dirpath, callback) => {
  // do something
  // first io operation
  if (err) 
    return callback(err)
  else
    return callback(null, entries)
}

// my code
myFunction("/home/hello", (err, entires) => {
  // blah blah blah
})

// do something else
console.log("blah, blah...")

我们首先说callback的本质是一个event handler。调用myFunction相当于在前面说的最淳朴的event model里enqueue一个event,这个event的handler会根据event里定义的dirpath执行某个操作,操作结束的时候会构造另一个event,里面包含error或result。

这个纯粹模型的写法会非常复杂,从这个意义上说,node.js callback是一种简单的continuation实现。

But wait! 两者不是完全一致的!

myFunction函数里入口处do something部分的代码;如果是我们上述的淳朴event model,它会在当前代码结束之后执行,即console.log会先执行,等到全局的event manager开始层层dispatch event的时候,这个请求才可能landing到正确的handler,这段do something才开始执行,在console.log之后。

这是一个subtle,但是极为重要的区别。

插个话:callback形式如果在入口处do something立刻返回的话,对外部调用者来说是一场灾难,因为它根本没办法确定它提供的callback在console.log之前还是之后执行。所以callback形式要guarantee它是异步的,用process.nextTick。promise和async/await在这个问题上是一大进步,它有异步保证,即使代码形式上看起来是同步返回。

现在我们在自己脑袋上敲一锤子,昏过去,醒来的时候站在V8虚拟机的中控台上。V8激进的inline函数来提高执行效率,在源码层面上的myFunction函数调用,对V8编译的代码来说有一个call/return的边界吗?probably not!对编译代码来说,极大的可能性是执行函数边界在myFunction内部第一个io处,而不是函数入口。

如果仍然用淳朴Event Model来类比,enqueue的event是一个纯粹的io操作请求,而不是要执行myFunction函数!

所以写到这里,一个关键的概念问题阐述清楚了:

coroutine is all about how to structure your control flow unit, while node callback is all about how to structure your io operation.

他们的出发点完全不同。

FP vs OO

在建模层面(而不是语言技术层面)Funtional Programming,FP,它不是OO的对立,而是OO的超集。

在FP模型下,程序分为三个部分:Pure Functions,OO (state monads),和io (io monads)。

Pure的部分里,Pure Function只有输入输出(函数的输入输出,不是io输入输出),function和immutable数据结构是孪生姐妹。

OO的部分,如果程序需要state,OO至少在JavaScript里是绝对的最佳实践,只有少量场合可以用闭包代替。

io的部分,应该多带带抽象出来,用callback、promise或者async/await做薄层封装。

站在Pure Function的角度看,state和io都是它的外部世界。

Side Effect

Side Effect一词最广的使用上指的是一个函数是不是pure。io function毫无疑问不pure,但是访问state的呢?比如前面的代码里,如果myFunction修改了它的调用者域内的闭包变量呢?这也是side effect。

在OO里我们保障减少side effect的影响的办法,对于state(而不是io)范畴的变量来说,是用封装原则来保障的。

在FP里对这个问题的有效办法,则是immutable。

比如上面的代码,如果你传入myFunction的参数是一个对象,有深层次的结构,你会设计myFunction的函数约定是我要修改某个参数吗?或者你会防止其他程序员这样做吗?

简单的办法就是用immutable来处理在pure function domain的这类问题,大家都用immutable;即使你没有显式的包含某些immutable库,JavaScript里也有大量的集合类函数已经这样做了。

Lock

Lock分为两类,atomic operation lock,和transactional lock。

transactional lock指的是一个操作的结果是all or none的,包括更新state,也包括执行output io操作。

容易实现transactional lock是需要fp和immutable的一个重要原因。因为它让这种lock容易书写。

Early Lock vs Opportunistic Lock

你可以用一种锁对付两种情况。但是很难。用Big lock并发效率有问题,细粒度锁编程难度大;而且对于JavaScript的单进程Event Model来说,用细粒度锁对付transactional的数据完整性问题是overkill的。

另外一种锁机制是Opportunistic lock,它和数据库的事务操作是同样的逻辑:你不断的执行更新数据的操作,实际上是创建了一个副本,在最后commit的时候全部生效或失败。如果失败了可以重试这个过程。

在有immutable数据保证的情况下,如果有多步io操作导致更新过程分了几个步骤,这个不是问题,你一直在创建一个副本,在最后需要更新state monad的时候,用referential equality check检查input是否发生了变化(你也可以每一步都做,但几率上说意义不大)。

这样书写事务问题,即使对文科生改行来的程序员来说也不算太难。

IO Lock

在某些情况下IO操作的原子锁无可替代;

比如你要更新一个文件,你可以用时间戳来替代上面说的immutable referential check,即先读入文件时间戳,写入前检查时间戳是否发生变化,这么做能大大减少race的几率,但不是解决了问题,因为读入时间戳本身和写入文件操作没有原子性,可以出现race。

那么这种时候封装原子操作是必要的,传统的early lock也必要,但这是最细粒度锁,它属于原子操作锁而不是事务锁。

事务锁本质上是big lock,即使要提高效率也只是每步操作检查input,没有逻辑难度,只有代码量。

文件系统io是有需要写原子操作锁的情况的,数据库和api操作应该由提供者保证rmw操作(read-modify-write),如果需要的话。

Big Picture

所以问题不是简单的fiber/coroutine vs async/await之争,而是要站在更大的problem domain去全局的看。程序员需要的是全局的和一致的解决方案。

在前面的讨论上说过了,fiber/coroutine完全是关于调度控制流程的,而callback/promise/async/await完全是关于结构化io操作的;两者没在同一个角度上谈问题。

fiber/coroutine不是完整的问题答案,除非你的problem domain里最重要的问题是如何并发计算任务,io无所谓;

async/await回答了如何结构化io操作的问题,结合fp/immutable回答了如何在维护state和更新外部世界时解决事务性竞争问题。它是一个一揽子解决办法,而且不难。

在针对state维护的问题上,state machine/event/state model是合格的,但是它与重io操作时的结构化io操作尤其是transactional更新问题没有直接答案。nodejs本身不是general purpose的系统级开发语言和环境,它是domain specific language (dsl)。

我们不能说coroutine或者csp/channel在JavaScript上完全没有意义,但是nodejs在io并发上已经做得很好,而如果还要在计算任务并发上做得很好,支持多核,目前看差距太大了,需要解决的问题很多很多。

未来

JavaScript的未来肯定不在于目前worker引入的锁,这是个joke,属于monkey-patching。

在系统语言里不得不用的细粒度锁也不该在JavaScript里出现,也不该用于解决事务问题。

Opportunistic Lock是被数据库领域证实的和被广泛接受的solution,只是在语言一级去实现primitive支持上有困难。它需要:

JS语言和JSVM真正支持immutable数据类型;

在JSVM里有Software Transactional Memory的实现;

理论上STM支持多核是没问题的,系统语言的STM库有很多成熟的,但是JS的语言对象模型是list/hash table,在JIT层面上又要编译成类型对象,所以把对象模型扣在内存模型上并不简单。

Final

你应该花上几周的时间了解一下Haskell。

Haskell是静态语言,最纯粹的simple typed lambda实现;它有着匪夷所思的强大的代数类型系统,但是到底是静态的代数类型系统是未来,还是JIT的动态类型系统是未来,只有时间能回答了。

它有个搞笑的do语法,async/await该做的是就是haskell里do该做的。do/io monad也是最能说明白nodejs callback的设计初衷和最恰当的应用场景的。

在pure function, state monad, 和io monad之间划分清楚的界限,是程序建模的巨大进步,而不是把io封装在OO对象的操作里,它等于没有区分state和io的不同。

无论用任何语言编程,这个建模方式和划分模块的办法都是极具借鉴意义的;除非你的程序真的和老式程序一样只需要封装简单的几个文件操作。

时代不同了,web和network改变了我们编程的问题域,相应的我们在解法域需要新思维也就理所应当。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/83238.html

相关文章

  • 少妇白洁一起JavaScriptAsync/Await

    摘要:匿名函数是我们喜欢的一个重要原因,也是,它们分别消除了很多代码细节上需要命名变量名或函数名的需要。这个匿名函数内,有更多的操作,根据的结果针对目录和文件做了不同处理,而且有递归。 能和微博上的 @响马 (fibjs作者)掰扯这个问题是我的荣幸。 事情缘起于知乎上的一个热贴,诸神都发表了意见: https://www.zhihu.com/questio... 这一篇不是要说明白什么是as...

    Bryan 评论0 收藏0
  • 少妇白洁一起JavaScript

    摘要:我们已经回答了的构造函数和原型都是谁的问题,现在牵扯出来一个,我们继续检查的构造函数是全局对象上属性叫的对象的原型是个匿名函数,按照关于构造函数的约定,它应该是构造函数的属性我们给这个对象起个名字,叫。 我不确定JavaScript语言是否应该被称为Object-Oriented,因为Object Oriented是一组语言特性、编程模式、和设计与工程方法的笼统称谓,没有一个详尽和大家...

    DevTTL 评论0 收藏0
  • 少妇白洁系列React StateUp模式

    摘要:一般这种情况会在类的构造函数内创建一个属性,引用或词法域的,但后面会看到我们有更好的办法,避免这种手工代码。 换句话说,StateUp模式把面向对象的设计方法应用到了状态对象的管理上,在遵循React的组件化机制和基于props实现组件通讯方式的前提之下做到了这一点。 ---- 少妇白洁 阅读本文之前,请确定你读过React的官方文档中关于Lifting State Up的论述: ht...

    jaysun 评论0 收藏0
  • 少妇白洁如何使用React 001?

    摘要:目的是为了解决在重用的时候,持久和方法重用的问题。换句话说你不用担心把组件写成模式不好重用,如果你需要传统的方式使用,一下即可。 这篇文章所述的思想最终进化成了一个简单的状态管理模式,称React StateUp Pattern,详细介绍请参阅:https://segmentfault.com/a/11... 写了一个非常简单的实验性Pattern,暂且称为PurifiedCompon...

    davidac 评论0 收藏0
  • 少妇白洁系列React StateUp Pattern, Explained

    摘要:本文用于阐述模式的算法和数学背景,以及解释了它为什么是里最完美的状态管理实现。欢迎大家讨论和发表意见。 本文用于阐述StateUp模式的算法和数学背景,以及解释了它为什么是React里最完美的状态管理实现。 关于StateUp模式请参阅:https://segmentfault.com/a/11... P-State, V-State 如果要做组件的态封装,从组件内部看,存在两种不同的...

    20171112 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<