如何并发
新年以后十四天过去了。一月份的一半,一年的二十四分之一。而在这之中的每一天,也是一眨眼睛就几个小时,一晃天就黑了。真的很快。
来来回回想了好几次这篇是自己偷偷写还是往知乎上写,还是决定先写这边。毕竟目前还只能算是未完待续,等到我搞定了(或者说,不折腾了),再搬到那边去也不是不行。
毕竟,当下最要紧还是把想到的都记下来。
我要写的(科研)代码各种各样,但是它们大抵都是并发应用。
这里说的并发并不是指分布式,而是单机层面的并发,虽然分布式也是单机并发的一个重要原因。由于多个节点必须透过网络相互通信,所以对于每个节点来说,并不存在一个确定的完全任务的顺序——并不能平铺直叙地写先收谁的消息然后收谁的消息这种逻辑,因为这些消息并不一定按照预想的顺序到来。更糟糕的是,并不能假定一条消息一定会到来,而如果它始终没被收到,那就会把程序彻底卡在等待接收它的地方。
为此,必须在一个节点上建立并发的工作模式。一个节点必须在同一时刻既做这个又做那个,比如同时接收多个节点发来的消息,表现出来的就是哪个节点的消息来了,处理对应消息的任务都会继续进行下去。处理各个消息所完成的工作并不遵循某种既定的顺序,如果消息到达的顺序变化了,那么节点执行任务逻辑的顺序也会跟着变化。(当然这里指的是不同平行宇宙之间的不同顺序,消息到达了就是到达了,它的顺序是没法改变的。)
大概是最近想太多因果关系相关的科研课题了,有点疯魔了。
事情进行到这里并没有什么难度,经典好用的事件驱动模式就是一个理想的并发模式。这个模式将一切任务的诱因归纳为事件的概念,诸如收到了消息,定时器超时,控制面的操作之类的,都被封装成各种事件。应用的实现不再是线性执行的逻辑,而是对每个事件编写对应的处理函数。这些函数的执行顺序是不确定的。调度器会在运行过程中根据实际发生的事件调用对应的事件函数。换句话说,应用并没有预设事件发生的顺序,某种程度上它在同一时刻处理了所有类型的事件,就像我们所需要的那样。
事件驱动模式足够好用,但是它有两个小问题和一个大问题。第一个小问题是它真的同一时刻处理了所有类型的事件。实践中有两种不同的情况,一种是有些时刻并不是所有类型的事件有可能发生,另一种是有些时刻应用并没有准备好处理一个事件——只有处理完另外一个(些)事件以后才能处理这个。调度器对于应用和事件的特性一无所知,所以对这两种情况都没有任何办法,只能在一个事件发生以后尽可能快地把它丢给应用,让它自己去应付。如果应用收到了一个它不想(现在)处理的事件(即对应的事件处理函数被调用),那么它只能自己把这个事件存下来,等到它想处理这类事件的时候再去查找有没有之前存的事件等着处理。
这样也不是没有好处,好处在于我们得到了一个复杂度最小的调度器和它收集事件的相关设施。一个事件一旦来到这个系统当中,就会以最快的速度穿过运行时和调度器,进入到应用当中被当场处理或者存下来。相对之下,比如说如果调度器能感知到应用现在不想处理收到网络消息的事件,从而干脆不接收网络消息的话,那么我们就能得到一个最简单的应用——只有有用的事件才会出现在这个应用当中,其他的事件可能分散在世界各地,甚至可能在不知道什么时候就不见了。如果我们对自己身处的运行环境很不信任的话,那还是用最简单可靠的方式跟它交互比较好。代价就是我们的应用当中充斥着各种各样存下来的事件,变得模糊不清。
第二个小问题是,根据发生了/过什么事件来组织应用代码的方式也许并不是最合理的。比方说上面提到的收到了不想处理的事件,就会带来下面这种结构的代码
处理事件A
如果现在能处理事件A
处理事件A
否则
存下来
处理事件B(以及其他遥远的毫无关联的地方)
进行一些更新操作
如果现在能处理事件A
把存下来的事件A都拿出来处理了
这本质上是希望发生什么事件和实际上发生了什么事件之间的不匹配。虽然并发应用必须有能力同时应付各种事件,但这不代表它就没有希望什么事件发生的倾向。比如说一个接受用户请求和打印机交互的应用,当它收到一个用户请求事件并转发给打印机以后,虽然我们要求它此时必须既能处理打印机的回复事件也能处理其他的用户请求,但是它一定是倾向于收到一个打印机回复事件的,收到别的请求事件也只能先存下来,毕竟打印机一次只能打印一张纸。最终的后果就是关于事件A的代码分散得到处都是。我们能做的最好的也就是把实际处理事件A的逻辑抽取到一个函数里(而不是就地写在现成的处理事件A的函数里),然后在各种各样的地方调用这个函数,并且不可避免地落下一些该调用它的地方然后痛苦调试。
一个理想的代码组织可能是
无限循环
收到一个用户请求
如果打印机坏了
告诉用户打印机坏了,并回到开头
转发请求给打印机
并发地处理
打印机的回复
告诉用户打印完了
超时
告诉用户打印机坏了,并记下来
在无限循环的前半段,应用只想处理用户请求事件,而打印机回复事件此时不可能发生(因为我们没转发请求给它)。而在循环的后半段,应用只想处理打印机回复事件或者超时事件。这个循环本质上是一个在这两个状态之间来回跳跃的状态机,而把它写成三个分别的事件处理函数势必要引入一个状态变量“此时我们是不是在等打印机的回复/我们是不是在循环的后半段”,本质上只是对这个状态机的一种显式地模拟。
当然,归根结底这都只是一些对于代码变得不够直观的抱怨,只要精心编写倒也没什么严重的后果。那么大的问题就不是这样了:如果我们在处理一个事件的过程中想要处理另外的事件了该怎么办。
在此之前,我们一直假设应用在同一时刻只做一件事(只是做各个事的顺序要根据事件的顺序决定)。如果在应用处理完一个事件之前另外一个事件就到达了,那么它会在调度器当中排队。于是我们最终会得到一个单线程运行的应用。虽然节点多半会有几十个处理器核心,但是应用也只会用到其中的一个,因为它同一时刻只做一件事。调度器一次只调用一个事件处理函数是有理由的。这些处理函数会更新一些共享的状态(比如上面的“是不是在循环后半段”),如果多个处理函数在同一时刻更新就会导致未定义行为。
有些时候一个核心真的不够用。给一条网络消息做数字签名/验证数字签名大概需要40微秒,一秒钟最多只能发出/接收25K条消息。对于很多应用这个数量足够了,可惜我的第一篇工作恰恰要求把这个数字提高到70K-80K。虽然对于它可以用一些跳过验证之类的歪门邪道,但是这个需求是真是存在的:编写能有效利用多个处理器的可并行并发应用。事件驱动模型只能并发而不能并行,因为调度器不能在同一时刻调用多个事件处理函数,这是违反并发安全的。
我们需要另外一种组织代码的单元,不同于事件处理函数的组织单元。对这种单元的执行不需要严格遵循同一时刻只能一个的限制。我姑且将这种单元称为会话。
考虑一个用于处理收到消息事件的会话。因为这个会话可以同时执行多个,所以调度器可以在每收到一条消息时启动一个新的会话,而不用等前一条消息花40微秒验证完签名。当然,如果节点真的只有一个处理器,那么新的会话还是只能等前一个会话执行完才能开始执行,但重点是并行语义可以用不上,但不能没有。
一个潜在的并行替代方案是把并发原语添加到事件驱动模型中,即“从这往下的部分可以一边做一边执行别的事件处理函数”,一个理想的场景是签名并发送一条消息,因为应用并不关心发送的结果如何:
处理询问消息
查询询问的问题答案
创建答复消息
并发地去做
给答复消息签名
发送答复消息
那么调度器就可以在一个处理器上给答复消息签名,并在另一个处理器上处理下一条询问消息了。
可惜很多时候应用是要关心并发执行的代码的结果的,或者它执行完成的时机。比如很多应用希望把签名的答复消息留档一份,还有验证收到的消息
处理询问消息
并发地去做
验证询问消息的签名
不并发地/顺序地去做
如果验证成功
查询询问的问题答案
创建答复消息
并发地去做
给答复消息签名
不并发地去做
留档答复消息
并发地去做
发送答复消息
一个可能的方案是在每个并发步骤结束以后产生一个对应的事件,然后把顺序执行的步骤打散到各个事件的处理函数当中去
处理询问消息
并发地去做
验证询问消息的签名
处理签名验证完毕
如果验证成功
查询询问的问题答案
创建答复消息
并发地去做
给答复消息签名
处理签名完毕
留档答复消息
并发地去做
发送答复消息
从功能上来讲这是一个足够完备的方案,但它很繁琐。更要命的是,就像在小问题中提到过的,事件驱动模型存在着实际发生的事件和预期发生的事件的不匹配。并发过程引入的数量众多的额外事件让这个问题更严重了:先收到的消息可能会后完成验证,而某些情况下又需要额外的缓存来把完成验证的消息重新进行排序,诸如此类。
TODO 补完会话模型的特性:会话之间不共享状态,通过信道传消息。使用上的缺陷。