【问题标题】:Is there a way to short circuit async/await flow?有没有办法让异步/等待流程短路?
【发布时间】:2023-07-26 16:35:01
【问题描述】:

下面在update 中调用的所有四个函数都返回承诺。

async function update() {
   var urls = await getCdnUrls();
   var metadata = await fetchMetaData(urls);
   var content = await fetchContent(metadata);
   await render(content);
   return;
}

如果我们想在任何给定时间从外部中止序列怎么办?

例如,当fetchMetaData 正在执行时,我们意识到我们不再需要渲染组件并且我们想要取消剩余的操作(fetchContentrender)。有没有办法从update 函数之外中止/取消这些操作?

我们可以在每个 await 之后检查一个条件,但这似乎是一个不优雅的解决方案,即使这样我们也必须等待当前操作完成。

【问题讨论】:

  • 和我们在“正常”代码中所做的一样:要么返回一个表明某些东西不可用的值(例如null),要么抛出一个异常。在这两种情况下,调用者都必须决定如何处理它们。
  • 我希望调用者明确中止流程。怎么做呢
  • fetchMetaData 返回null 并执行if (!metadata) { return; }/
  • 重点是 var updateComponent = update(); // 几秒钟后,用户点击离开,我们必须拆除这个渲染。那么我们如何才能短路更新内部的流程。请注意显式中止操作与从函数返回的内部操作之间的区别
  • 我建议你看看这个项目:github.com/Mitranim/posterus

标签: javascript promise async-await cancellation ecmascript-next


【解决方案1】:

我刚刚讨论过这个问题 - 这是一个很好的话题,但遗憾的是你不会真正喜欢我要提出的解决方案,因为它们是网关解决方案。

规范为您做什么

取消“恰到好处”实际上非常困难。人们已经为此工作了一段时间,并决定不阻止异步功能。

在 ECMAScript 核心中有两个提议试图解决这个问题:

这两个提案在上周发生了很大变化,所以我不指望任何一个会在明年左右到达。这些提议有点互补,没有矛盾。

你可以做些什么来解决这个问题

取消令牌很容易实现。遗憾的是,目前异步函数不可能实现您真正想要的那种取消(又名“third state 取消,取消也不例外),因为您无法控制它们的运行方式. 你可以做两件事:

  • 改用协程 - bluebird 使用生成器和可以使用的 Promise 进行声音消除。
  • 用中止语义实现标记 - 这实际上很容易,所以让我们在这里做吧

CancellationTokens

嗯,令牌表示取消:

class Token {
   constructor(fn) {
      this.isCancellationRequested = false; 
      this.onCancelled = []; // actions to execute when cancelled
      this.onCancelled.push(() => this.isCancellationRequested = true);
      // expose a promise to the outside
      this.promise = new Promise(resolve => this.onCancelled.push(resolve));
      // let the user add handlers
      fn(f => this.onCancelled.push(f));
   }
   cancel() { this.onCancelled.forEach(x => x); }
}

这会让你做类似的事情:

async function update(token) {
   if(token.isCancellationRequested) return;
   var urls = await getCdnUrls();
   if(token.isCancellationRequested) return;
   var metadata = await fetchMetaData(urls);
   if(token.isCancellationRequested) return;
   var content = await fetchContent(metadata);
   if(token.isCancellationRequested) return;
   await render(content);
   return;
}

var token = new Token(); // don't ned any special handling here
update(token);
// ...
if(updateNotNeeded) token.cancel(); // will abort asynchronous actions

这是一种非常丑陋的工作方式,最好是您希望异步函数意识到这一点,但它们不是()。

最理想的情况是,您的所有临时函数都会知道并在取消时throw(再次,只是因为我们不能拥有第三状态)看起来像:

async function update(token) {
   var urls = await getCdnUrls(token);
   var metadata = await fetchMetaData(urls, token);
   var content = await fetchContent(metadata, token);
   await render(content, token);
   return;
}

由于我们的每个函数都可以感知取消,它们可以执行实际的逻辑取消 - getCdnUrls 可以中止请求并抛出,fetchMetaData 可以中止底层请求并抛出等等。

以下是在浏览器中使用XMLHttpRequest API 编写getCdnUrl(注意单数)的方式:

function getCdnUrl(url, token) {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    var p = new Promise((resolve, reject) => {
      xhr.onload = () => resolve(xhr);
      xhr.onerror = e => reject(new Error(e));
      token.promise.then(x => { 
        try { xhr.abort(); } catch(e) {}; // ignore abort errors
        reject(new Error("cancelled"));
      });
   });
   xhr.send();
   return p;
}

这与我们在没有协程的情况下使用异步函数所能达到的一样接近。它不是很漂亮,但它确实可以使用。

请注意,您希望避免将取消视为例外。这意味着如果您的函数 throw 在取消时,您需要在全局错误处理程序 process.on("unhandledRejection", e => ... 等上过滤这些错误。

【讨论】:

  • 你在哪里发表演讲的?它在某处可用吗?
  • @Bergi 在当地的一次聚会上,它很小(约 250 人),在希伯来语中,它只有 10 分钟,只是对最近发展的调查 + 我对取消承诺的个人看法。提出并讨论了docs.google.com/presentation/d/…github.com/domenic/cancelable-promise/issues/10 中的一些讨论,repo 尚未更新)。当前的共识 - 用于取消的代币,没有代币就不能直接取消承诺,可观察对象也使用代币。 github.com/zenparsing/es-observable/pull/97 中的一团糟。
  • 啊,没什么大不了的 :-) 我想我会花一些时间来为可取消承诺做出贡献……
  • 感谢@BenjaminGruenbaum 的详细解释。我也在考虑使用 co.js(github.com/tj/co)而不是直接使用 async await,它使用生成器和承诺来实现它,并且可以在每次调用 .next() 之前根据令牌状态做出决定跨度>
  • @sbr 在这种情况下,只需使用 bluebird - 它会更快、性能更好、提供更好的错误消息,并在其协程中内置取消语义。
【解决方案2】:

你可以使用 Typescript + Bluebird + cancelable-awaiter 得到你想要的。

现在所有证据都指向取消令牌not making it to ECMAScript,我认为取消的最佳解决方案是@BenjaminGruenbaum 提到的蓝鸟实现,但是,我发现协程和生成器的使用有点笨拙和不安眼睛。

由于我使用的是 Typescript,它现在支持 es5 和 es3 目标的 async/await 语法,因此我创建了一个简单的模块,它将默认的 __awaiter 助手替换为支持蓝鸟取消的助手:https://www.npmjs.com/package/cancelable-awaiter

【讨论】:

    【解决方案3】:

    不幸的是,不,您无法控制默认异步/等待行为的执行流程——这并不意味着问题本身是不可能的,这意味着您需要稍微改变您的方法。

    首先,您关于将每个异步行包装在检查中的建议是一个可行的解决方案,如果您只有几个具有此类功能的地方,那么它没有任何问题。

    如果您想经常使用这种模式,最好的解决方案可能是to switch to generators:虽然不那么普遍,但它们允许您定义每个步骤的行为,并且添加取消是最简单的。生成器是pretty powerful,但是,正如我所提到的,它们需要一个运行器函数,而不是像 async/await 那样简单。

    另一种方法是创建cancellable tokens pattern——您创建一个对象,该对象将填充一个想要实现此功能的函数:

    async function updateUser(token) {
      let cancelled = false;
    
      // we don't reject, since we don't have access to
      // the returned promise
      // so we just don't call other functions, and reject
      // in the end
      token.cancel = () => {
        cancelled = true;
      };
    
      const data = await wrapWithCancel(fetchData)();
      const userData = await wrapWithCancel(updateUserData)(data);
      const userAddress = await wrapWithCancel(updateUserAddress)(userData);
      const marketingData = await wrapWithCancel(updateMarketingData)(userAddress);
    
      // because we've wrapped all functions, in case of cancellations
      // we'll just fall through to this point, without calling any of
      // actual functions. We also can't reject by ourselves, since
      // we don't have control over returned promise
      if (cancelled) {
        throw { reason: 'cancelled' };
      }
    
      return marketingData;
    
      function wrapWithCancel(fn) {
        return data => {
          if (!cancelled) {
            return fn(data);
          }
        }
      }
    }
    
    const token = {};
    const promise = updateUser(token);
    // wait some time...
    token.cancel(); // user will be updated any way
    

    我写过关于取消和生成器的文章:

    总而言之——你必须做一些额外的工作来支持取消,如果你想在你的应用程序中将它作为一等公民,你必须使用生成器。

    【讨论】:

      【解决方案4】:

      这是一个带有承诺的简单示例:

      let resp = await new Promise(function(resolve, reject) {
          // simulating time consuming process
          setTimeout(() => resolve('Promise RESOLVED !'), 3000);
          // hit a button to cancel the promise
          $('#btn').click(() => resolve('Promise CANCELED !'));
      });
      

      请参阅此codepen 以获取演示

      【讨论】:

        【解决方案5】:

        不幸的是,目前还不支持cancellable 承诺。有一些自定义实现,例如

        扩展/包装可取消和可解决的承诺

        
        function promisify(promise) {
          let _resolve, _reject
        
          let wrap = new Promise(async (resolve, reject) => {
            _resolve = resolve
            _reject = reject
            let result = await promise
            resolve(result)
          })
        
          wrap.resolve = _resolve
          wrap.reject = _reject
            
          return wrap
        }
        

        用法:取消promise并在它之后立即停止进一步执行

        async function test() {
          // Create promise that should be resolved in 3 seconds
          let promise = new Promise(resolve => setTimeout(() => resolve('our resolved value'), 3000))
          
          // extend our promise to be cancellable
          let cancellablePromise = promisify(promise)
          
          // Cancel promise in 2 seconds.
          // if you comment this line out, then promise will be resolved.
          setTimeout(() => cancellablePromise.reject('error code'), 2000)
        
          // wait promise to be resolved
          let result = await cancellablePromise
          
          // this line will never be executed!
          console.log(result)
        }
        

        在这种方法中,promise 本身会执行到最后,但等待 promise 结果的调用者代码可以被“取消”。

        【讨论】:

        • 其实,这正是我想要的。在我的调度程序实现之一中,我有很多从数据库读取、从服务器获取等的异步操作。如果异步操作超时并且不返回......只需使用参考自己调用解析/拒绝,并使用您认为合适的超时时间。这也将解决内存问题,因为我们确保将执行解析/拒绝。
        【解决方案6】:

        使用 CPromise (c-promise2 package) 可以通过以下方式轻松完成 (Demo):

        import CPromise from "c-promise2";
        
        async function getCdnUrls() {
          console.log(`task1:start`);
          await CPromise.delay(1000);
          console.log(`task1:end`);
        }
        
        async function fetchMetaData() {
          console.log(`task2:start`);
          await CPromise.delay(1000);
          console.log(`task2:end`);
        }
        
        function* fetchContent() {
          // using generators is the recommended way to write asynchronous code with CPromise
          console.log(`task3:start`);
          yield CPromise.delay(1000);
          console.log(`task3:end`);
        }
        
        function* render() {
          console.log(`task4:start`);
          yield CPromise.delay(1000);
          console.log(`task4:end`);
        }
        
        const update = CPromise.promisify(function* () {
          var urls = yield getCdnUrls();
          var metadata = yield fetchMetaData(urls);
          var content = yield* fetchContent(metadata);
          yield* render(content);
          return 123;
        });
        
        const promise = update().then(
          (v) => console.log(`Done: ${v}`),
          (e) => console.warn(`Fail: ${e}`)
        );
        
        setTimeout(() => promise.cancel(), 2500);
        

        控制台输出:

        task1:start 
        task1:end 
        task2:start 
        task2:end 
        task3:start 
        Fail: CanceledError: canceled 
        

        【讨论】:

          【解决方案7】:

          就像在常规代码中一样,您应该从第一个函数(或接下来的每个函数)抛出异常,并在整个调用集周围设置一个 try 块。无需额外的 if-else。这是关于 async/await 的优点之一,您可以像我们习惯的常规代码那样处理错误。

          无需取消其他操作。在解释器遇到它们的表达式之前,它们实际上不会开始。所以第二个异步调用只会在第一个完成后开始,没有错误。其他任务可能有机会同时执行,但出于所有意图和目的,这部分代码是串行的,并将按所需的顺序执行。

          【讨论】:

          • 我不知道常规代码中类似取消的语义的类比 - 如果你找到了,请告诉我我很好奇。
          【解决方案8】:

          This answer I posted 可以帮助您将函数重写为:

          async function update() {
             var get_urls = comPromise.race([getCdnUrls()]);
             var get_metadata = get_urls.then(urls=>fetchMetaData(urls));
             var get_content = get_metadata.then(metadata=>fetchContent(metadata);
             var render = get_content.then(content=>render(content));
             await render;
             return;
          }
          
          // this is the cancel command so that later steps will never proceed:
          get_urls.abort();
          

          但我还没有实现“类保留”then 功能,所以目前你必须用comPromise.race 包装你希望能够取消的每个部分。

          【讨论】:

            【解决方案9】:

            我创建了一个名为@kaisukez/cancellation-token的库

            想法是将CancellationToken 传递给每个异步函数,然后将每个promise 包装在AsyncCheckpoint 中。这样当令牌被取消时,你的异步函数将在下一个检查点被取消。

            这个想法来自tc39/proposal-cancelable-promises conradreuter/cancellationtoken


            如何使用我的图书馆

            1. 重构您的代码
            // from this
            async function yourFunction(param1, param2) {
                const result1 = await someAsyncFunction1(param1)
                const result2 = await someAsyncFunction2(param2)
                return [result1, result2]
            }
            
            // to this
            import { AsyncCheckpoint } from '@kaisukez/cancellation-token'
            async function yourFunction(token, param1, param2) {
                const result1 = await AsyncCheckpoint.after(token, () => someAsyncFunction1(param1))
                const result2 = await AsyncCheckpoint.after(token, () => someAsyncFunction2(param2))
                return [result1, result2]
            }
            
            1. 创建一个令牌,然后使用该令牌调用您的函数
            import { CancellationToken, CancellationError } from '@kaisukez/cancellation-token'
            
            const [token, cancel] = CancellationToken.source()
            
            // spawn background task (run async function without using `await`)
            CancellationError.ignoreAsync(() => yourAsyncFunction(token, param1, param2))
            
            // ... do something ...
            
            // then cancel the background task
            await cancel()
            

            所以这就是 OP 问题的解决方案。

            import { CancellationToken, CancellationError, AsyncCheckpoint } from '@kaisukez/cancellation-token'
            
            async function update(token) {
               var urls = await AsyncCheckpoint.after(token, () => getCdnUrls());
               var metadata = await AsyncCheckpoint.after(token, () => fetchMetaData(urls));
               var content = await AsyncCheckpoint.after(token, () => fetchContent(metadata));
               await AsyncCheckpoint.after(token, () => render(content));
               return;
            }
            
            const [token, cancel] = CancellationToken.source();
            
            // spawn background task (run async function without using `await`)
            CancellationError.ignoreAsync(() => update(token))
            
            // ... do something ...
            
            // then cancel the background task
            await cancel()
            

            【讨论】:

              【解决方案10】:

              使用 Typescript 编写的 Node 示例调用可以从外部中止:

              function cancelable(asyncFunc: Promise<void>): [Promise<void>, () => boolean] {
                class CancelEmitter extends EventEmitter { }
              
                const cancelEmitter = new CancelEmitter();
                const promise = new Promise<void>(async (resolve, reject) => {
              
                  cancelEmitter.on('cancel', () => {
                    resolve();
                  });
              
                  try {
                    await asyncFunc;
                    resolve();
                  } catch (err) {
                    reject(err);
                  }
              
                });
              
                return [promise, () => cancelEmitter.emit('cancel')];
              }
              

              用法:

              const asyncFunction = async () => {
                // doSomething
              }
              
              const [promise, cancel] = cancelable(asyncFunction());
              
              setTimeout(() => {
                cancel();
              }, 2000);
              
              (async () => await promise)();
              

              【讨论】:

              • 这只是Promise.race 的错误实现。我也看不出有任何理由使用EventEmitter,甚至是它的本地子类?
              • 对于 setTimeout Promise.race 就足够了。
              • cancel 函数是一样的。