/proposal-concurrency-control

interfaces and data structures for concurrency control and integration into async iterator helpers

Primary LanguageTypeScript

JavaScript Concurrency Control Proposal

Stage: 1

Champions: Michael Ficarra, Luca Casonato

Authors: Michael Ficarra, Luca Casonato, Kevin Gibbons

This proposal aims to provide a mechanism for describing a desired amount of concurrency and a coordination mechanism to achieve it. This could be for limiting concurrent access to a shared resource or for setting a target concurrency for an otherwise unbounded workload.

A major motivator for this proposal is the concurrency support in the async iterator helpers proposal. While that proposal has gone to great lengths to allow for concurrent iteration of its produced async iterators (such as through map and filter), it does not provide any way to consume async iterators concurrently (such as through some or forEach). Additionally, there is no mechanism provided by that proposal for generically limiting concurrent iteration of async iterators. This propsal attempts to address those deferred needs.

The concurrency control mechanism proposed here is also motivated by many other use cases outside of async iteration. For example, an application may want to limit the concurrency of a certain function being invoked, or limit concurrent file reads, writes, or network requests. This proposal aims to provide a generic mechanism for controlling concurrency in JavaScript that can be used in a wide variety of use cases.

Presentations to Committee

Proposal

This proposal consists of 3 major components: a Governor interface, the CountingGovernor class, and the AsyncIterator.prototype integration.

Governor

The Governor interface is used for gaining access to a limited resource and later signalling that you are finished with that resource. It is intentionally designed in a way that permits dynamically changing limits.

There is only a single method required by the Governor interface: acquire, returning a Promise that eventually resolves with a GovernorToken. A GovernorToken has a release method to indicate that the resource is no longer needed. The GovernorToken can also be automatically disposed using using syntax from the Explicit Resource Management proposal.

A Governor is meant to control access to resources among mutually trustworthy parties. For adversarial scenarios, a Capability should be used instead.

The Governor name is taken from the speed-limiting device in motor vehicles.

There is also a Governor constructor with helpers on its prototype.

The constructor unconditionally throws when it is the new.target. To make the helpers available, a concrete Governor can be implemented as follows:

const someGovernor = {
  __proto__: Governor.prototype,
  acquire() {
    // ...
  },
};

The with(fn: () => R): Promise<R> helper takes a function and automatically acquires/releases a GovernorToken. An approximation:

Governor.prototype.with = async (fn) => {
  using void = await this.acquire();
  return await fn();
};

The wrap(fn: (...args) => R): (...args) => Promise<R> helper takes a function and returns a function with the same behaviour but limited in its concurrency by this Governor. An approximation:

Governor.prototype.wrap = fn => {
  const governor = this;
  return async function() {
    using void = await governor.acquire();
    return await fn.apply(this, arguments);
  };
};

Similarly, wrapIterator(it: Iterator<T> | AsyncIterator<T>): AsyncIterator<T> takes an Iterator or AsyncIterator and returns an AsyncIterator that yields the same values but limited in concurrency by this Governor.

Open Questions

  • should the protocol be Symbol-based?
  • maybe a sync/throwing acquire?
    • tryAcquire(): GovernorToken
    • or maybe not throwing? tryAcquire(): GovernorToken | null
  • non-throwing Governor() constructor
    • takes an acquire: () => Promise<GovernorToken> function
    • also takes a tryAcquire function?
    • easy enough to live without it
  • alternative name: Regulator?

CountingGovernor

This proposal subsumes Luca's Semaphore proposal.

CountingGovernor is a counting semaphore that implements the Governor interface and extends Governor. It can be given a non-negative integral Number capacity and it is responsible for ensuring that there are no more than that number of active GovernorTokens simultaneously.

Open Questions

  • are idle listeners useful?
    • triggered whenever the CountingGovernor hits "full" capacity (0 active GovernorTokens)
    • addIdleListener(cb: () => void): void
    • removeIdleListener(cb: () => void): void
    • callback interface or EventTarget?
  • are there concerns about sharing CountingGovernors across Agents?
  • alternative name: CountingGovernor? CountingRegulator?

AsyncIterator.prototype integration

This proposal adds an optional concurrency parameter to the following async iterator helper methods:

  • .toArray([ governor ])
  • .forEach(fn [, governor ])
  • .some(predicate [, governor ])
  • .every(predicate [, governor ])
  • .find(predicate [, governor ])
  • .reduce(reducer [, initialValue [, governor ]])

When not passed, these methods operate serially, as they do in the async iterator helpers proposal.

This proposal also adds a limit(governor) method (the dual of governor.wrapIterator(iterator)) that returns a concurrency-limited AsyncIterator.

Because CountingGovernor will be an extremely commonly-used Governor, anywhere a Governor is accepted in any AsyncIterator.prototype method, a non-negative integral Number may be passed instead. It will be treated as if a CountingGovernor with that capacity was passed. Because of this, we are able to widen the first parameter of the buffered helper to accept a Governor in addition to the non-negative integral Number that it accepts as part of the async iterator helpers proposal.

Open Questions

  • reduce parameter order: gross?
  • buffered parameter order

Proposal Summary

  • Governors
    • Governor Interface
      • acquire(): Promise<GovernorToken>
    • Governor() constructor
      • throws when constructed directly
    • Governor.prototype
      • with(fn: () => R): Promise<R>
      • wrap(fn: (...args) => R): (...args) => Promise<R>
      • wrapIterator(it: Iterator<T> | AsyncIterator<T>): AsyncIterator<T>
    • GovernorToken.prototype
      • release(): void === [Symbol.dispose](): void
  • CountingGovernors
    • CountingGovernor(capacity: number) constructor
    • extending Governor
    • implementing the Governor interface
    • shareable across threads
  • AsyncIterator.prototype
    • buffered(limit: Governor | integer, prepopulate = false)
    • limit(limit: Governor | integer)
    • a concurrency param (Governor | integer) added to all consuming methods

FAQ

Why the generic Governor interface?

While a counting semaphore is a common concurrency control mechanism, there are many other ways to control concurrency. Some examples of these:

  • A governor that allows for a burst of activity before enforcing a limit (e.g. a semaphore that allows for 10 concurrent operations, but allows for 20 concurrent operations for the first 5 seconds).
  • A distributed governor that enforces a concurrency limit across multiple machines using a distributed lock or consensus algorithm.
  • A governor that enforces a concurrency limit based on the current load of the system (e.g. a semaphore that allows for 10 concurrent operations, but allows for 20 concurrent operations if the system is under 50% load).

Because of this variety of use cases, it is important to give developers the flexibility to use their own concurrency control mechanisms with built in JavaScript APIs.

The CountingGovernor class provides a simple and common concurrency control mechanism that can be used in many cases. It is expected that many developers will use CountingGovernor for their concurrency control needs. However, because APIs don't explicitly take a CountingGovernor instance, but any object that implements the Governor interface, developers can use their own concurrency control mechanisms if they need to.