仓库源文

.. Kenneth Lee 版权所有 2020

:Authors: Kenneth Lee :Version: 1.0

一个关于4+1视图的案例:从概念视图开始


本文和别人讨论一个线程调度模型的接口设计。顺便手把手展示一次怎么做基本的低层架 构设计。

我们抽象一下问题:我有一个IO接口,有多个IO设备,也不管它有没有内核用户态分层, 通讯到了设备上有没有对端(例如通过网卡和一台服务器通讯),反正就是我有一个“应用 ”,和它进行通讯,通讯过程有状态,我们讨论这个接口设计怎么考量。

    .. figure:: _static/IO设备概念视图1.jpg

这个图啥意思,估计都能猜到一点,但表义是不是清楚呢?其实不一定,应用怎么和“背后 那个实体”通讯,这个概念怎么表达?其实不那么容易,但如果用IO设备提供的接口代码来 给你表达,可能就容易理解一些了:::

    dev = io_pick_dev(dev_requirement)  # 找到设备
    ctx = dev.io_create_ctx()           # 从设备获得通讯上下文,表示“我”这个应用
    result = ctx.do_request(param)
    result.handle_result()              #这不是API的一部分,指代用户的处理过程
    free(ctx)
    free(dev)

但直接把接口给出来,又涉及了太多的细节。其实这里用类Python的伪码表述,抛弃具体 的语言约束,已经比较强调其中“抽象”的部分了。但它仍不是最核心的约束,比如我们迫 不得已用do_request()来表示一种和ctx关联的行为,但这种关联可能不止这一个函数,不 少有经验的人能猜到有不止一个这样的函数,但至少表达上这个关系并不清晰。

所以,我们最基础的一层建模会建得更抽象(飘渺),我们只关注这个接口上呈现出来的“ 概念”。

从概念空间说,这里定义了几个概念:dev, ctx, request, 基本和上面那个图表达的意思 差不多,但如果我们标准一点用UML语法,由于有表达共识的存在,我们谈这个概念会容易 得多。比如这样:

    .. figure:: _static/IO设备概念视图2.jpg

    特别提醒一句,这里的“线程上下文”的概念只是表示一个连续的会话过程,表示
    一个应用程序中,和ctx进行有先后要求的一组对话,它不需要是一个线程。这种
    类型的“共识”定义,也是概念空间建模的一部分,概念空间建模并非是一幅或者
    几幅图。

这就叫概念建模,这才是“形而上”的设计,它不受API怎么写控制,也不在乎多一个 do_request1()还是少一个do_request2()。这里的“应用”也是最原始的“用我们的功能的那 个抽象的东西”,并不表示就是一个进程了,一个进程也可能申请两个ctx,在两个“应用场 合”(或者这里说的“线程上下文”)里面分别访问它。这种概念上谁和谁是什么关系,直接 影响你怎么分解模块,谁和谁的线程可以分离,哪个“概念”(包括那个概念的属性)不能 被哪个模块“看见”,从而实现“抽象”。我们谈架构设计,是谈这种脱离了现实,直接讨论“ 关系”的设计,不能从这种形而上的角度上思考问题,你是看不懂架构设计的。

建立这样的抽象,是提升我们选择的自由度,因为这个概念空间不关心具体你用什么语言 ,用什么线程,用不用协程,靠调用来通讯还是靠消息来通讯……这些都是自由度。如果我 一开始建模就是具体的东西,我的选择就被自己的“描述能力”给阻塞了,我的自由度就不 高了,我就不好找一个最优解了。

但另一方面,越是形而上,就越是“缺乏设计”。我们得继续寻找不得不引入的约束,尽快 把更多的约束加进来,这样,到我们做细节设计的时候,所有这些约束就成为条件了,我 们建立逻辑链就顺理成章变得很容易了。

    | 这就是为什么说架构师都是Masochism。
    | 因为整个设计过程就是一个把自己(和团队)束缚起来的过程:
    | “小明从A地去B地,需要多长时间?”,不知道。
    | 加一个约束“A地到B地距离10公里”,这可以得到一定的范围,
    | 再加一个约束“小明平均速度5公里每小时”,这个结果就唯一了。
    | 整个设计过程,要不你自己加入约束,要不发现约束。
    | 架构师的工作是用收益构造约束,从而让约束完整的时候,
    | 得到最大的收益。而失败的架构师是不知不觉中引入了无数的约束,
    | 外面的利益还没有来呢,就动不了了。比如前面这个例子,
    | 一开始引入一个约束:“小明坐飞机过去”,
    | 这个“设计”本身有一个约束“起飞(到正常巡航)距离200公里”,
    | 然后这个问题就不用解决了。
    | 这一段说明,是希望读者可以理解我们煞费苦心从一个最小约束
    | 来分析问题到底要干什么。

所以,概念视图是我们(约束)的根,但我们不能只有根。现在我们开始用“不为天下先” 的策略开始给这个框架加约束,让这个形而上落下去,去接近形而下。

应用找到一个Dev,和设备通过Ctx建立一个关联,然后给Ctx发送请求,从而获得结果。这 个概念看起来不错,总能写出API来。一旦考量这个API,我们马上就会面对运行模型的问 题:同一个ctx,能否被多个线程上下文访问?

这说起来是我们自己的一种选择,但正如我们前面说的,我们不“选择”,我们不敢为主而 为客。我们自己不制造约束,我们是大自然约束的搬运工。是有(注意这个“有”,我们需 要确定这是否是事实)客户在多个线程的上下文中调用同一个ctx,所以他问了这个问题, 如果他没有这个问题,这个就不是问题。我们不提前解决问题的。

那我们开始“食母”,我们来讨论一下:为什么用户需要在多个线程中使用同一个ctx?可能 这个IO通讯是多个并行业务流中的一步,多个业务流访问同一个资源,这个资源需要保护 。

但谁来保护?一个业务使用一个公共的资源,伴随这个公共资源的一般会有其他的数据, 那些数据也会需要保护对吗?从整体来说,这两个资源应该一同保护,这种情况下,如果 我提供了一个保护,就会产生两把锁。

“两把锁”,这能让你想到什么?我只想到死锁。

所以很简单,在我最基本一层抽象中,我是不可能引入锁给你做保护的。引入锁引入了两 个约束:

用户整个程序的锁控制被你限制了一部分,因为他要考虑你的锁关系避免和他自己的锁互 相死锁用户可以选择的线程库被你限制了,因为一个系统很难让两个线程库共存,继而其 他依赖不同线程库的其他库的选择也被你限制了,比如有人使用协程库,就会对线程库有 要求。

所以我们说,做选择,我们其实一直是在收益和约束之间做平衡,多接受一个约束,我们 的设计就越好做,但相应我们也失去了其他的选择。但我们希望在这种选择中“救”下更多 的代码。所以我们会有这样的开发视图设计:

    .. figure:: _static/IO设备概念视图3.jpg

这个模型中我们挽救了iolib_base这个模块,让iolib对pthread的依赖聚集在iolib之内, 而且我们不拒绝应用把依赖直接建立在iolib_base上。

好了,现在假设我们决定吃下pthread依赖这个坑,我们开始挖掘iolib的问题。

    | “吃下”这个概念很有意思。我们设计高内聚,低扇出的模块。
    | 目的就是可以单独升级和替换。比如上面的iolib,
    | 我们依赖了pthread,把和pthread有关的东西都包在iolib内了,
    | 这样如果我们替换pthread,只要换掉iolib就可以了,
    | 不会影响到iolib_base。一旦iolib吃下pthread,
    | 我们就不会忌讳充分利用所有有可能的pthread能力,
    | 因为我们希望用尽这个依赖给我们产生的逻辑优势。
    | 这个过程就好像鱼吞吃东西:张开大口把一个依赖吃进肚子,
    | 肚子大了,但鱼嘴还是很小。形成一个高内聚,低扇出的模块。
    | 这就是在基层的构架设计中主要解决的问题。
    | 我们就是在不断权衡这些依赖关系,
    | 看让哪个模块把哪个依赖吃进去,让系统的关系变得有条理,
    | 免得很快变成混沌系统,加入任何一个逻辑都影响所有模块
    | 或者不同的概念空间,一旦进入这种状态,
    | 后面加入需求的难度就变得非常高,甚至不可能了。

用户用多个线程来访问ctx,可以有三种设计思路:

第一种,每次请求上个锁就可以了。一个线程弄完,下个再来。这个方法简单粗暴,最大 的问题在于,它会破坏流水线。设想一下,IO设备有多个执行部件(无论是并行的还是串 行的),每个都需要时间才能完成一个请求,那么第一个线程的请求下下去,没有完成前 ,其实第二个线程的请求也可以下去了,但用现在这种方法,第二个线程只能等着,这个 效率发挥不出来。

如果我们承认这是个问题,我们就需要让把数据放下去和收回来这两个过程断开。所以第 二种方法可以是这样的:

    .. figure:: _static/IO设备概念视图4.jpg

    注意:主业务时间线不止一个实例(线程),但一般来说,由于ctx的限制,IO处
    理线程通常只能容纳一个或者两个(把收分离出来)实例。

这个引入了另一个约束:iolib里面自己创建了一个线程。我们说过了,引入约束写程序容 易,但这个约束会叠加给使用者,比如你多了个线程主程序处理signal的时候要不要考虑 这个signal是从你这个线程发过来的?调优整个程序的时候,你这个线程要不要绑定到特 定的核上?这会多了很多其他约束给使用这个库的应用。

无论如何,如果我们决定“吃下”这个约束。我们就要正经做下一层的设计,比如:

  1. 如果考虑全系统的平衡,你这个IO队列具体应该用几个线程?(这个问题我们后面还会 讨论)全系统所有ctx用一个统一线程?还是每个ctx用一个线程?这也需要作出决定。

  2. 维持IO设备压力的算法,比如下去多少个再收回一个?这个问题在这个上下文中不好处 理的,因为你用了独立的线程,而这个线程又和业务线程一起调度,这有很多细节问题 要解决的,但无论如何吧,从高层设计来看,还是可以一赌的。

  3. 其实这种“主动调度”的行为,挺适合使用“协程”的,但引入协程,就开始引入新的约束 ,这个讨论起来就更复杂了。本文不考虑这个方向。

很多人还会选择第三种方案——用回调:

    .. figure:: _static/IO设备概念视图5.jpg

    绿色部分的内存实现在同一个模块中,但属于不同的线程

当主业务把请求送到IO线程中,注册一个回调函数给它,这样收到响应的时候,后面的处 理就可以用IO队列的线程的时间来完成处理了。

人们容易从API上觉得这种所谓“异步”调用很方便,但一旦你考虑到线程的压力,就会发现 ,这只适合最后处理IO响应的压力不大的情形(通常是发完不管的场景,因为这种情况下 ,你收到响应唯一要做的事情是释放资源)。基本上我们认为“处理IO响应”这个CPU占比不 多才可以用,否则这个回调线程自己就会成为瓶颈。而且同一个流程的两个处理在不同的 线程上,你说不定又有锁的问题,锁问题一上来,死锁和互锁等待的问题也出来了。其实 这条路并不好走。

说起来,每条路都是有好处也有坏处的,不分析你的目标市场,你根本就不知道那条路是 对的。所以,这部分的接口设计,你需要的是确定你的目标市场到底是那种情形。如果确 定不了,就尽量把逻辑分隔开。

上面这样的分析方法主要还是站在IO设备是被动一方,主业务流都在CPU的计算上的情形。 如果IO设备的的性能才是整个性能的关键就不能这样看这个问题了。最极端一点的,我们 做IO设备的专用功能服务器(比如一个AI训练服务),或者干脆我们就做一个benchmark程 序,我们又应该是什么接口?

要让IO设备占满,整个核心就是一看它闲下来就要调度CPU来喂数据。

我们还是分两种情况,一种是CPU不是喂数据的瓶颈,我们没有必要依赖线程。我们先推演 直接基于iolib_base可以怎么做:::

        dev = io_pick_dev(dev_requirement)
        ctx = dev.io_create_ctx()
        req_pool = create_req_pool()
        while True:
          for i in [1..bunch_in_a_cpu_deal]:   #发出多个请求
              param = create_new_request()
                  req_pool.add(param)
                      ctx.request_async(param)     #异步发出请求
                        for i in [1..bunch_in_a_io_deal]:    #回收一组相应
                            result = ctx.pull_result()   #等待结果
                                param = req_pool.match_param(result)  #从结果找回当初发出的那一个请求的上下文
                                    param.handle_result(result)  #继续这个响应
                                        free(param)
                                        free(ctx)
                                        free(dev)

这里的核心是在维护设备的压力,保证对它的请求是充足的,剩下的时间才用于处理结果 。但这样还会导致另一个结果,就是这里的req_pool深度如果不控制,一旦送入和消耗不 平衡,送入多于消耗,param可以无限增长。我们只要控制它就可以了。

这样这个地方整体上就有了一个可以抽象的概念空间了:我们不关心什么时候发,什么时 候收,我们有数据,你告诉我怎么发,怎么收。这个API可以变成这样:::

        def input(ctx, param):
          ctx.request_async(param)
          def output(result, param):
            param.handle_result(result)
            def match(sched, result):
              return sched.private.param_pool.match_param(result)

              sched = io_create_scheduler(dev_requirement, 
                input, output, match, max_param) #提供的调度需要的回调函数
                while True:
                  sched.run_a_step()
                  free(sched)

这就把整个调度的驱动力全部交给了设备这边的逻辑,提供数据一方变成被动回调的。调 度器每一步交给应用程序,调度器根据ctx的水线决定下一步应该做数据准备(input)还 是响应处理(output)。

这个概念空间是这样的:

    .. figure:: _static/IO设备概念视图6.jpg

我们用Sched封装掉了Ctx和Dev的概念了,虽然其实Sched上是暴露这两个数据结构的,但 我们都认为那是Sched自己的概念细节了,是应用Sched进行通讯的参数问题。应用现在面 对的问题仅仅就是:用自己的线程调度Sched,然后靠Sched决定自己什么时候发,什么时 候收。至于收发的收,就用ctx的函数进行实际地收发自己的数据就是了。这里仍暴露了 ctx,但解开了从哪里拿到ctx这个问题,也把水线控制这个问题丢出去了。如果sched中 ctx直接有水线控制这个逻辑,应用就是看不见这个逻辑的。这种关系相当微妙,但看惯了 也很容易理解。

用前面一样的考量方法,这个实现升级为多线程的行为也没有什么困难的,无非是多个线 程的时候如何分别调度这些input和output的问题而已,完整的概念空间可能是这样的:

    .. figure:: _static/IO设备概念视图7.jpg

用户接口可能是这样的:::

  def input(ctx, param):
    ctx.request_async(param)
    def output(result, param):
      param.handle_result(result)
      def match(sched, result):
        return sched.private.param_pool.match_param(result)

        sched = io_create_mt_scheduler(dev_requirement, 
          input, output, match, max_param,
            number_of_ctx, number_of_dev)
            sched.start()    #启动多个线程进行调度
            ...              #干别的主线程的事情
            sched.join()     #等待调度结束
            free(sched)

我说了这么多,不知道读者们有没有意识到:最后这个mt_sched的方案,恰好就是前面多 线程访问ctx的调度方案的泛化模型。基本上你要做复杂的调度,都可以用这个调度器去决 定加线程,加ctx,加dev,加深度,最终都是决定什么送数据到哪个ctx中。

这样,我们综合所有这些模型,我们可以给出这样一个开发视图:

    .. figure:: _static/IO设备概念视图8.jpg

我们的约束就分离到不同的层次上了。应用基于libsimple的接口,只是多线程调用的一种 接口。它可以认知ctx,也可以完全不认知,仅在初始化的时候给定ctx的参数,后面的io 请求可以看见它,也可以看不见它。而核心的线程调度封装在libsched_mt中,而libio则 封装最基本的ctx接口。这样我们整个库的依赖就可以一层层向下剥离了。

最后我们考虑一个独立的问题:这种类型的IO访问,很多人都会提到所谓的“块式IO”和“流 式IO”的区别问题,所谓块式IO,是说如果应用有多个请求,这些请求没有什么顺序关系, 可以一次都送下去。而所谓流IO则反过来,前一个请求没有结束前,下一个请求不能向下 送。但其实这个问题整个和前面的模型都是没有关系的。因为这不是调度器如何下数据的 问题,而是提交方怎么下数据的问题。你在libsimple里面多制造一个上下文参数,记住这 一串顺序的上下文就是了,和我们前面的推演一点关系都没有。

总结起来,4+1视图,本质就是用一个个的“需求”(Use Case)去给我们开发中最难用代码 去表达的问题制造约束,让我们提早把概念控制在不同的范围内,为控制系统熵增提供基 础。