异步编程

1.1K
0
0
最后修改于

何为同步#

对于一个任务T,有四个部分T(p1, p2, p3, p4),由执行者 A 进行推进。这是一种典型的同步推进。

何为异步#

而异步作为一个经典的概念,放在不同语境下,也有不同的含义,而且经常与并发、多线程、协程一起提起,容易造成混淆。

在我的认识中,异步描述的是一种结构关系。多线程、协程,是实现异步关系的常用手段。

其通常结构如下:

对于一个任务T,有四个部分T(p1, p2, p3, p4),由执行者 A 进行推进。这是一种典型的同步推进。

但其实 p2 是一段相对独立的子任务, p2,p3 并没有依赖关系。

从执行者 A 的视角看来,自己的执行时间可能会被 p2 严重拉长。因此,A 通过一种方式,将这部分工作委托给执行者 B,让这部分工作由 B 进行推进。而 A 则可以继续推进 p3

对于 A 而言,A 发起 p2 委托给 B 后,p2 的完成不再是 A 继续推进后续部分的前置条件。这种关系,即是异步的。

放在整体视角上看,在一个单核 CPU 下, A、B 可能都是同一个 Thread 交替切换推进,但这并不妨碍 A 的视角中,这里形成的异步关系。

异步编程#

异步关系与异步编程并非等同。我们为什么需要异步呢?我们如何用代码描述和组织这种关系?

编程语言的控制流是线性的、顺序的。对于一个任务而言,要表达异步关系,具有一定困难。不过 OS 早已提供类似的 fork / join 机制。多线程本身就是一种异步关系。将一部分工作分出去执行,之后在某个汇合点等待结果。但如果直接使用进程或系统线程承载大量细粒度异步任务,资源成本会很高。

我们更希望用更低成本的机制来表达这种关系,以免为每一个异步子任务都创建系统级别的进程 / 线程。

为了实现这样的低成本异步,一种早期方案是 callback。也就是将 “异步操作完成后要继续执行的部分(依赖异步操作结果)” 作为函数传入:

p1()
p2((result) => {
  p4(result)
})
p3()

这导致逻辑任务结构p1,p2,p3,p4,与实际的代码结构 p1,p2(p4),p3 出现了偏差。
在一些极端场景,会导致严重的 callback hell。

p1((r1) => {  
    p2(r1, (r2) => {
        p3(r1, r2, (r3) => {
	        p4(r1,r2,r3)
        })
    });
});

代码可读性与可维护性大大降低。逻辑跳转非常的不直观。

为了解决 CallbackHell 这种问题。开始引入一类对象,称为 Future/Promise,表示一个未完成的结果。可以对这个对象进行传递,组合,等待。可以通过链式结构来体现这种关系。

p1
.then((r1) => p2(r1))
.then((r2) => p3(r2))
.then((r3) => p4(r3))

相比于前一个 Callback 有了不少的改进。减少了嵌套层级,但这种的链式调用仍然与逻辑执行流相左,在上下文传递、分支、错误处理时仍然会具有一定问题。

为了解决代码组织与逻辑控制流相左的问题。用接近同步代码的直接风格,表达异步任务的发起、等待和恢复。

一种思路是主动 wait

function T() {
	p1()
	const h2 = p2()
	p3()
	const r2 = h2.wait()
	p4(r2)
}

另外一些编程语言则开始引入asyncawaityieldsuspend这样的关键字。将其做成语言特性。通过一些编译期魔法。实现逻辑控制流与代码组织的对齐。

async function T() {
  p1()
  const r2p = p2()
  p3()
  p4(await r2p)
}

在语义上,wait()await 都表示一个 join 点,在任务 T 看来,当前任务需要某个未来结果,因此在这里暂停,等待结果完成后继续。

这里有一个重要转变,任务 T 在语义上是同步的,到达 join 点后,T 被挂起了,异步关系对于 T 而言不再明显,T 已经表现出同步的形式。

但是对实际执行 T 的执行线程而言,遇到 T 挂起之后,执行线程不会等待 T 挂起完成,而是转而去执行其他任务,执行线程与 T 则表现出异步关系。

于是就形成了所谓的 “用同步方式写异步代码”。