封装异步编程中时间的理念

发布于 · 最后修改时间

异步编程,本质就是要充分利用时间。但现代异步编程对于时间仍旧是一个很片面的理解,比如关于“超时异常”,我们往往只是定义一个 30s,超过这个时间就是失败。而所谓“健壮的异步程序”,往往也只是堆砌地使用这些定时器而已,这里头缺乏了一个“系统地时间理念”来规范时间的使用与等待。

从业务或者功能等角度,可以定义出各种时间的概念,比如渲染的、网络的、磁盘的等待。
但进一步解剖,其实可以用两种时间概念来替代:
“我自己花费的时间”“我等待别人的时间”
进一步简化就是:“计算时间”“等待时间”

这里我是以一个“程序包”的角度去理解时间,无关“线程/进程”、“网络”、“磁盘”等待。
接下来一边分享我的理解,一边进行编程所需要的设计封装。

计算时间(我自己花费的时间)

和人一样,如果自己是在做正确的事情,那么我们不会认为自己在浪费时间,自己也就没必要给自己“计算耗时”,毕竟“正确的事情”是最总要的,计算耗时反而会转移自己的注意力,不是“正确的事情”。
所以我们不会去给“计算时间”挂上计时钩子,而是一个程序的执行消耗多少时间也不是固定的,会被设备的状态所影响,比如低电量、一个 CPU 线程中有多个程序在切换调度互相争夺资源 等等。
但是程序之间可以互相统计对方消耗了多少时间,由此来做出自己的判断。不过这一步往往是“系统内核”在做的,因为是它在决策程序的调度,所以它应该统计并记录每一个程序的执行时间、压力状态。
这些信息都将帮助整个系统变得更加的稳健,而不是单一地使用超时来决策接下来的作业。

举个例子:

前段时间我就遇到一个 BUG,我封装了一个请求,如果请求超时,那么过一会儿再去请求。结果超时的不是别人,而是自己的封装中,有一些需要进行排队的验证。其实请求的结果已经返回了,而因为排队时间过长导致上层的流程认为需要重试,结果就又重新发起了一次请求,而后请求的结果又一次排队验证,外层又一次认为超时……一般来说因为验证很快,所以不会出现这样的恶性循环,但因为验证模块是共用的,所以很可能被其它模块的调用给塞满任务。
一个复杂的系统中,出现异常的原因很多,各种模块都有自己的异常情况,如果我一段程序依赖了其它三种模块,那难不成我要考虑这三种模块的所有可能涉及的异常吗?

理论上来说我们确实可以通过抽象封装来简化一个已经依赖了很多程序的程序。再通过缓存结果、合并请求等等手段来进行加固。但这样的程序结构在愈发庞大的时候,仍然是包含着内耗的。这就是是为什么很多系统,一开始跑着没问题,跑着跑着就只能“重启”了。
因为“封装”本就是人面对复杂系统的一种简化措施,它需要配合各种缓存优化才能达到一个更高的可用性。而缓存优化本身就是以一种带有副作用的行为。比如说我们调用缓存时,内存不够了怎么办?一般来说都是没有后路地去写下程序吧,没内存了,重启吧……正是因为大量没有系统性的规范操作,才导致了一个系统无法长时间地运转,当然我们也看过那种自动重启来解决问题的。自动重启在有分布式集群的时候确实蛮实用,但这篇文章探讨的是如何在一开始就规避这些问题,而不是如何补救。
所以解决问题最好的方法之一,就是面对它们!而不是一味地迷信于一个程序自己“封装”的力量。
如果“人”不能直接做到对所有依赖程序的异常处理进行最优解,那么这就应该交给系统去做。
一个程序一个模块对于异常,不应该自己藏着掖着,因为它自己不知道下游的需求场景,所以它最好是暴露出相关的信息。这些信息经过系统统筹,以及调用堆栈的层层决策之后,才有一个真正意义上正确的结果。

以刚才的例子来说:点击按钮发起一个请求,理想中,它应该 0.5s~1s 内就返回结果了,结果到了 800ms 还是没有响应,这时候这个按钮应该要知道,到底出现了什么问题,好让用户能去及时做出一些更加正确的决策,而不是再让用户白白浪费时间。在传统系统中,上游程序并不知道下游发生了什么,但如果系统能告知按钮程序:“其实数据请求已经返回了,是验证排队满了,而验证排队满了是因为另外一个程序在大量调度,可能需要再等上 2s。”当用户得知了这些信息,这时候就等于将选择权交给了用户,让用户有事情可以做了。这对于整个系统长远来说是更益处的,一方面是在当下,模块之间(包括人)可以做出更加正确的事情,另一方面,大量的决策信息,在使用人工智能加持后,对于使用者来说就会越来越好用。比如下次还是遇到这种情况的话,用户愿意等,而且在等待之后的事情并不是与其它程序竞争资源,那么也许就可以让用户插个队~~

从刚才的例子也可以看出,这里头最重要的其实是“沟通的艺术”。说白了就是,一个模块一个人,如果 ta 想要做决策,那么它需要的是什么样的信息?

再举个例子:对于渲染程序,往往就是我们的主线程在承载。当我们位于子线程中的逻辑运算过慢的时候,渲染程序并不需要为什么算得慢,而是只是想向用户反应“计算慢了”这个信息。通过这个信息,它可以通过延长动画时间,来向用户表达当前设备的计算压力,也借由这些动画,可以用来减慢用户的操作速度,进而减缓计算模块的响应压力。从而达成一个正循环的机制。所以,在这里,它需要的信息是:“程序现在有多慢”。

这与刚才按钮程序想要知道“为什么慢”,这里就说明一个模块所需要的信息就是不一样的。包括人这个模块也是,人能决策的只是 ta 自身能操作的东西,比如开关程序、操作程序、等待结果。比如读一个文件,卡了,系统不应该用户:“磁盘现在很忙,因为system.logs模在疯狂写入”。而是应该说:“视频程序导致了文件读取比较慢,可能还需要再等 3 秒钟”。亦或者你直接跟一个正在读写磁盘的程序说一些主板才能听懂的话,那也没意义。

等待时间(我等待别人的时间)

这其实包含了“别人的计算时间”以及“信息传输时间”。当我们在讨论:处理”等待时间“过长问题的时候,并不是在处理异常,而是将这种定义成非理想流程的执行。过去我们一直将这类流程统一归纳成了错误或者异常,总是想用各种省时省力的方法将它们扭转到所谓“正确的流程上来”,却忘了一分耕耘一分收获。若不认真对待它们,特别是当它们在复杂系统中,自然而然就会混沌起来。
所以我不建议将它们当成错误或者异常,而是将它们作为正常且必须面对的流程来处理。所以下文我们用“异流”来对其进称呼。

你可以看到其实有些编程语言就有考虑过这样的设计,比如 Go。

但它是通过多返回值来传递错误,这也是不得已的设计,虽然被人诟病,但其背后的理念支持是多少 BUG 堆叠而来的。可 Go 的哲学导致对它的设计过于简单,所以对于开发者来说,反而也会变得复杂和迷糊。
还有,大部分静态编程语言都能有根据类型进行捕捉的能力。但这种写法很容易造成雪崩效应。

所以我们才需要一个系统的理念来处理它们,上文提到很重要的一点:在异流中,我们需要根据不同的场景来得到不同的信息。
所以这里我们先对信息进行定义:

  1. 上下文信息

    这点可以用“程序堆栈”来理解,但准确说堆栈只是描述了“上文”,它可以让我们知道已经走过了哪些程序,消耗了多少的资源,“下文”是指它即将做什么任务,即将消耗哪些资源。

  2. 统计信息

    这里是指对于资源消耗的统计。
    补充一下对于资源的定义:不同的程序、模块是可以自己定义资源的,也可以将自己的资源消耗细分成到某一种资源下的子资源。所以从程序角度来说:资源是一种全局的不断累积或者不断变化的变量

我们以上文“渲染层动画时长”的实现为例:

@media only resouce-usage and (module-name: "my.logic" and cpu:80%){
    --base-transform-duraction: 400ms;
}
@media only resouce-usage and (module-name: "my.logic" and cpu:50%){
    --base-transform-duraction: 100ms;
}
@media only resouce-usage and (module-name: "my.logic" and cpu:20%){
    --base-transform-duraction: 30ms;
}

待续~