Promise 不能被取消,真的算是它的缺点吗?

很多文章都说 Promise 的缺点之一是:一旦开始,就不能被取消。尽管事实是这样,但这真的算是“缺点”吗?谈一谈的你看法,谢谢!
关注者
319
被浏览
238,875

55 个回答

不是。TC39 曾经有人提案过取消 Promise 的 API,但最后讨论一番后作者撤销了提案。以下是 TC39 GitHub 上的提案:

已经有人写过长文为什么 Promise 不应该有可取消的 API:

根据 TC 历史讲故事的工作交给 @贺师俊 吧,我直接说一下为什么 Promise 不能被取消吧。


首先,Promise 被设计为 eager,也就是一旦启动就立即开始执行,尽快获得执行结果,而不是等到你需要结果时才开始执行。这种设计使得 Promise 的取消往往毫无意义。

这种 eager 执行的方式,就如同你下单后立即现金结算然后发货一样,钱你肯定是要不回来的,货物送到后你也不可能要求送回仓库去,你拒收就相当于把货物在原地扔掉。

类似地,Promise 一旦开始执行,底层的软硬件资源开销就已经产生了,没有任何办法撤回。举个具体的例子,你发了一个网络请求,无论如何这个网络请求都会发完,无论如何服务器返回的数据都会收完,底层不提供任何接口给你中断和服务器的连接。

所以,如果你取消 fetch 返回的 Promise 实际上什么都撤销不了。上层调用 fetch 的复杂异步操作,给你一个可取消的 Promise,实际上也是无法撤销底层的 fetch。如果复杂操作涉及多次 fetch,让你取消实际上撤销了哪个 fetch,哪个 fetch 已经撤销不了,也无法明示。

如果取消 Promise 的语义如此不明确,那还是别给你取消的 API 好了。


要有明确的可撤销语义,很快你发现你重新发明了 Observable,那你为什么不直接用 Observable 呢?

回到上面的例子,你有一个复杂的异步操作,需要多阶段调用 fetch,封装成 Observable 就可以一步一步往前走,你不让它往前走时,剩余步骤的开销就不会发生。所谓的取消,就是不再驱动它往前走,这一个变量就能控制。


在 Promise 广泛应用之后,再引入取消的 API 就会破坏兼容性。一个 Promise 启动后,原本 await 只预期 resolve 或 reject 两种终极状态。现在多一个 cancel 的终极状态,原来的代码处理不了,怎么办?

你会说你不取消 Promise 就能保证兼容,但你能保证你写的 Promise 内部嵌套的别人的 Promise 呢?例如一个第三方库,支持取消当前所有网络请求的 Promise,你怎么办?


如果你真的需要一个可以取消的 Promise,你用两个正常的 Promise 就能捏出来一个。第一个 Promise 是正常使用的 Promise,代表你要执行的异步操作状态。第二个 Promise 代表第一个 Promise 是否被取消过,取消时它就 resolve,但一旦第一个 Promise 完成了它就被自动 reject 掉。这时候你就手工捏出来了一个有三个终极状态的 Promise。

其实概念上有(不限于)三种东西可以被称作Awaitable,一种是“任务”,Task,正在执行的任务,可以是local的也可以是remote的,await的语义是等待结束,还有一种是惰性值,Lazy,await的语义是求值,虽然它更合适的叫法是eval,还有一种是“预期值”,Future,它只是在说“我们在将来会奇迹般地得到一个值”,而await的语义是“等待那个值的降临”。

对于这三类Awaitable,取消的语义是不同的。

Task的取消意味着立即结束并销毁任务,远程执行者将收到通知,仍在进行的等待将立即结束,因为取消任务意味着任务的停止。

Lazy的取消有两种语义:使惰性值无效,它将无法再次被求值,任何正在进行的求值都将立即结束,任何将来的求值都将无法进行;

或者,结束目前进行的求值,然而惰性值依然有效。

Future的取消有两种语义:结束目前的等待,但预期值依然有效;

或者,宣称该预期值无效,任何目前和将来对该值的等待将永不结束。

Lazy和Future对于是否抛出异常的偏好是不同的,因为“戈多不会出现”是合法的,但是一个惰性值会被认为能在有限时间内求值。不过立即结束和永不结束,对于等待来说是相同的。

总的来说,这两者都需要面临两种取消:打断等待或者销毁值。所以不能只提供一种取消语义。


可能这的确有些反直觉,但是forever pending promise是不会造成内存泄漏的:

const godot = () => new Promise(() => {})

setInterval(async () => {...; await godot(); ...}, 0)

就这么两行,setInterval里面的那个函数里可以搞很多事情,这样并不会造成泄露。



Promise是non-lazy的,它实际上是Future,它没有表示Task的能力,async函数对应Task,但是它只会给出表示它返回值的Future,而不会给出表示整个处理过程的Task,相比之下generator才是“Task”,虽然generator和async-await都具有实现协作式多任务的能力,但是我们会发现generator对调度细节有更多的控制。(所以async-await更好用,因为锅直接甩给event loop了)。

PromiseLike/Thenable是Awaitable,他可以是任何东西。


我们多数情况下需要取消语义是为了通过减少无用计算来节约计算资源,所以通常来说这并不是一个太大的问题,它只会白白消耗计算资源,并不会造成错误。而且如果你需要的话,你也可以比较容易地在提供这个Promise的地方实现。


不过我们有时候还需要另一种和取消有关但并不是取消的语义,回滚。回滚语义是关于“操作”的,一个Task可以是一个操作,但是Lazy和Future并不是操作因此不适用回滚语义。另外回滚也并不是那么容易提供的。


总结一下,

Thenable/Awaitable/PromiseLike只提供then,但是只有Awaitable是不够的。常见的三种Awaitable语义,Task,Lazy和Future,它们对于取消的理解并不相同。

JavaScript的Promise接近于Future语义。

Async函数会产生Task,但是JavaScript并没有提供对这个Task的控制。相比之下,Generator更接近Task语义。

可以用Awaitable接口自行实现Task语义,但是因为JavaScript没有提供细粒度控制async函数执行的能力,所以如果有需要可以使用generator function。

Operation不是Awaitable,Operation的取消/回滚与Awaitable的取消不同。Task同时是Operation和Awaitable,Task的取消会同时涉及两种语义。

Lazy和Future不是Operation,Lazy和Future的取消只是关于“无用计算”的。