简体   繁体   中英

What is a practical / elegant way to manage complex event sequences with cancellation in JavaScript?

I have a JavaScript ( EmberJS + Electron ) application that needs to execute sequences of asynchronous tasks. Here is a simplified example:

  1. Send a message to a remote device
  2. Receive response less than t1 seconds later
  3. Send another message
  4. Receive second response less than t2 seconds later
  5. Display success message

For simple cases this seems reasonably easy to implement with Promises : 1 then 2 then 3 ... It gets a little trickier when timeouts are incorporated, but Promise.race and Promise.all seem like reasonable solutions for that.

However, I need to allow users to be able to cancel a sequence gracefully, and I am struggling to think of sensible ways to do this. The first thing that came to mind was to do some kind of polling during each step to see if a variable someplace has been set to indicate that the sequence should be canceled. Of course, that has some serious problems with it:

  • Inefficient: most of the polling time is wasted
  • Unresponsive: an extra delay is introduced by having to poll
  • Smelly : I think it goes without saying that this would be inelegant. A cancel event is completely unrelated to time so shouldn't require using a timer. The isCanceled variable may need to be outside of the promise' scope. etc.

Another thought I had was to perhaps race everything so far against another promise that only resolves when the user sends a cancel signal. A major problem here is that the individual tasks running (that the user wants to cancel) don't know that they need to stop, roll-back, etc. so even though the code that gets the promise resolution from the race works fine, the code in the other promises does not get notified.

Once upon a time there was talk about cancel-able promises , but it looks to me like the proposal was withdrawn so won't be incorporated into ECMAScript any time soon though I think the BlueBird promise library supports this idea. The application I'm making already includes the RSVP promise library , so I didn't really want to bring in another one but I guess that's a potential option.

How else can this problem be solved? Should I be using promises at all? Would this be better served by a pub/sub event system or some such thing?

Ideally, I'd like to separate the concern of being canceled from each task (just like how the Promise object is taking care of the concern of asynchronicity). It'd also be nice if the cancellation signal could be something passed-in/injected.

Despite not being graphically skilled, I've attempted to illustrate what I'm trying to do by making the two drawings below. If you find them confusing then feel free to ignore them.

时间线显示事件顺序


该图显示了事件的可能顺序

If I understand your problem correctly, the following may be a solution.

Simple timeout

Assume your mainline code looks like this:

send(msg1)
  .then(() => receive(t1))
  .then(() => send(msg2))
  .then(() => receive(t2))
  .catch(() => console.log("Didn't complete sequence"));

receive would be something like:

function receive(t) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject("timed out"), t);
    receiveMessage(resolve, reject);
  });
}

This assumes the existence of an underlying API receiveMessage , which takes two callbacks as parameters, one for success and one for failure. receive simply wraps receiveMessage with the addition of the timeout which rejects the promise if time t passes before receiveMessage resolves.

User cancellation

But how to structure this so that an external user can cancel the sequence? You have the right idea to use a promise instead of polling. Let's write our own cancelablePromise :

function cancelablePromise(executor, canceler) {
  return new Promise((resolve, reject) => {
    canceler.then(e => reject(`cancelled for reason ${e}`));
    executor(resolve, reject);
  });
}

We pass an "executor" and a "canceler". "Executor" is the technical term for the parameter passed to the Promise constructor, a function with the signature (resolve, reject) . The canceler we pass in is a promise, which when fulfilled, cancels (rejects) the promise we are creating. So cancelablePromise works exactly like new Promise , with the addition of a second parameter, a promise for use in canceling.

Now you can write your code as something like the following, depending on when you want to be able to cancel:

var canceler1 = new Promise(resolve => 
  document.getElementById("cancel1", "click", resolve);
);

send(msg1)
  .then(() => cancelablePromise(receiveMessage, canceler1))
  .then(() => send(msg2))
  .then(() => cancelablePromise(receiveMessage, canceler2))
  .catch(() => console.log("Didn't complete sequence"));

If you are programming in ES6 and like using classes, you could write

class CancelablePromise extends Promise {
  constructor(executor, canceler) {
    super((resolve, reject) => {
      canceler.then(reject);
      executor(resolve, reject);
    }
}

This would then obviously be used as in

send(msg1)
  .then(() => new CancelablePromise(receiveMessage, canceler1))
  .then(() => send(msg2))
  .then(() => new CancelablePromise(receiveMessage, canceler2))
  .catch(() => console.log("Didn't complete sequence"));

If programming in TypeScript, with the above code you will likely need to target ES6 and run the resulting code in an ES6-friendly environment which can handle the subclassing of built-ins like Promise correctly. If you target ES5, the code TypeScript emits might not work.

The above approach has a minor (?) defect. Even if canceler has fulfilled before we start the sequence, or invoke cancelablePromise(receiveMessage, canceler1) , although the promise will still be canceled (rejected) as expected, the executor will nevertheless run, kicking off the receiving logic--which in the best case might consume network resources we would prefer not to. Solving this problem is left as an exercise.

"True" cancelation

But none of the above addresses what may be the real issue: to cancel an in-progress asynchronous computation. This kind of scenario was what motivated the proposals for cancelable promises, including the one which was recently withdrawn from the TC39 process. The assumption is that the computation provides some interface for cancelling it, such as xhr.abort() .

Let's assume that we have a web worker to calculate the n th prime, which kicks off on receiving the go message:

function findPrime(n) {
  return new Promise(resolve => {
    var worker = new Worker('./find-prime.js');
    worker.addEventListener('message', evt => resolve(evt.data));
    worker.postMessage({cmd: 'go', n});
  }
}

> findPrime(1000000).then(console.log)
< 15485863

We can make this cancelable, assuming the worker responds to a "stop" message to terminate its work, again using a canceler promise, by doing:

function findPrime(n, canceler) {
  return new Promise((resolve, reject) => {
    // Initialize worker.
    var worker = new Worker('./find-prime.js');

    // Listen for worker result.
    worker.addEventListener('message', evt => resolve(evt.data));

    // Kick off worker.
    worker.postMessage({cmd: 'go', n});

    // Handle canceler--stop worker and reject promise.
    canceler.then(e => {
      worker.postMessage({cmd: 'stop')});
      reject(`cancelled for reason ${e}`);
    });
  }
}

The same approach could be used for a network request, where the cancellation would involve calling xhr.abort() , for example.

By the way, one rather elegant (?) proposal for handling this sort of situation, namely promises which know how to cancel themselves, is to have the executor, whose return value is normally ignored, instead return a function which can be used to cancel itself. Under this approach, we would write the findPrime executor as follows:

const findPrimeExecutor = n => resolve => {
  var worker = new Worker('./find-prime.js');
  worker.addEventListener('message', evt => resolve(evt.data));
  worker.postMessage({cmd: 'go', n});

  return e => worker.postMessage({cmd: 'stop'}));
}

In other words, we need only to make a single change to the executor: a return statement which provides a way to cancel the computation in progress.

Now we can write a generic version of cancelablePromise , which we will call cancelablePromise2 , which knows how to work with these special executors that return a function to cancel the process:

function cancelablePromise2(executor, canceler) {
  return new Promise((resolve, reject) => {
    var cancelFunc = executor(resolve, reject);

    canceler.then(e => {
      if (typeof cancelFunc === 'function') cancelFunc(e);
      reject(`cancelled for reason ${e}`));
    });

  });
}

Assuming a single canceler, your code can now be written as something like

var canceler = new Promise(resolve => document.getElementById("cancel", "click", resolve);

function chain(msg1, msg2, canceler) {
  const send = n => () => cancelablePromise2(findPrimeExecutor(n), canceler);
  const receive =   () => cancelablePromise2(receiveMessage, canceler);

  return send(msg1)()
    .then(receive)
    .then(send(msg2))
    .then(receive)
    .catch(e => console.log(`Didn't complete sequence for reason ${e}`));
}

chain(msg1, msg2, canceler);

At the moment that the user clicks on the "Cancel" button, and the canceler promise is fulfilled, any pending sends will be canceled, with the worker stopping in midstream, and/or any pending receives will be canceled, and the promise will be rejected, that rejection cascading down the chain to the final catch .

The various approaches that have been proposed for cancelable promise attempt to make the above more streamlined, more flexible, and more functional. To take just one example, some of them allow synchronous inspection of the cancellation state. To do this, some of them use the notion of "cancel tokens" which can be passed around, playing a role somewhat analogous to our canceler promises. However, in most cases cancellation logic can be handled without too much complexity in pure userland code, as we have done here.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM