【问题标题】:How to "cancel/abort" the side effect of an already fired asynchronous task?如何“取消/中止”已经触发的异步任务的副作用?
【发布时间】:2025-06-05 18:15:02
【问题描述】:
let state = 'A'

async function runTask() {
  state = await someApi()
}

// `someApi`'s execution time may vary, as well as its response.
  1. 初始状态为A,然后调用runTask
  2. 出于某种原因,runTask 在上一次调用得到解决之前再次调用。
  3. someApi 的第二次调用以响应C 解析,并相应更新状态。
  4. someApi 的第一次调用解析为响应B,并且相应地更新了状态,这是不可取的。
time  state

 |      A
 |      A  --- runTask
 |      A     someApi(call #1)
 |      A         |
 |      A         |
 |      A  ------------------------ runTask (get called before previous one settles)
 |      A         |              someApi(call #2)
 |      A         |                    ‖
 |      A         |                    ‖
 |      A         |                    v
 |      C  --------------------- response #2: C
 |      C         |
 |      C         |
 |      C         v
 |      B  -- response #1: B
 |      B
 |      B
 v

我应该如何在javascript中优雅地解决这个问题?我能想到的一种解决方法是:为每个请求分配时间戳并维护一个全局 lastUpdated 变量,以防止任何过时和迟到的响应修改状态。但是,它感觉很脏并且不能很好地扩展,因为通常有很多状态和异步操作。


更新

赏金奖励最早的有利答案。

【问题讨论】:

  • 对 XHR 处理程序的引用和取消令牌。
  • 如果我们确实有正在运行的请求,我们可以延迟 someApi 吗?
  • "我应该如何在javascript中优雅地解决这个问题?" - 没有简单的解决方案(除了禁止someApi()runTask 的并发调用,这实际上只是将问题一扫而光)。根本问题是您的程序正在覆盖(因此丢失)共享状态。我可能会建议您重构代码以类似于 Redux 模式...
  • 可以检查call id是否大于上一个结果。当响应 #1 到达时,它看到最后一个值是响应 #2 并忽略结果

标签: javascript asynchronous es6-promise race-condition cancellation


【解决方案1】:

解决此问题的一种模式是使用mutex 将您的呼叫锁定到runTask

这样做的一些好的nodejs库是 https://www.npmjs.com/package/async-mutex

【讨论】:

    【解决方案2】:

    在这里,我维护了一个包含所有XMLHttpRequests 的数组。 在新请求的情况下,旧请求将被中止并从数组中删除。 当一个 xhr 被加载时,它将从 requestArr 中移除,并返回 xhr 结果。

    someApi() 中并非所有xhr 都相关的不同xhr 的情况下,我在xhr 上创建自定义属性requestType。或者,可以中止并删除数组中的所有内容。

    let state = 'A';
    let requestArr = [];
    
    runTask('B', 2000); // should return after 2 sec
    runTask('C', 1000); // should return after 1 sec
    
    async function runTask(fakeReturnValue, fakeReturnDelay) {
      state = await someApi(fakeReturnValue, fakeReturnDelay);
      // when the state is updated, console.log
      console.log(state);
    }
    
    async function someApi(fakeReturnValue, fakeReturnDelay) {
      /* find all xhr with requestType "stateupdater"
         and call abort() on them */
      requestArr.filter(xhr => xhr.requestType == 'stateupdater').forEach(xhr => xhr.abort());
      // remove all the xhr requestType "stateupdater"
      requestArr = requestArr.filter(xhr => xhr.requestType != 'stateupdater');
      // create a new Promise
      return new Promise(function(resolve, reject) {
        const xhr = new XMLHttpRequest();
        xhr.addEventListener('load', e => {
          /* remove the current xhr (e.target)
             from the requestArr */
          requestArr = requestArr.filter(xhr => xhr != e.target);
          // return the resonse in the Promise
          resolve(e.target.response);
        });
        // set a custom property on xhr
        xhr.requestType = 'stateupdater';
        // URL is replaced by a data URL for testing
        xhr.open("GET", `data:text/plain,${fakeReturnValue}`);
        // add the new xhr to the requestArr
        requestArr.push(xhr);
        // send the request, here combined with a setTimeout for testing
        //xhr.send();
        setTimeout(function(){xhr.send();}, fakeReturnDelay);
      });
    }

    【讨论】:

    • 我尝试运行代码,结果是C,然后是B,这是我想要阻止的。 C 稍后调用,因此它的结果不应被 B 覆盖,即使 B 稍后解析。
    • 我期待一个针对异步情况的通用解决方案,而不仅仅是 XHR。
    • @NandinBorjigin 上面的代码只返回C——这不是重点吗?当下一个呼叫 (C) 进行时,第一个呼叫 (B) 将被取消。
    【解决方案3】:

    这是处理竞争条件的老式解决方案:

    • 使用在发送异步请求之前递增的全局计数器
    • 使用计数器“标记”请求
    • 在请求回调中(以任意顺序触发)将标记与计数器进行比较
    • 如果回调是针对最新请求的,则继续

    下面是一个小的概念验证,它使用 PHP 脚本来模拟竞态条件。请注意,runTask 函数应该按执行顺序返回 B 和 C,但按完成顺序返回 C 和 B。但是,竞态条件处理确保状态从 A 变为 C:

    let state = "A";
    let requestCounter = 0;
    async function runTask(url) {
        console.log(`runTask was called (url = ${url})`);
        let currentRequest = ++requestCounter;
        let response = await fetch(url);
        let value = await response.json();
        if (currentRequest !== requestCounter) {
            console.log(`Ignoring invocation (url = ${url})`);
            return;
        }
        console.log(`Updating state (url = ${url})`);
        state = value;
    }
    runTask("http://localhost/api.php?delay=5&state=B");
    runTask("http://localhost/api.php?delay=3&state=C");
    

    输出:

    17:00:00.000 runTask was called (url = http://localhost/api.php?delay=5&state=B)
    17:00:00.004 runTask was called (url = http://localhost/api.php?delay=3&state=C)
    17:00:03.032 Updating state (url = http://localhost/api.php?delay=3&state=C)
    17:00:05.041 Ignoring invocation (url = http://localhost/api.php?delay=5&state=B)
    

    【讨论】:

      【解决方案4】:

      有很多方法可以实现这一点。在我的回答中,我将使用带计数器的闭包。我相信这是实现它的最简单方法。

      首先,我将通过创建问题示例来定义问题。从下面的示例中可以清楚地看出,状态开始时是空的。然后将调用计数设置为 2,然后从第一个 api 请求中将调用计数设置为 1。这是不受欢迎的行为。

      问题的一个例子

      let state = {}
      const returnValue = document.getElementById("return-value")
      returnValue.innerHTML = JSON.stringify(state)
      
      //* This is the api, lets assume we do not have access over anything in it */
      let apiState = {callCount:0}
      async function someApi() {
        console.log("the server got a request");
        apiState.callCount++
        const returnableState = JSON.parse(JSON.stringify(apiState));
        return await new Promise((resolve) =>
                  setTimeout(async () => {
                    resolve(returnableState);
                  }, 8000 - apiState.callCount*3000)
                );
      }
      
      
      // this function calls the api and for convienience places it in the DOM
      async function runTask() {
        state = await someApi()
        returnValue.innerHTML = JSON.stringify(state)
      }
      
      runTask();
      runTask();
      <code><pre id="return-value"></pre></code>

      使用可访问计数器变量的闭包可以解决这个问题。为简单起见,我将计数器放在全局范围内;但是,如果需要,您理论上可以封装它。

      为了解决这个问题,我们在调用 api 之前递增计数器变量 (requestCount),然后将其保存为常量 (thisRequestId)。

      在 api 请求返回后,我们会检查以确保没有新的已解析 api 请求已发出。我们使用一个恰当地命名为currentNewestResolvedCounter的变量来做到这一点

      使用计数器解决问题的示例

      let state = {}
      const returnValue = document.getElementById("return-value")
      returnValue.innerHTML = JSON.stringify(state)
      
      //* This is the api, lets assume we do not have access over anything in it */
      let apiState = {callCount:0}
      async function someApi() {
        console.log("the server got a request");
        apiState.callCount++
        const returnableState = JSON.parse(JSON.stringify(apiState));
        return await new Promise((resolve) =>
                  setTimeout(async () => {
                    resolve(returnableState);
                  }, 8000 - apiState.callCount*3000)
                );
      }
      
      // this function calls the api and for convienience places it in the DOM
      let requestCount = 0;
      // newer is higher
      let currentNewestResolvedCounter = 0;
      async function runTask() {
        requestCount++;
        const thisRequestId = requestCount
        
        const newState = await someApi()
        
        // make sure that there is not a newer resolved counter 
        // before saying we are the newest and updating the state
        if (currentNewestResolvedCounter < thisRequestId) {
          currentNewestResolvedCounter = thisRequestId
          returnValue.innerHTML = JSON.stringify(newState);
          state = newState;
        }
        console.log({ requestCount, thisRequestId, currentNewestResolvedCounter })
      }
      
      runTask()
      runTask()
      &lt;code&gt;&lt;pre id="return-value"&gt;&lt;/pre&gt;&lt;/code&gt;

      如您所见,我们没有在第二个示例中更新 DOM,即使它在第一个 API 请求之后完成。

      【讨论】: