仓库源文

.. Kenneth Lee 版权所有 2019-2020

:Authors: Kenneth Lee :Version: 1.0

线程的本质


线程是万恶之源,这是我的一个基本架构判断。显然这句话很偏颇,但对很多新手来说, 记住这句话很重要。很多架构问题,都是线程引起的,本文解剖一下这个问题的核心在哪 里。

软件通过抽象降低系统熵增速度。比如说,我们有两个流程:::

    def flow1():
      a()
      b()
      c()
      d()
      e()
      f()
    def flow2():
      a1()
      b()
      c()
      d()
      e()
      f2()

我们要理解它,认为每行需要一个脑力,这需要12个脑力(忽略和和主执行业务无关的定 义)。但我们发现bcde是重复的,我们可以这样优化:::

    def bcde():
      b()
      c()
      d()
      e()
    def flow1():
      a()
      bcde()
      f()
    def flow2():
      a1()
      bcde()
      f2()

这只需要10个脑力。这样,这个系统还在我们脑子可控的范围内,只要bcde的基础不变, 我们过一段时间甚至可以忘记bcde,心思聚焦在flow1和2的流程中。

但抽象是个名的问题,它可以被无限扩展的。引入一个名本身,就带来产生新的名字的风 险。比如上面这个抽象,如果后来发展和我们的设想不一致,就可能发展成这样:::

    def bcde(type):
      b()
      if type is a:
        c()
      else if type is b:
        d()
      else
        show_err()
        sys.exit(error.invalid);
      e()
    def flow1():
      a()
      bcde(a.type)
      f()
    def flow2():
      a1()
      bcde(b.type)
      f2()

这个规模一下就增长到15了,这是多余的,因为我们本来就没有这个show_err()之类的需 要,如果我老老实实直接写flow1和flow2这两个流程,根本就没有给错参数这回事。这个 错误处理是被你的抽象人为“制造”出来的。

我还见过一种常见的抽象错误:::

    def abcdef(callback):
      callback->a();
      b()
      c()
      d()
      e()
      callback->f()
    def flow1():
      callback.a=a
      callback.f=f
      abcdef(callback)
    def flow2():
      callback.a=a1
      callback.f=f1
      abcdef(callback)

这抽象的是abcdef本身的执行行为是不变的(最多是少数差别),但流程不变是最容易错 判的,所以,弄得不好就会变成这样:::

    def abcdef(callback):
      if callback->a:
        callback->a()
      if callback->b:
        b()
      else
        b1()
        c()
      if callback->d:
        d()
      else if callback.type==1:
        e()
      callback->f()
    def flow1():
      callback.type=1
      callback.a=a
      callback.b=b
      callback.f=f
      abcdef(callback)
    def flow2():
      callback.type=2
      callback.a=a1
      callback.b=nil
      callback.f=f1
      abcdef(callback)

这本质也是误判失败,觉得abcdef这个流程是个不变,没有抓住变化的本质,最后抽象变 成问题本身了。

要避免这类的问题,说到底要求我们可以抓住“不变”是什么。当我们抽象一个问题,不能 考虑它眼前是否存在重复,而是所有的定义是否使用一个稳固的,和事实相关的名称空间 。比如你做一个定时器,有几个创建函数,每个函数具体的参数是什么,这些是容易变化 的,但定时器句柄的定义,回调上下文的线程语义,这些才是第一位的,因为这些东西改 变了,所有的接口语义也跟着改变了。所以接口要跟着概念走。

概念又跟着什么走呢?概念跟着利益走。让改变不变的核心是抓住为它背书的利益是什么 。这个背书的利益,就是这个概念的本质。

那么,线程的本质是什么?我认为主要是两个:增加算力,抢占。

对比一般的程序,我们写一个main,然后实现一个序列:先a,再b,再c,……最后f。这样 的程序是不需要线程的,就算给你两个线程,你也得先a,再b,再c……没啥用。如果你用线 程t1做abc,用t2做def,这个过程除了引入额外的同步工作以外,你没有获得任何收益。 这就是以名生名,额外制造概念,这显然在降低你的系统的竞争力。但如果不同,假设b和 c之间没有依赖关系,而且都是长操作。那么我们可以把c分离到另一个线程上,如果我们 预期我们现在或者未来有至少2个CPU,那么我们可以充分利用两个CPU的算力来完成b和c, 这提高了效率。如果我们有这个需求,这时引入线程就是必须的。这个地方存在一个利益 背书:算力的需要。但为了得到这个利益,我们是付出新概念(线程,同步等)引入的代 价的。

抢占这个利益的背后是遇到事件随时打断流程的要求,比如这里的c步骤,和整个流程没有 依赖关系,但需要长期执行,而其他步骤是需要实时响应的,我们需要随时打断c的能力, 我们也会拆分线程。为此我们背上额外的时间片检查,调度中无条件保存CPU全部上下文的 代价。这些其实不是流程需要的一部分,但由于有“实时响应”这个利益背书,我们接受了 这个代价。

想想这个问题,你就会发现,我们需要引入线程的地方是不多的,而且引入得非常有技巧 。通常我们真的需要引入并行、关系不密切的多个流程,我们才引入线程。一般来说,我 们用得比较多的是同构多线程。比如有很多解码需求,一个CPU处理不过来,起10个解码线 程,它们的执行流程都是一样的,只是参数不同,这是同构多线程,这是利用算力的需要 。但我们一般不会出现每个协议一个线程这种模型,因为这很难优化,你没法动态控制每 个协议具体需要多少算力。把模块分解为线程,常常就是以名生名,背后缺乏利益背书。

在高性能系统中,基本上我们也不会基于会话的数量来创建线程,因为这样同样违反了“利 用算力”这个目标,你有100个流要解,但你只有10个CPU,你创建100个线程去处理,只会 引入额外的调度,这毫无意义。你要保持流的实时性,按报文来调度就可以了,报文调度 是确定的,靠线程来调度,调度成本是额外多出来的。

一旦我们引入了一个概念,没有用到它的利益,就会无条件背上使用的代价,最后就是我 们前面提到的以名生名的问题。

所以,很明显,线程的引入就是为了解决业务流在算力和实时性上的需要,它是系统工程 师的干活,而不是模块,接口等要素驱动的。

而正因为如此,我非常反感在基础库中使用锁。因为选择了锁,也就选择了线程库,而且 在很大的程度上选择了线程模型,就不再给业务性能调优留下余地了。我不反对你在系统 调度库上为用户选定调度模型。但那个的驱动力是你要去分析用户的业务模型,以模型为 输入进行设计。而且那个也不需要和基层的支持库绑定。

所以,简单总结,做基础框架的工程师们,请把线程看做洪水猛兽,把它留给系统工程师 ,别自作聪明出来找不自在,吃饱没事别它么每个接口都给我上把锁。