**把原本已经由操作系统解决的(健壮性)问题又再一次赤裸裸的暴露在了写业务的程序员面前。而大多数程序员,并没有足够的经验和能力去驾驭他**
异步编程不反人类,它反的是cpu这种笨蛋,以及因此连累了程序员而已。
最基础的异步怎么做的?
把一个同步工作一刀切两半:先做一点准备工作,然后调用异步api,同时传入一个回调,就完事了。回调呢?等什么时候被激活了,吭哧吭哧干完收尾的活就搞定了——能有多难?
但问题来了:cpu它笨啊——它不会自己去找它需要什么样的数据,只能让程序员不厌其烦的告诉它每条数据都分别在哪。所以,前半段的数据好说,让调用者传参进来就行了。但后半段的回调就麻烦了,这回调又不是我程序员自己调用的,数据从哪来?这事用学术点的术语来说,就是:(在你一刀把工作流切两半时)上下文也同样被切断了——所以你需要做上下文切换和现场保存工作。
> - **同步做事**:就好比你一个人去面馆点了碗面,你一直坐在那里等面做好,做好了再去拿,期间你就干不了其他事了。CPU 也一样,一旦开始做某件事,就一直要忙活这个事,直到它真正完成。
> - **异步做事**:同样是去面馆点面,但你把单子一交(相当于"发起一个请求"),就直接去忙别的事了(CPU 可以去处理其他操作)。等面馆给你打电话("回调")说面做好了,你再回来接着端面、吃面。这样就能更好地利用时间,让"CPU"别干等着。但是,这中间有个问题:你要么记着自己是来干啥的,要么得给面馆一个"标记"(额外参数),不然对方叫你回来时,你不记得自己点过面,面馆也不记得这面是给谁的,双方的"上下文"就断了。
所以,为了解决这个问题,大多数异步api都会提供一两个额外的透传参数让程序员有办法重新连接起上下文。
如果你的上下文很小,能完全塞进透传参数里(一般就一两个int),这一切依然非常简单美好。
好,麻烦开始来了:如果上下文在参数里放不下怎么办?
你要借助堆来暂存。然后把这块内存的指针传过去,那边用完了再清掉——看上去也不难。但问题是:你的那个回调函数,一定会被调用吗?如果没回调,这块内存可就泄露了。
这还是有比较通行的方案的:全局弄一个上下文管理器,把这内存指针放过去,在回调里重新从这管理器里重新找到对应的上下文就可以了。那没回调……就定时清理咯。于是,你又要多一个定时器和定时器线程(当然可以和其他定时需求合并共用)。
但有了定时机制还是不够——你说我这超时值该多少呢?或者更明确一个场景:如果它回调时相关的上下文已经被清理了,怎么办?更麻烦的是,在超时清理上下文时,很可能不仅仅是清理那块内存,还可能有其他相关的清理工作——这一切都清理了,然后这时你告诉我这活一直干得好好的,其实不需要清理???!!!
好,退一步说,这我也忍了,大不了就当白干嘛。但事情真没那这么简单:你在异步api里做的事情,有没有副作用的?如果有,这上层看上去工作已经被清理了,但底层实际上工作又做完了,就会出现状态不一致——往下就会有不少逻辑问题出来了。
> 在**同步**模型下,这些细节通常都存在"当前函数的栈里"或函数的局部变量里。你不需要操心"数据在哪",等函数执行完,这些数据自然就用完了。
> 而**异步**模型下,需要你自己小心地保存和传递这些细节,比如用透传参数、全局管理器、堆内存等。万一没传好、没管好,或者回调永远不触发,就会遇到内存泄露、数据丢失、状态不一致等问题。
意识到这麻烦不小了吧?
例如说如果是异步读文件,这事没副作用,大不了就当浪费一次io嘛,没什么大不了的。但如果是写文件呢?如果能覆盖写,还可以尝试重新发起个一样的任务redo一下(这也不一定可靠)。但如果是追加写,怎么办?是让底层undo刚才的操作?是让上层undo刚才的清理工作?无论哪个,都不简单——甚至有些场景可能就根本做不到。
所以,这里的问题已经接近了数据库/存储引擎的那种"事务"的概念了——你的工作设计上,要么有redo能力,要么有undo能力,要不然就自求多福咯。
那最后一个问题是:有多少程序员敢拍胸脯说自己能设计/定制一个健壮的带事务的存储引擎?或者给自己的每个异步工作都设计一套足够健壮能随时redo/undo的机制?
甚至都不用说动手撸代码实现它,光说逻辑设计,我敢说相当一部分顶着"架构师"title的人都实际上搞不定这活。
说完了问题。
就说一下解决方案的本质:就是业务层在设计上,就必须要有一套运转良好的状态机,明确各个状态,以及对应的状态迁移函数。把这玩意在逻辑上吃透了,这异步的问题才算是过关了。
至于说[[协程]]这玩意,它确实通过底层架构的设计,简化了上下文连接和对接的工作。简化了撸代码时的心智负担,也减少了各种手贱bug低级错误bug。但是,在逻辑上,上下层状态不一致的可能性并没有消除(甚至在概率上都不见得降低了)。
为了研究协程"用同步方式写异步代码"的作用和价值有多大,可以顺带再思考一个问题:同步api,就一定没有这样的问题了吗?也就是说,你用同步api发起一个操作——假定是写磁盘吧——那在内核里还是会把你的上下文切出去让给其他线程,等到dma,中断,信号,再到内核把你的上下文重新切进来……理论上,上面的流程任何一环出问题,它一样有可能永远卡住你的线程的……本质上还是一样的。
**但我们为什么会觉得同步api简单?是因为我们通常相信cpu和外设芯片是没有bug的,操作系统的内核是久经考验的……那如果你真的无比信赖异步库的健壮性,那你同样可以把这事做得非常简单。
**这个是否信任问题还带来另一个区别:原本同步模型里,在工作没有确定最终完成之前,所有的"半状态"都可以以局部变量形式保存在调用栈上,等到最终结果才合并提交到全局。这样,就算线程真的被永久卡住了,至少不会污染全局。而我们又相信内核基本上是可靠的,所以我们基本上也相当于实现了最终一致性。但异步,我们之所以要做全局管理器什么的,本质还是不信任它的可靠性和健壮性。如果真的信任它的话,我们照样可以简单的new一段内存出来,然后指针传给回调就完事了。最终一致性?和同步是一样的……**
所以,异步编程为什么难?
抛开前面那些理论和术语,最简单的就是:是因为它把原本已经由操作系统解决的(健壮性)问题又再一次赤裸裸的暴露在了写业务的程序员面前。而大多数程序员,并没有足够的经验和能力去驾驭他——这大概就是为了榨取高性能的代价吧。