Async

怎样好的异步编程?

同步异步

首先,我们需要了解什么是异步。

从编程的角度来看,程序是一串执行流,从大的尺度来说,是一个个逻辑流(也可以说是函数),从小的尺度来说,是一条条的指令。对于函数,如果不能在某串逻辑流中固定时机得到调用,那么相对于该逻辑流而言,该函数是异步的。反之,则是同步的。

之所以要强调对逻辑流而言,是想说明同步异步的概念显然是和语境相关的,描述的是多个逻辑流之间的关系。程序是死的,程序员总得告诉计算机下一步该做什么,从全局的角度来看,程序员的程序让计算机干什么计算机就得干什么,因此所有调用都能在这全局语境上找到确定时机,计算机只做确定的事情。

因此,进程间通信中,信号相对于进程是异步的,多线程编程中,线程之间是相互异步的。这些异步逻辑流的交叉细粒度是指令级别的,即逻辑流可能在执行任何一条指令的时候,被打断执行,被插入一段原逻辑流意料之外的异步逻辑流。对于原逻辑流来说,一段异步逻辑流会导致语境变化,原来做的判断可能不再成立;对于异步逻辑流来说,其执行的上下文是不确定的。这些区别都会使我们需要注意或者花费额外的代码,以保证在这样的环境下异步逻辑流能够正确的执行。对于进程信号处理来说,我们需要关心函数的可重入,对于多线程编程来说,我们需要关心函数是否线程安全。资源共享以及潜在的编译重排(甚至是 CPU 级别乱序执行及缓存一致性)都将影响程序的正确性。

对于多进程多线程这种天然多执行流并行的结构来说,它们都强制要求逻辑流必须满足指令级任意交叉的正确性,因为逻辑流的调度是抢占式的,逻辑流没办法得到不被打断的保证。在没有同步机制的情况下,逻辑流之间的执行次序也是完全无法确定的。

怎样并行

在函数式编程中,一个函数的输入是确定的,那么该函数的输出也是确定的。由此,一个函数唯一的作用在于对输入输出做变换,而不会产生任何的副作用。我们可以像数学代数运算一样对函数调用进行处理,将一个元素与其逆元相消。函数式编程天然是对并行友好的,函数之间是相互独立的,程序中函数之间的依赖只体现在输入输出之间的依赖。如果计算过程中,A 和 B 之间没有输入输出之间的依赖,显然任意安排 A、B 之间的计算顺序,都不会对最终结果产生任何影响,它们是次序无关的。

按函数式编程的想法来理解,多执行流之间的依赖越少越好。如果执行流之间完全没有状态依赖,我们需要的只是输入输出,那么无论执行流之间以任何细粒度交叉执行,都不会影响到程序的正确性。当然,这只是一种理想情况,程序不是孤岛,绝对形式化的要求会严重影响效率以及可读性。将一个程序并行化,需要设计一系列的方案来减少执行流之间的时域耦合,比如采取 Golang 中 “不要通过共享内存来通信,而应该通过通信来共享内存” 的哲学来显式最小化耦合。其中有的耦合是不可避免的,多且繁琐的同步意味着执行流可能并不适合并行化。

怎样并发

指令级交叉下的正确性是抢占式调度的原罪,如果像协作式调度一样,程序可以自己决定交叉时机。虽然丧失了天然多处理器执行的能力,但程序变得更加灵活,上层程序知道如何选择恰当的时机让其他逻辑流交叉。在这种恰当的时机下,上层程序对共享状态的处理对其他逻辑流来说可以是原子性的,由此避免了加锁带来的性能下降;上层程序能够有一个上层调度中心,当继续执行下去的条件不满足时,可以向调度中心注册条件监听。调度中心能够掌握逻辑流的执行条件,避免唤醒逻辑流尝试判断执行条件,导致时间浪费。

怎样异步

无论是多线程还是协程,都是一种异步编程。在可能交叉逻辑流的地方,当前逻辑流的语境可能会随着逻辑流的交叉而产生改变,原来所做隐形的约束都可能被打破。为保证逻辑流的正确性,需要尽可能地保证约束不被打破,可能是加锁保证独占,可能是冗余提升隔离性,还可能是反过来检查约束是否满足做出对应的处理。

想要尽可能使用异步提升效率,对于时域耦合严重的逻辑流,使用协作式的并发较好,而对于独立性相对较高的逻辑流,可以将其放到不同线程上并行,最大利用多核优势。当然这一切只是纸上谈兵而已,具体做法还是得根据开销及场景来权衡。

作者

Sinksky

发布于

2022-05-04

更新于

2023-12-01

许可协议