Future与promise

异步处理

计算机科学中,futurepromisedelaydeferred,是在某些并发编程语言中,指称用于同步程序执行英语Execution (computing)的一种构造。由于某些计算尚未结束,故而需要一个对象来代理这个未知的结果。这种构造起源于函数式编程和相关范型逻辑编程,其目的是将值与其运算过程解耦,从而允许更灵活地进行计算,特别是通过将其并行化来进行。后来它在分布式计算中得到了应用,用来减少网络通信往返的延迟。再后来async/await语法机制使其变得更有用,籍此允许以直接风格编写异步程序,而不再采用续体传递风格

术语

在1976年Daniel P. Friedman和David Wise提出了术语“promise”[1],同年Peter Hibbard称之为“eventual”[2]。1977年Henry Baker英语Henry Baker (computer scientist)Carl Hewitt英语Carl Hewitt在一篇论文中介绍了一个类似的概念“future”[3]

术语“future”、“promise”、“delay”和“deferred”通常用来称谓同样一种机制,在特定实现中可能只选用其中之一。在这种情况下,值与其运算过程是一起创建并且相互关联的:future若指称推迟设置的一个值[4],设置它的函数就可称谓为promise;promise若指称推迟设置的一个值[5],设置它的函数就可称谓为resolver;promise若指称一个异步函数[1],它的结果值所设置的就可称谓为future。设置一个推迟值也称为“决定/解决”(resolve)、“履行/实现”(fulfil)或“绑定”(bind)它。

推迟值及其设置者也存在区分称谓而同时使用的情况,二者的这种用法在不同实现中的差异将在专门章节中讨论。具体来说,future指称一个“只读”的变量的占位符视图,而promise指称一个可写的单赋值容器,使用其“成功”方法来设置这个future的值[6]。尤其是在定义future之时,无须指定设置其值的promise;并且可能有不同的promise,设置同一个future的值;但是对于一个给定future,仅可以进行一次设置。

历史

future及/或promise构造,首先实现于编程语言例如MultiLisp英语MultiLisp[4]Act 1之中。在并发逻辑编程英语Concurrent logic programming语言中,使用非常类似于future的逻辑变量进行通信[7]。这种语言开始于1982年的“Prolog with Freeze”和“IC Prolog”,并且在后来的语言中变成了真正的并发原语,比如Concurrent PrologParlog英语Parlog守卫霍恩子句(GHC)、Strand英语Strand (programming language)Janus英语Janus (concurrent constraint programming language)Oz(Mozart)和Alice ML英语Alice (programming language)[8][9]。叫做“I-变量”的单赋值变量,非常类似于并发逻辑变量,它起源于数据流程编程语言Id英语Id (programming language)的“I-结构”元件[10],并且包含在Reppy的Concurrent ML[11]

在1988年Barbara Liskov和Liuba Shrira,发明了promise流水线技术来克服传输延迟[5];Liskov和Shrira在论文中使用了术语“promise”,但是他们采用了现在少见的名称“call-stream”来提及流水线机制。在大约1989年Mark S. Miller英语Mark S. Miller、Dean Tribble和Rob Jellinghaus,于Xanadu项目中也独立发明了此技术[12]

Liskov和Shrira的论文中描述的设计,以及Xanadu中的promise流水线的实现,都有一个限制,即promise值不是头等的:call或send的参数或返回值,不能直接是promise。在Liskov和Shrira论文中使用的编程语言Argus,直至大约1988年停止开发,似乎都未曾在任何公开发布中实现promise和call-stream[13][14]。Xanadu实现的promise流水线,仅在1999年Udanax Gold的源代码发布时才公开发布[15],并且在任何已发布的文档中都没有解释过[16]

一些早期的演员语言,包括Act系列[17][18],支持并行消息传递和流水线式消息处理,但不支持promise流水线。Joule英语Joule (programming language)E语言的后续实现,支持完全头等的promise和resolver,还有promise流水线。

2000年之后,由于消息模式英语Messaging pattern请求-响应模型,在用户界面响应力英语ResponsivenessWeb开发中的应用,future和promise重新引起了人们的兴趣。现在一些主流语言对future和promise有了语言支持,最著名的是2004年发行的Java 5中的FutureTask[19],以及2012年发行的.NET框架 4.5中的async/await结构[20][21],它在很大程度上受到可追溯到2007年的“F#异步编程模型”的启发[22][23]async/await随后被其他语言采用,特别是2014年的Dart 1.9[24]、2014年发行的HackHHVM)、2015年的Python 3.5[25]ECMAScript 2017、2019年的Rust 1.39和C++20等。

实现列表

编程语言支持

下面列出支持future、promise和并发逻辑变量、数据流程变量或I-变量的语言,包括直接在语言中支持还有在标准库中支持:

还支持promise流水线的语言包括:

非标准库的实现

自行实现

future可以用协程生成器实现[25],从而具有相同的求值策略(例如协同多任务或延迟求值)。

future还可以很容易地用通道实现:future是一个单元素的通道,而promise是一个发送到通道,实现future的过程。这允许future在支持通道(如CSPGo)的并发编程语言中实现[103]。由此产生的future是显式的,因为它们必须通过从通道读取而不是仅仅通过求值来获取。

编程语言典型示例

Python

Pythonconcurrent.futures模块,提供了异步英语Asynchrony (computer programming)执行英语Execution (computing)可调用对象的高层接口。异步执行可以使用线程池执行器ThreadPoolExecutor,通过多个线程来进行;或使用进程英语Pool (computer science)执行器ProcessPoolExecutor,通过分立的多个进程来进行。concurrent.futures.Future封装了可调用对象的异步执行,其实例由执行器抽象类Executor的这两个子类的submit()方法创建[41]

在下面的例子中,定义了load_url(),用来检索一个URL所指定的单一网页并报告其内容。使用with语句指定采用线程池执行器,这确保了线程由它及时清理。用这个执行器的submit()方法启动所有的装载操作任务,并使用字典推导式为其创建的每个future标记上对应的URL。采用模块函数as_completed(),在指定的Future类的诸实例“已齐全”(completed)之时,建立在其上的迭代器

import concurrent.futuresimport urllib.requestURLS = [    'https://www.python.org/',    'https://pypi.org/search/',    'https://no-such-url'    ]def load_url(url, timeout):    with urllib.request.urlopen(url, timeout=timeout) as conn:        return conn.read()with concurrent.futures.ThreadPoolExecutor() as executor:    future_to_url = {executor.submit(load_url, url, 60) : url for url in URLS}    for future in concurrent.futures.as_completed(future_to_url):        url = future_to_url[future]        try:            data = future.result()        except Exception as exc:            print(f'{url!r} generated an exception: {exc!s}')        else:            print(f'{url!r} page is {len(data):d} bytes')

CPython中,由于全局解释器锁(GIL),保证一时只有一个线程可以执行Python字节码;一些扩展模块被设计为在进行计算密集任务时释放GIL,还有在进行I/O时总是释放GIL。线程池执行器的max_workers参数的缺省值是min(32, os.cpu_count() + 4),这个缺省值为I/O踊跃英语I/O bound任务保留至少5个worker线程,为释放GIL的CPU踊跃任务利用最多32个CPU逻辑核心。想要更好利用多核心机器的计算资源,可以使用进程池执行器或多进程模块multiprocessing。对于同时运行多个I/O踊跃英语I/O bound任务,仍然适合采用线程池执行器或线程模块threading

Python的异步I/O模块asyncio,是采用async/await语法编写并发代码的库[104]asyncio模块基于低层事件循环,提供的高层API包括了:并发运行Python协程并拥有在其执行上的完全控制,进行网络IOIPC,控制子进程,通过队列分布任务英语Task (computing)同步并发代码。asyncio模块的同步原语,在设计上类似于threading模块的同步原语,但与之相比有两个重要差异:asyncio模块的同步原语不是线程安全的,故而不能用于OS线程同步;这些同步原语的方法不接受超时英语Timeout (computing)实际参数。

asyncio模块提供的低层API中有asyncio.Future对象,用来桥接低层基于回调的代码和高层采用async/await语法的代码,它在设计上模仿了concurrent.futures.Future对象。这两个模块的Future对象的set_result()方法标记这个Future对象为“已完毕”(done)并设置它的结果,而set_exception()方法标记这个Future对象为已完毕并设置一个例外;done()方法在这个Future对象已完毕时返回Trueresult()方法返回这个Future对象所设置的结果,或者引发其所设置的例外

二者的关键差异包括了:asyncio.Future是可期待(awaitable)对象,而concurrent.futures.Future对象不可以被期待(awaited)。asyncio.Future.result()不接受超时实际参数,如果此刻这个Future对象的结果仍不可获得,它引发一个InvalidStateError例外;而concurrent.futures.Future.result(),可接受超时英语Timeout (computing)timeout)实际参数,如果此刻结果仍未“完全”(completed),它等待指定时间后若仍未完全则引发TimeoutError例外,如果超时未指定或指定为None,则对等待时间没有限制。

JavaScript

JavaScript中,Promise对象表示一个异步英语Asynchrony (computer programming)运算的最终完成或失败,及其结果值或失败理由。Promise对象是对一个值的代理(proxy),这个值在创建这个promise之时不必需已知。promise允许为异步行动的最终成功值或失败理由关联上处理器,这使得异步方法像同步方法那样返回值:并非立即返回最终值,异步方法返回一个promise来在将来的某一点上提供这个值。

在JavaScript生态系统中,Promise对象在成为语言本身的一部份之前很久就有了多种实现[105]。尽管各有不同的内部表示,至少几乎所有类似Promise的对象都实现了“可接续”(thenable)接口[59]。可接续对象实现了.then()方法,Promise对象就是可接续的。

一个promise被称为已落实(settled),如果它要么已履行(fulfilled)要么已拒绝(rejected),而不再待定(pending)。Promise实例的.then()方法,接受一到二个实际参数,它们是作为处理器异步执行的回调函数,分别针对了这个promise的已履行和已拒绝情况;此方法返回一个新的promise,它决定(resolve)出其所调用处理器的返回值,或者在原来这个promise未被处理的情况下,仍决定出其已落实的值。Promise实例的.catch()方法,实际上就是置空针对已履行情况的回调函数,而只有针对已拒绝情况的回调函数的.then()方法。

Promise()构造子创建Promise对象,它主要用于包装仍基于回调的API所提供的异步英语Asynchrony (computer programming)运算,要注意它只能通过new算子来构造:new Promise(executor)Promise()构造子的实际参数叫做“执行器”(executor),它是由这个构造子同步执行的一个函数,它有可自行指定名字的两个函数形式参数,比如指定为resolveFuncrejectFunc,这两个函数接受任何类型的单一实际参数。

Promise()构造子返回的Promise对象,在要么resolveFunc函数要么rejectFunc函数被调用之时,它就成为“已决定”(resolved)的。要注意如果在调用这两个函数之一的时候,将另一个Promise对象作为实际参数传递给它,则这个Promise对象可以称为已决定但仍未落实。在执行器中有任何错误抛出,都会导致这个promise成为已拒绝状态,而返回值会被忽略。

Promise类提供四个静态方法来实施异步任务英语Task (computing)并发,其中的Promise.allSettled()方法,接受有多个promise的可迭代对象作为输入,当所有输入的promise都已落实之时,返回一个单一的已履行的Promise对象,它决定出描述每个输入promise的结果的一个对象数组;其中每个对象都有status属性,它是字符串"fulfilled"或者"rejected";还有在status"fulfilled"时出现的属性value,或者在status"rejected"时出现的属性reason

在下面例子中,全局fetch()方法,启动从网络上取回一个资源的过程,并返回一旦响应可获得就履行的一个promise;这个promise决定出表示对这个请求响应的一个Response对象,它只在遇到网络错误之时拒绝。Response.text()方法返回一个promise,它决定出这个响应的主体的一个文本表示。

const urls = [  'https://developer.mozilla.org/',  'https://javascript.info/promise-api',  'https://no-such-url'];const fetchPromises = urls.map((url) => fetch(url));Promise.allSettled(fetchPromises)  .then((results) => {     results.forEach((result, num) => {      if (result.status == "fulfilled") {        if (result.value.ok) {           result.value.text()            .then((data) => {              console.log(`${urls[num]}: ${data.length} bytes`);            })            .catch((err) => {              console.error(err);            });        }         else {          console.log(`${urls[num]}: ${result.value.status}`);        }      }      else if (result.status == "rejected") {        console.error(`${urls[num]}: ${result.reason}`);      }    });  });

JavaScript是天然的单线程的,所以在给定时刻只有一个任务英语Task (computing)执行英语Execution (computing),但控制可以在不同的promise之间转移,使得多个promise表现为并发执行。在JavaScript中并行执行只能通过分立的worker后台线程来完成[106]

同基于promise的代码合作的一种更简单的方式,是使用async/await关键字。在函数开始处增加async,使其成为异步函数,异步英语Asynchrony (computer programming)函数总是返回一个promise。在异步函数中,可以在对返回一个promise的函数的调用之前,使用await关键字;这使得代码在这一点上等待直到这个promise已落实下来,然后在这一点上promise的已履行的值被当作返回值,而已拒绝的理由被作为例外抛出。可以使用try...catch块进行例外处理,就如同这个代码是同步的一样。

只读视图

在某些编程语言(如OzEAmbientTalk英语AmbientTalk)中,可以获得推迟值的“只读视图”,允许在解决(resolve)出这个值之后通过它来读取,但不允许通过它来解决这个值:

  • 在Oz语言中,!!运算符用于获得只读视图。
  • 在E语言和AmbientTalk中,推迟值由一对称为“promise/resolver对”的值表示。promise表示只读视图,需要resolver来设置推迟值。
  • 在C++11中,std::future提供了一个只读视图。该值通过使用std::promise直接设置,或使用std::packaged_taskstd::async设置为函数调用的结果。
  • 在Dojo Toolkit的1.5版本的Deferred API中,“仅限consumer的promise对象”表示只读视图。[107]
  • Alice ML英语Alice (programming language)中,future提供“只读视图”[27],而promise包含future和解决future的能力[108]
  • 在.NET 4.0中,System.Threading.Tasks.Task<T>表示只读视图。解决值可以通过System.Threading.Tasks.TaskCompletionSource<T>来完成。

对只读视图的支持符合最小特权原则,因为它使得设置值的能力仅限于需要设置该值的主体。在同样支持流水线的系统中,异步消息(具有结果)的发送方接收结果的只读promise,消息的目标接收resolver。

有关结构

“future”是事件同步原语的特例,它只能完成一次。通常,事件可以重置为初始的空状态,因此可以根据需要多次完成。[109]

“I-var”是具有下面定义的阻塞语义的future。它起源于Id语言中包含I-var的“I-structure”数据结构。可以使用不同值多次设置的有关同步构造称为“M-var”。M-var支持take(采取)或put(放置)当前值的原子性操作,这里采取这个值还将M-var设置回其初始的“空”状态。[110]

“并发逻辑变量”与future类似,但是通过合一更新,与逻辑编程中的“逻辑变量”相同。因此,它可以多次绑定到可合一的值,但不能设置回到空或未解决状态。Oz的数据流变量充当并发逻辑变量,并且还具有上面提到的阻塞语义。

“并发约束变量”是并发逻辑变量的一般化,以支持约束逻辑编程:约束可以多次“缩小”,表示可能值的较小集合。通常,有一种方法可以指定每当约束进一步缩小时应该运行的thunk英语thunk;这是支持“约束传播”所必需的。

隐式与显式future

对future的使用可以是“隐式”的,任何对future的使用都会自动获得它的值,它就像是普通的引用一样;也可以是“显式”的,用户必须调用函数来获取值,例如Java中的Future[32]或CompletableFuture[33]get方法。获得一个显式的future的值可以称为“刺激”(stinging)或“强迫”(forcing)。显式future可以作为库来实现,而隐式future则通常作为语言的一部分来实现。

最初的Baker和Hewitt论文描述了隐式future,它们在演员模型和纯面向对象编程语言(如Smalltalk)中自然得到支持。Friedman和Wise的论文只描述了显式的future,可能反映了在老旧硬件上有效实施隐式future的困难。难点在于老旧硬件不能处理原始数据类型(如整数)的future。例如,add指令不会处理3 + future factorial(100000) 。在纯演员模型或面向对象语言中,这个问题可以通过向future factorial(100000)发送消息+[3]来解决,它要求future自己加3并返回结果。请注意,无论factorial(100000)何时完成计算,消息传递方法都可以工作,而且不需要任何“刺激”或“强迫”。

阻塞与非阻塞语义

如果future的值是异步访问的,例如通过向它发送消息,或者通过使用类似于E语言中的when的构造显式地等待它,那么在消息可以被接收或等待完成之前,推迟直到future得到解决(resolve)是没有任何困难的。这是在纯异步系统(如纯演员语言)中唯一需要考虑的情况。

然而,在某些系统中,还可能尝试“立即”或“同步”访问future的值。这样的话就需要做出一个设计选择:

  • 访问可能会阻塞当前线程或进程,直到future得到解决(可以具有超时)。这是Oz语言中“数据流变量”的语义。
  • 尝试的同步访问总是会引发信号指示错误,例如抛出异常。这是E语言中远程promise的语义。[111]
  • 潜在的,如果future已经解决,则访问可能成功,但如果未解决,则发出信号指示错误。这样做的缺点是引入了不确定性和潜在的竞争条件,这似乎是一种不常见的设计选择。

作为第一种可能性的示例,在C++11中 ,需要future值的线程可以通过调用wait()get()成员函数来阻塞,直到它可获得为止。还可以使用wait_for()wait_until()成员函数指定等待超时,以避免无限期阻塞。如果future对std::async的调用,那么阻塞等待(没有超时)可能导致函数的同步调用以计算等待线程上的结果。

promise流水线

分布式系统中使用推迟值可以显著地减少传输延迟。例如,指称推迟值的promise,成就了“promise流水线”[112][113],就像在E语言和Joule英语Joule (programming language)语言中实现的那样,它在Argus语言中称为“call-stream”[5]

考虑一个涉及常规远程过程调用的表达式,例如:

 t3 := (x.a()).c(y.b())

可以展开为

 t1 := x.a(); t2 := y.b(); t3 := t1.c(t2);

每个语句需要发送一条消息,并在下一个语句可以继续之前收到一个答复。例如,假设xyt1t2都位于同一台远程机器上。在这种情况下,在开始执行第三条语句之前,必须对该机器进行两次完整的网络往返。然后,第三条语句将引起另一个到同一个远程机器的往返。

上面的表达式可以使用E语言的语法写为:

 t3 := (x <- a()) <- c(y <- b())

其中x <- a()表示将消息a()异步发送给x。它可以展开为:

 t1 := x <- a(); t2 := y <- b(); t3 := t1 <- c(t2);

所有三个变量都会立即为其结果分配promise,执行过程将继续进行到后面的语句。之后尝试解决t3的值可能会导致传输延迟;但是,流水线操作可以减少所需的往返次数。如果与前面的示例一样,xyt1t2都位于相同的远程机器上,则流水线实现可以用一次往返来计算t3,不必用三次。由于所有三条消息都指向同一远程计算机上的对象,因此只需要发送一个请求,只需要接收一个包含结果的响应。另请注意,即使t1t2位于不同机器上,或者位于与xy不同的机器上,发送t1 <- c(t2)也不会阻塞。

promise流水线应与并行异步消息传递区分开来。在支持并行消息传递但不支持流水线操作的系统中,上面示例中的消息发送x <- a()y <- b()可以并行进行,但发送t1 <- c(t2)将不得不等到t1t2都被接收,即使xyt1t2在同一个远程机器上。在涉及许多消息的更复杂情况下,流水线的相对传输延迟优势变得更大。

promise流水线操作也不应与演员系统中的流水线式消息处理相混淆,在这种系统中,演员可以在完成当前消息的处理之前,指定并开始执行下一个消息的行为。

有特定线程的future

某些语言比如Alice ML英语Alice (programming language),定义的future可以关联着计算这个future值的特定线程[27]。这种计算可以通过spawn exp,在创建future时及早地开始;或者通过lazy exp,在首次需要其值时懒惰地开始。在延迟计算的意义上,懒惰的future类似于thunk 。

Alice ML还支持可由任何线程解决的future,并称谓它们为“promised future”[108]。这里的promise是给future的显式把柄(handle),它通过多态库函数Promise.promise来创建,所有promise都关联的一个future,创建一个新promise也就创建了一个新鲜的future;这个future通过Promise.future来提取,并且通过显式的应用Promise.fulfill于对应的promise来消除,即在全局中将这个future替代为一个值。Alice ML不支持promise流水线,转而对于future,包括关联着promise的future,流水线是自然而然地发生的。

在没有特定线程的future(如Alice ML所提供的)中,通过在创建这个future的同时创建一个计算这个值的线程,可以直接实现及早求值的有特定线程的future。在这种情况下,最好将只读视图返回给客户,以便仅让新创建的线程能够解决这个future。

要在没有特定线程的future中,实现隐式惰性的有特定线程的future,需要一种机制来确定何时首次需要future的值(例如,Oz中的WaitNeeded构造[114] )。如果所有值都是对象,那么有实现透明转发对象的能力就足够了,因为发送给转发器的首条消息表明需要future的值。

假定系统支持消息传递,在有特定线程的future中,通过让解决线程向future自己的线程发送消息,可以实现没有特定线程的future。然而这可能被视为不必要的复杂性。在基于线程的编程语言中,最具表现力的方法似乎是提供一种混合:没有特定线程的future、只读视图、以及要么有WaitNeeded构造要么支持透明转发。

传future调用

求值策略而言,“传future调用”是非确定性的:future的值将在创建future和使用其值之间的某个时间进行求值,但确切的时间不确定的,一次运行和另一次运行的求值时间会不一样。计算可以在创建future时开始(及早求值),或者仅在实际需要值时开始(懒惰求值),并且可以在中途暂停,或在一次运行中执行。一旦future被赋值,它就不会在访问future的时候重新计算;这就像传需求调用时使用的记忆化

“懒惰”future是确定性的具有惰性求值语义的future:future值的计算在首次需要时开始,与传需要调用一样。懒惰future使用在求值策略默认不是懒惰求值的语言中。例如,在C++11中,可以通过将std::launch::deferred启动策略传递给std::async以及计算值的函数来创建这种惰性future。

演员模型中的future语义

在演员模型中,形式为future <Expression>的表达式,以它对具有环境E和客户C的Eval消息的响应方式来定义:future表达式通过向客户C发送新创建的演员F(计算<Expression>的响应的代理)作为返回值来响应Eval消息,与之并发的向<Expression>发送具有环境E和客户F的Eval消息。F的默认行为如下:

  • 当F收到请求R时,它会检查是否已经收到来自求值<Expression>的响应(可以是返回值或抛出异常),处理过程如下所示:
    1. 如果它已经有了响应V,那么
      • 如果V是返回值,那么向它发送请求R。
      • 如果V是一个异常,那么就把这个异常抛给请求R的客户。
    2. 如果它还没有响应,那么将R存储在F内的请求队列中。
  • 当F接收到来自求值<Expression>的响应V时,那么将V存储在F中,并且
    • 如果V是返回值,那么将所有排队的请求发送到V。
    • 如果V是一个异常,那么就会把这个异常抛出给每个排队请求的客户。

但是,一些future可以通过特殊方式处理请求以提供更大的并行性。例如,表达式1 + future factorial(n)可以创建一个新的future,其行为类似于数字1+factorial(n) 。这个技巧并不总是有效。例如,以下条件表达式:

if m>future factorial(n) then print("bigger") else print("smaller")

挂起,直到factorial(n)这个future已回应询问m是否大于其自身的请求。

参见

引用

外部链接