Asynchronous Programming

profile image

Reading multi-paradigm programming, we explore how to handle asynchronous processing techniques combined with functional programming, starting from the perspective of treating Promises as values.

This post has been translated by DeepL . Please let us know if there are any mistranslations!

Multiparadigm Programming

This post is a personal summary after reading Multiparadigm Programming (by Yoo In-dong).

Handling Asynchrony as Values

Promise is both an object and a protocol that allows you to handle the results of asynchronous operations as values. When combined with the iterator pattern, it can create a very powerful asynchronous programming model. Let's explore how to control asynchronous logic using list processing.

Promise

Promises allow you to create objects immediately and handle them as values regardless of whether the asynchronous operation has completed, and you can retrieve results or handle errors when needed.

They have three states: pending, fulfilled, and rejected, and multiple Promises can be combined to execute sequentially or simultaneously.

The delay function that returns a Promise

typescript
function delay<T>(time: number, value: T): Promise<T> {
  return new Promise((resolve) => setTimeout(resolve, time, value))
}

This is a function that waits for the specified time and then returns the value received.

Have you ever used new Promise() directly?

Recently, it's rare to use new Promise() directly because Web APIs and most libraries already provide Promise-based interfaces.

However, when implementing unique parallel execution control or complex asynchronous logic not provided by existing tools, logic that directly creates and controls through new Promise() is necessary.

Being proficient with new Promise() means having a deep understanding of asynchronous control and problem-solving abilities.

Promise.race

Promise.race returns the result or error of the first Promise to complete among multiple Promises executed in parallel.

typescript
const promise1 = new Promise((resolve) => {
  setTimeout(() => resolve('Promise 1 complete!'), 2000);
});

const promise2 = new Promise((resolve) => {
  setTimeout(() => resolve('Promise 2 complete!'), 1000);
});

Promise.race([promise1, promise2])
  .then((result) => {
    console.log(result); // 'Promise 2 complete!'
  });

In what situations can Promise.race be used in practice?

Setting timeouts for IO operations

If you want to handle errors when API responses are delayed, using Promise.race is a good approach.

typescript
const result = await Promise.race([
  fetch('/friends'),
  delay(5000).then(() => { throw new Error('Timeout!') })
]);

While you could also use AbortController with fetch, the method using Promise.race provides versatility for effectively handling asynchronous operations in various situations beyond fetch.

Rendering UI with different strategies based on response speed

In a screen where there's an 'Invite Friends' button in a chat room, and you fetch the friend list when the button is pressed, you can use different UI strategies depending on response time. If the friend list API response completes within 100ms, render immediately; otherwise, display a loading indicator. This can be easily implemented using Promise.race.

UI rendering quiz using Promise

친구 목록

Multi-paradigm Programming Chapter 4 Quiz

Promise.all

Promise.all waits until all given Promises are fulfilled and returns all results as an array. If even one is rejected, it immediately rejects and returns the reason. It's useful when executing multiple asynchronous operations in parallel and waiting for all tasks.

typescript
function getFile(name: string, size = 1000): Promise<File> {
  return delay(size, { name, body: '...', size });
}

const files = await Promise.all([
  getFile('img.png', 500),
  getFile('book.pdf', 1000),
  getFile('index.html', 1500)
]);

console.log(files);
// After about 1,500ms:
// [
//   { name: 'img.png', body: '...', size: 500 },
//   { name: 'book.pdf', body: '...', size: 1000 },
//   { name: 'index.html', body: '...', size: 1500 }
// ]

Since it executes in parallel, the result is completed after 1500ms, which is the time of the longest Promise.

If there's a Promise that rejects in the middle, it terminates immediately. Therefore, the following code executes the catch block after 500ms:

typescript
try {
  const files = await Promise.all([
    getFile('img.png'), // default size: 1000, delay: 1000ms
    getFile('book.pdf'),
    getFile('index.html'),
    delay(500, Promise.reject('File download failed'))
  ]);
  console.log(files);
} catch (error) {
  // After about 500ms:
  console.error(error); // 'File download failed'
}

Promise.allSettled

Promise.allSettled waits for all Promises to complete regardless of fulfillment or rejection, and then returns the results as objects. This function appeared in ES11, and previously, helper functions like the following were created and used:

typescript
const settlePromise = <T>(promise: Promise<T>) =>
  promise
    .then(value => ({ status: 'fulfilled', value }))
    .catch(reason => ({ status: 'rejected', reason }));

const files = await Promise.all([
  getFile('img.png'),
  getFile('book.pdf'),
  getFile('index.html'),
  Promise.reject('File download failed')
].map(settlePromise));

Promise.any

Promise.any returns the value of the first Promise to be fulfilled among multiple Promises. It returns the result value of the first fulfilled Promise even if there are rejected values.

typescript
const files = await Promise.any([
  getFile('img.png', 1500),
  getFile('book.pdf', 700),
  getFile('index.html', 900),
  delay(100, Promise.reject('File download failed'))
]);

console.log(files);
// After about 700ms
// { name: 'book.pdf', body: '...', size: 700 }

Handling Asynchrony with Laziness

Now let's expand our thinking of treating Promises as values by creating reusable functions for handling asynchronous situations.

How to delay Promise execution

Promise.all executes all Promises in parallel and returns all results as an array. In this situation, how can we control the load for each Promise? For example, instead of executing all 6 Promises at once, we might want to execute them in 2 batches of 3.

Let's create a function that divides and executes such asynchronous operations:

typescript
async function executeWithLimit<T>(
  promises: Promise<T>[],
  limit: number
): Promise<T[]> {
  const result1 = await Promise.all([promises[0], promises[1], promises[2]]);
  const result2 = await Promise.all([promises[3], promises[4], promises[5]]);
  return [
    ...result1,
    ...result2
  ];
}

console.time("executeWithLimit");
await executeWithLimit(
  [
    delay(1000, "test"),
    delay(500, "test"),
    delay(300, "test"),
    delay(1000, "test"),
    delay(500, "test"),
    delay(200, "test"),
  ],
  3,
);
console.timeEnd("executeWithLimit"); // executeWithLimit: 1001.912841796875 ms

We expected it to take about 2000ms, with each batch taking up to 1000ms, but it actually only took 1000ms. This is because Promise objects are executed immediately upon creation.

Immediate execution of Promises

Promise objects are executed immediately upon creation. That is, the moment the delay function is called, the Promise has already started. Therefore, even if we group them into 3 and wait with await, all Promises start simultaneously.

The meaning of parallel execution

Promise.all is just a function that receives all already-executed Promises, waits until they are all completed, and then returns each Promise as an array; it's not a function that controls the start of the Promises themselves. Even if there are two calls to Promise.all, each Promise has already started, so it doesn't affect the total execution time.

In the end, even though it looks like we're executing in groups of 3 in parallel, they're actually all starting at the same time.

Solution

The solution is simple. Just wrap each Promise in a function:

typescript
async function executeWithLimit<T>(
  fs: (() => Promise<T>)[],
  limit: number,
): Promise<T[]> {
  const result1 = await Promise.all([fs[0](), fs[1](), fs[2]()]);
  const result2 = await Promise.all([fs[3](), fs[4](), fs[5]()]);
  return [...result1, ...result2];
}

(async () => {
  console.time("executeWithLimit");
  await executeWithLimit(
    [
      () => delay(1000, "test"),
      () => delay(500, "test"),
      () => delay(300, "test"),
      () => delay(1000, "test"),
      () => delay(500, "test"),
      () => delay(200, "test"),
    ],
    3,
  );
  console.timeEnd("executeWithLimit"); // executeWithLimit: 2003.93701171875 ms
})();

This way, we can delay the execution of Promises so they run only when needed.

Claude (Sonnet 4)'s imperative implementation of a concurrency handling function

I asked Claude to implement the executeWithLimit function:

typescript
async function executeWithLimit<T>(
  promiseFactories: (() => Promise<T>)[],
  limit: number,
): Promise<T[]> {
  const results: T[] = [];

  // Divide the promiseFactories array into chunks of size limit
  for (let i = 0; i < promiseFactories.length; i += limit) {
    const chunk = promiseFactories.slice(i, i + limit);

    // Create promises by executing all promise factories in the current chunk
    const promises = chunk.map((factory) => factory());

    // Execute the created promises in parallel
    const chunkResults = await Promise.all(promises);

    // Add the results to the overall results array
    results.push(...chunkResults);
  }

  return results;
}

Functional implementation of a concurrency handling function

Let's implement executeWithLimit from a list processing perspective.

chunk(size, iterable) function

First, we need a list processing function that groups an iterable into the given size:

typescript
function* chunk<T>(size: number, iterable: Iterable<T>): IterableIterator<T[]> {
  const iterator = iterable[Symbol.iterator]();
  while (true) {
    const arr = [
      ...take(size, {
        [Symbol.iterator]() {
          return iterator;
        },
      }),
    ];
    if (arr.length) yield arr;
    if (arr.length < size) break;
  }
}

class FxIterable<A> {
  ...
  chunk(size: number) {
    return fx(chunk(size, this));
  }
}

console.log([...chunk(2, [1, 2, 3, 4, 5])]); // [[1, 2], [3, 4], [5]]

Completing executeWithLimit starting with chunk

typescript
async function fromAsync<T>(
  iterable: Iterable<Promise<T>> | AsyncIterable<T>
): Promise<T[]> {
  const arr: T[] = [];
  for await (const a of iterable) {
    arr.push(a);
  }
  return arr;
}

const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
  fx(fs)
    .chunk(limit) // Group by limit
    .map(fs => fs.map(f => f())) // Execute async functions
    .map(ps => Promise.all(ps)) // Wait for limit at a time
    .to(fromAsync) // Extract results from Promise.all
    .then(arr => arr.flat()); // Flatten to 1D array

The key to the executeWithLimit function is laziness:

  • The executeWithLimit function takes functions that delay Promise execution as arguments.
  • .map(fs => fs.map(f => f())) looks like it executes all the functions, but unlike Array's map, it's our custom map that is lazily evaluated.
  • Therefore, in fromAsync, only the functions in that chunk are executed when one element is extracted, and in the next map, they are wrapped with Promise.all.
  • fromAsync waits for the results of Promise.all by extracting them with for await...of.
  • As a result, it evaluates delayed asynchronous operations sequentially and stores them in an array.

Making the code more concise with better use of laziness in function composition

typescript
const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
  fx(fs)
    .map(f => f()) // Lazy execution of async functions
    .chunk(limit) // Grouping
    .map(ps => Promise.all(ps)) // Wrap with Promise.all to wait for limit at a time
    .to(fromAsync)
    .then(arr => arr.flat());

.to(fromAsync) - This is where actual execution happens!

javascript
// for await loop executes sequentially:

// Step 1: Execute first Promise.all
await Promise.all([f1(), f2(), f3()])
// → API calls 1,2,3 simultaneously → wait for all to complete
// Result: [response1, response2, response3]

// Step 2: Execute second Promise.all
await Promise.all([f4(), f5(), f6()])
// → API calls 4,5,6 simultaneously → wait for all to complete
// Result: [response4, response5, response6]

// Step 3: Execute third Promise.all
await Promise.all([f7()])
// → API call 7 → wait for completion
// Result: [response7]

// Final result from fromAsync
[
  [response1, response2, response3],
  [response4, response5, response6],
  [response7]
]

Practicing handling Promises as values using lazy evaluation and list processing will help you develop the ability to handle various asynchronous situations well (especially in backend development).

Lazy evaluation is not just a tool for performance improvement or optimization, but through code patterns that evaluate iterators and first-class functions at desired points, you can create reusable logic.

Handling Asynchrony with Types

AsyncIterator, AsyncIterable, AsyncGenerator

JavaScript provides protocols such as AsyncIterator, AsyncIterable, and AsyncGenerator to support sequential processing of asynchronous operations.

AsyncIterator, AsyncIterable interfaces

The following code expresses the structure of AsyncIterator, AsyncIterable, and AsyncGenerator through TypeScript interface definitions, simplified from the actual implementation:

typescript
// Means the AsyncIterator is not yet complete
interface IteratorYieldResult<T> {
  done?: false;
  value: T;
}

// Means the AsyncIterator is complete
interface IteratorReturnResult {
  done: true;
  value: undefined;
}

// An interface with a next method that returns a Promise
interface AsyncIterator<T> {
  next(): Promise<IteratorYieldResult<T> | IteratorReturnResult>;
}

interface AsyncIterable<T> {
  [Symbol.asyncIterator](): AsyncIterator<T>;
}

interface AsyncIterableIterator<T> extends AsyncIterator<T> {
  [Symbol.asyncIterator](): AsyncIterableIterator<T>;
}

Just as Iterables can be iterated with the for...of statement, AsyncIterables can be iterated asynchronously using the for await...of statement.

Basic AsyncGenerator syntax

AsyncGenerator provides functionality to generate values asynchronously and process them sequentially. The stringsAsyncTest shown in the example below is an AsyncGenerator that asynchronously generates strings:

typescript
async function* stringsAsyncTest(): AsyncIterableIterator<string> {
  yield delay(1000, 'a');
  const b = await delay(500, 'b') + 'c';
  yield b;
}

async function test() {
  const asyncIterator: AsyncIterableIterator<string> = stringsAsyncTest();
  const result1 = await asyncIterator.next();
  console.log(result1.value); // 'a' after about 1000ms

  const result2 = await asyncIterator.next();
  console.log(result2.value); // 'bc' after about 500ms

  const { done } = await asyncIterator.next();
  console.log(done); // true
}

await test();
}

toAsync function

The toAsync function converts a synchronous Iterable or an Iterable containing Promises into an AsyncIterable. Let's implement it in two ways:

  1. Directly implementing AsyncIterator:

    typescript
    function toAsync<T>(iterable: Iterable<T | Promise<T>>): AsyncIterable<Awaited<T>> {
      return {
        [Symbol.asyncIterator](): AsyncIterator<Awaited<T>> {
          const iterator = iterable[Symbol.iterator]();
          return {
            async next() {
              const { done, value } = iterator.next();
              return done ? { done, value } : { done, value: await value };
            }
          };
        }
      };
    }
  2. Implementing using AsyncGenerator:

    typescript
    async function* toAsync<T>(
      iterable: Iterable<T | Promise<T>>
    ): AsyncIterableIterator<Awaited<T>> {
      for await (const value of iterable) {
        yield value;
      }
    }

For toAsync, the AsyncGenerator approach is simpler, shorter, and more intuitive. In this case, the imperative approach is the most suitable among various paradigms, and choosing the appropriate paradigm for each problem provides better code writing and higher maintainability.

Higher-order functions for handling AsyncIterable

Let's create mapAsync, an AsyncIterable-handling version of map.

Using a generator, it can be implemented concisely as follows:

typescript
async function* mapAsync<A, B>(
  f: (a: A) => B,
  asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>> {
  for await (const value of asyncIterable) {
    yield f(value);
  }
}

Now let's create filterAsync, an AsyncIterable-handling version of filter:

typescript
async function* filterAsync<A>(
  f: (a: A) => boolean | Promise<boolean>,
  asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<A> {
  for await (const value of asyncIterable) {
    if (await f(value)) {
      yield value;
    }
  }
}

Protocol for making functions support both synchronous and asynchronous - toAsync

The toAsync function is a function that converts a regular Iterable or an Iterable composed of Promise values into an AsyncIterable.

map function supporting both synchronous and asynchronous

The existing map takes Iterable<A> as an argument, and mapAsync takes AsyncIterable<A> as an argument.

In TypeScript, one function can perform two or more roles through function overloading. Let's apply it right away:

Rename the existing map to mapSync, and replace it with a function called map that supports both mapSync and mapAsync.

typescript
function map<A, B>(
  f: (a: A) => B,
  iterable: Iterable<A>
): IterableIterator<B>;
function map<A, B>(
  f: (a: A) => B,
  asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>>;
function map<A, B>(
  f: (a: A) => B,
  iterable: Iterable<A> | AsyncIterable<A>
): IterableIterator<B> | AsyncIterableIterator<Awaited<B>> {
  return isIterable(iterable)
    ? mapSync(f, iterable)    // [iterable: Iterable<A>]
    : mapAsync(f, iterable);  // [iterable: AsyncIterable<A>]
}

This code actively utilizes TypeScript's overloading and type inference:

typescript
async function test() {
  // Synchronous array processing: mapSync
  console.log([...map(a => a * 10, [1, 2])]);
  // [10, 20]

  // Asynchronous iterable processing: mapAsync
  for await (const a of map(a => delay(100, a * 10), toAsync([1, 2]))) {
    console.log(a);
  }

  // Convert asynchronous iterable to array
  console.log(
    await fromAsync(map(a => delay(100, a * 10), toAsync([1, 2])))
  );

  // Process synchronous array asynchronously
  console.log(
    await Promise.all(map(a => delay(100, a * 10), [1, 2]))
  );
}

Asynchronous Error Handling

Due to the nature of asynchronous logic, it can be difficult to clearly identify where an error occurred and was executed. Therefore, effective error handling in asynchronous programming is essential.

Loading multiple images and calculating their heights

Let's say we're creating a feature that receives multiple image URLs, calculates the height of each image, and returns the sum. Here's a function that loads images asynchronously:

typescript
function loadImage(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.src = url;
    image.onload = function() {
      resolve(image);
    }
    image.onerror = function() {
      reject(new Error(`load error : ${url}`));
    }
  });
}

async function calcTotalHeight2(urls: string[]) {
  try {
    const totalHeight = await urls
      .map(async (url) => {
        const img = await loadImage(url);
        return img.height;
      })
      .reduce(
        async (a, b) => await a + await b,
        Promise.resolve(0)
      );
    return totalHeight;
  } catch (e) {
    console.error('error: ', e);
  }
}

This is a function that receives an array of URLs, loads them asynchronously, calculates the heights, and returns the total. If an error occurs, it's handled in the catch block. Although the code seems to work well, it has the following problems:

  • Unnecessary load: Even if an error occurs, it attempts to download all images for the remaining URLs.
  • Side effects: While this situation is simply a GET request, if you make POST or PUT requests in the same way, unnecessary requests can cause side effects.

This is code that may cause unnecessary requests or workflow progression due to insufficient consideration of error handling without a deep understanding of Promises and asynchronous situations.

Improved asynchronous logic

Let's make it stop requests immediately when an error occurs and prevent additional load:

typescript
async function calcTotalHeight(urls: string[]) {
  try {
    const totalHeight = await fx(urls)
      .toAsync()
      .map(loadImage)
      .map(img => img.height)
      .reduce((a, b) => a + b, 0);
    return totalHeight;
  } catch (e) {
    console.error('error: ', e);
  }
}

This approach processes one by one sequentially, so if an error occurs in the middle, it propagates and stops the remaining requests.

The key is to ensure errors occur properly

In asynchronous programming, what's important is not simply handling errors but designing so that errors occur properly. When you have some logic, instead of handling errors within that logic, let the caller detect and handle errors:

typescript
try {
  const height = await getTotalHeight(urls);
  // ...
} catch (e) {
  console.error(e);
}

This approach is advantageous for writing pure functions and managing side effects. Error handling is most effective when written close to the context where errors occur.

Also, letting the caller handle errors allows each caller to handle them according to their specific situation, providing flexibility.

Hiding errors is not always the best approach. Completely hiding errors makes it difficult to identify the cause of problems and can lead to unexpected behavior.

❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0