4
\$\begingroup\$

Are these good implementations of parallel and sequential "extension" methods, and do they accomplish what I think they do?

My understanding is that the first one is parallel because the await applies to each invocation of the async lambda that is passed to map. So each invocation will block asynchronously and run in parallel.

In contrast, the await in the sequential function applies to the outer function, so each iteration of the for loop will block, and the next iteration will only occur once the previous one completes.

I based these implementations on the answer found at this SO question: https://stackoverflow.com/questions/37576685/using-async-await-with-a-foreach-loop

Array.prototype.forEachParallel = async function (
   func: (item: any) => Promise<void>
): Promise<void> {
   await Promise.all(this.map(async (item: any) => await func(item)));
};

Array.prototype.forEachSequential = async function (
   func: (item: any) => Promise<void>
): Promise<void> {
   for (let item of this)
      await func(item);
};

Example usage:

await someList.forEachParallel(item => this.SomeAsyncOperation(item));  

A little additional code is:

// type declaration
interface Array<T> {
   ...
   forEachParallel(func: (item: T) => Promise<void>): Promise<void>;
   forEachSequential(func: (item: T) => Promise<void>): Promise<void>;
}

// make sure nothing is already defined on these members
[
   ...
   Array.prototype.forEachParallel,
   Array.prototype.forEachSequential
].forEach(maybeMember => { if (maybeMember) throw new Error("prototype extension collision"); });
\$\endgroup\$
2
\$\begingroup\$

JavaScript's execution model

My understanding is that the first one is parallel because the await applies to each invocation of the async lambda that is passed to map.

This is not quite right since JavaScript (i.e. ECMAScript) is not multi-threaded.

Simply speaking, the VM executing the code has a queue from which it dequeues an action (e.g. "run this file") and executes it. This is done ad infinitum. It runs every dequeued element either to completion, to a yield (in generator functions), an await (in async functions) or an iterator.next() (on a generator function).
The only way your code can be interfered with (preempted) is using these statements. But even then no parallelism is created, the VM just switches the current queue item it is processing, e.g. to the caller of iterator.next() when the generator function just yielded a value.

I recommend you to read Concurrency model and Event Loop on MDN, which explains it in more detail and formally better than I can off the top of my head :)

The effects of the execution model

Endless loops cannot be avoided

The following code will print Processed 1 and then hang in the endless loop.

await [1, 2, 3].forEachParallel(async (element: number) => {
  if (element === 2) {
    while (true);
  }
  else {
    console.log('Processed ' + element);
  }
});

Your forEachParallel will call Array#map, which will iterate the array's elements one-by-one and apply (i.e. call, not await!) the passed function on it. It is now useful to mentally desugar your passed function from the aync/await syntactic sugar:

async (item: any) => await func(item)
// <=> (just return the Promise)
async (item: any) => func(item)
// <=> (an async function without awaits does not need to be async anymore)
(item: any) => func(item)

After desugaring, it is now apparent that this is the reason why Array#map will hang on the second iteration.

forEachParallel vs. forEachSequential

In contrast, the await in the sequential function applies to the outer function, so each iteration of the for loop will block, and the next iteration will only occur once the previous one completes.

Exactly!

forEachSequential guarantees that func will be called and completed in-order on the array elements. In contrast, forEachParallel guarantees as well that func will be called in-order on the array elements, but not that they complete in-order, too!

await [1, 2, 3].forEachParallel(async (element: number) => {
  console.log('Processed ' + element);
});

The code above might print:

Processed 3
Processed 1
Processed 2

Whereas the following code will always print 1, 2, 3 in-order:

await [1, 2, 3].forEachSequential(async (element: number) => {
  console.log('Processed ' + element);
});

// This will print 1, 2, 3 as well, necessitating 1000ms for *each*
// element.
await [1, 2, 3].forEachSequential(async (element: number) => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('Processed ' + element);
      resolve();
    }, 1000);
  });
});

Quiz: forEachParallel with timeouts

Will the following code hang? If yes, where does it happen (in map, in await Promise.all(), in neither)? What does it print?

await [1, 2, 3].forEachParallel(async (element: number) => {
  setTimeout(() => {
    if (element === 2) {
      while (true);
    }
    else {
      console.log('Processed ' + element);
    }
  }, 0);
});

Processed 1 will be printed and then the program will hang.

setTimeout(..., 0) immediately adds the function to the end of the VM's queue (see the MDN link above). Since Array#map works sequentially on all elements, the VM's queue will look like
[current: forEachParallel] | setTimeout from first element | setTimeout from the second element | setTimeout from the third element
Actually, I am not really sure where it will hang. I suppose it might happen in forEachParallel (because await Promise.all(...)) will pave the way for the execution of the setTimeout callbacks (and thereby escpecially for the second one) or it might happen after forEachParallel has entirely completed its execution. Note that the promise returned by the function passed to it are resolved directly at the end through the implicit return, not in the setTimeout callback!

Design Suggestions

  • I would opt to not use async for the function you pass to Array#map:

    await Promise.all(this.map(async (item: any) => await func(item)));
    

    The first time I read the code, I thought: "Is this really the Array#map function which is passed an async function? But how should that work? Array#map does not deal with async functions, or does it?"

    Put another way, I usually expect functions getting async functions passed as arguments to be aware of them and deal with them in a special way. Here, you are just using the fact that Array#map accepts a general and generic (element: T) => U function, where you happen to instantiate U with Promise<void>.

    You can also leave out the type annotation, this results in the following line:

    await Promise.all(this.map(item => func(item));

  • You are correctly using the generic type inside the Array interface, that's good!
    In the implementation, however, you are leaving them out and thus lose type safety in the implementation. The code still benefits from the interface type safety, though.
    I am not entirely sure about the best practices on this, but simply annotating the implementation as well worked flawlessly for me:

    Array.prototype.forEachParallel = async function<T>(this: Array<T>, func: (item: T) => Promise<void>): Promise<void> {
      // TypeScript now correctly infers the result from this.map
      await Promise.all(this.map(item => func(item)));
    };
    
    Array.prototype.forEachSequential = async function<T>(func: (item: T) => Promise<void>) {
      /* ... */ 
    };
    
\$\endgroup\$
  • \$\begingroup\$ the method that you passed to forEachParallel is not actually asynchronous, since it doesn't await anything. I know that JS is not multi-threaded, but the idea is that the first "chunk" of a method (the part before the first await) can run separately from other "chunks" (parts separated by awaited calls). so, part A of 100 items might run while waiting for the part Bs to start, whereas sequentially, you would have to wait for parts A, B, C, D, etc. of the first item to all finish before starting part A of the second item. \$\endgroup\$ – Dave Cousineau Apr 23 at 15:44
  • \$\begingroup\$ for the type safety part, I had to do weird things in order to get the type system to accept these "extension methods" on the prototype. it's possible that it has been fixed since then, or maybe that I was missing something obvious, not sure. \$\endgroup\$ – Dave Cousineau Apr 23 at 15:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.