Unraveling Async/Await: A Journey from JavaScript Syntax to V8’s C++ Core

async and await in JavaScript have revolutionized asynchronous programming, making complex operations feel almost synchronous. But what magic truly lies beneath this elegant syntax? This article will embark on a comprehensive journey, dissecting async/await from its high-level usage down to its intricate C++ implementation within the V8 JavaScript engine.

1. The Familiar Face: async/await in Everyday Code

Let’s begin with the async/await code you’re likely familiar with:

async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data;
}

This code reads like a synchronous block, executing step-by-step. However, we know it’s inherently asynchronous, preventing the main thread from blocking. This deceptive simplicity is where the underlying mechanisms come into play.

2. The First Layer: Promises – The Core Abstraction

At its heart, async/await is merely “syntactic sugar” for Promises. Your async function, when transpiled or interpreted, closely resembles this Promise-based structure:

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${userId}`)
      .then(response => {
        return response.json();
      })
      .then(data => {
        resolve(data);
      })
      .catch(error => {
        reject(error);
      });
  });
}

Key Transformations:
* An async function automatically wraps its return value in a Promise.
* await expression effectively becomes a .then() call on the Promise returned by expression.
* A return value from an async function triggers resolve(value) for its implicit Promise.
* Any error thrown within an async function results in reject(error) for its implicit Promise.

While this clarifies the Promise connection, it doesn’t fully explain how the function “pauses” and “resumes” its execution flow at each await point. For that, we need to go deeper.

3. The Engine’s Secret Weapon: Generator Functions

The true mechanism enabling async/await‘s pause-and-resume capability is built upon generator functions. Conceptually, an async function is wrapped in a generator-like construct.

Consider this simplified representation:

function* fetchUserDataGenerator(userId) {
  const response = yield fetch(`/api/users/${userId}`);
  const data = yield response.json();
  return data;
}

// A simplified "runner" that consumes the generator
function asyncToGenerator(generatorFunc) {
  return function(...args) {
    const gen = generatorFunc(...args);

    return new Promise((resolve, reject) => {
      function step(nextFunc) {
        let result;
        try {
          result = nextFunc();
        } catch (e) {
          return reject(e);
        }

        if (result.done) {
          return resolve(result.value);
        }

        // Convert the yielded value to a Promise and continue
        Promise.resolve(result.value).then(
          value => step(() => gen.next(value)),
          error => step(() => gen.throw(error))
        );
      }
      step(() => gen.next());
    });
  };
}

const fetchUserData = asyncToGenerator(fetchUserDataGenerator);

How Generators Enable Pausing:
* yield keyword: Allows a generator function to temporarily suspend its execution, returning a value to its caller, and relinquishing control.
* .next() method: When called, it resumes the generator’s execution from where it left off, optionally injecting a value back into the generator at the yield point.
* State Maintenance: Generators inherently preserve their local state across yield calls, allowing variables and execution context to be restored seamlessly.

This generator-based model effectively provides the “pausing” and “resuming” required for await.

4. The JavaScript Runtime: Event Loop and Microtasks

When an await expression is encountered, it interacts directly with the JavaScript runtime’s event loop and its crucial microtask queue.

Here’s the sequence of events:

  1. await is hit: The Promise being awaited is not yet settled (resolved or rejected).
  2. Function Suspends: The async function’s execution is paused.
  3. Control Returns: Control is yielded back to the JavaScript event loop, allowing other synchronous code or macro-tasks to run.
  4. Promise Settles: When the awaited Promise eventually resolves or rejects, a microtask is enqueued.
  5. Microtask Execution: The event loop, after exhausting all synchronous code and before processing the next macro-task (like setTimeout), processes all pending microtasks. The enqueued microtask, representing the continuation of your async function, then executes.
  6. Function Resumes: The async function’s execution continues from the point of suspension.

Illustrative Example with Microtasks:

console.log('1: Synchronous');

async function example() {
  console.log('2: Async function start');
  await Promise.resolve(); // Awaiting an already resolved promise
  console.log('4: After await (Microtask)');
}

example();

console.log('3: Synchronous end');

// Output:
// 1: Synchronous
// 2: Async function start
// 3: Synchronous end
// 4: After await (Microtask)

This output clearly demonstrates that the code after await ('4: After await') is pushed to the microtask queue and executes after all other synchronous code has completed.

5. Diving Deep into V8’s C++ Implementation

Now, let’s peek under the hood of V8, Google Chrome’s JavaScript engine, where async/await is implemented in C++.

A. Async Function Objects

In V8, an async function is represented by an internal JSAsyncFunctionObject. When such a function is invoked, V8 performs several setup steps:

// Simplified from v8/src/builtins/builtins-async-gen.cc

// When entering an async function, V8:
BUILTIN(AsyncFunctionEnter) {
  // 1. Creates a new promise (this is the promise the async function will return)
  Handle<JSPromise> promise = factory->NewJSPromise();

  // 2. Creates a generator-like context to store the function's internal state
  Handle<Context> context = factory->NewAsyncFunctionContext();

  // 3. Stores the newly created promise within this context for later resolution
  context->set_extension(*promise);

  return *promise; // Returns the implicit promise
}

B. The Await Implementation

The await keyword itself is handled by a dedicated Await builtin in V8:

// Simplified from v8/src/builtins/builtins-async-gen.cc

BUILTIN(AsyncFunctionAwait) {
  // 1. Retrieves the value that is being awaited
  Handle<Object> value = args.at(1);

  // 2. Gets the implicit promise associated with the current async function
  Handle<JSAsyncFunctionObject> async_function_object = args.at(0);
  Handle<JSPromise> outer_promise = async_function_object->promise();

  // 3. Creates an internal Promise for the awaited value
  Handle<JSPromise> promise = factory->NewJSPromiseWithoutHook();

  // 4. Resolves this internal promise with the awaited value
  ResolvePromise(isolate, promise, value);

  // 5. Creates 'PromiseReaction' objects (these are the continuation functions)
  Handle<JSFunction> on_fulfilled = CreateAsyncFunctionResumeFunction(); // For success
  Handle<JSFunction> on_rejected = CreateAsyncFunctionRejectFunction(); // For error

  // 6. Attaches these reactions to the internal promise.
  // When the internal promise settles, these reactions will be triggered.
  PerformPromiseThen(isolate, promise, on_fulfilled, on_rejected, outer_promise);

  // 7. Suspends the execution of the current async function and returns control
  // to the event loop. This effectively yields 'undefined'.
  return ReadOnlyRoots(isolate).undefined_value();
}

C. Resuming After Await

When the internal promise for the await expression resolves, V8 enqueues a microtask:

// Simplified from v8/src/objects/js-promise.cc

void EnqueueMicrotask(Isolate* isolate, Handle<Microtask> microtask) {
  // 1. Gets the default microtask queue
  Handle<MicrotaskQueue> queue = isolate->default_microtask_queue();

  // 2. Adds the continuation microtask to the queue
  queue->EnqueueMicrotask(*microtask);

  // 3. Notifies the system that there are pending microtasks to run
  isolate->SetHasPendingMicrotasks();
}

// When the event loop decides it's time to run microtasks:
void RunMicrotasks(Isolate* isolate) {
  // ...
  MicrotaskQueue* queue = isolate->default_microtask_queue();

  while (queue->size() > 0) {
    Microtask* microtask = queue->Dequeue(); // Dequeue a microtask
    microtask->Run(isolate); // Execute the microtask
  }
}

D. Generator State Machine

V8 maintains a state machine for each async function instance to manage its execution flow:

enum class AsyncFunctionState {
  kSuspendedStart,    // Initial state, before the first await
  kSuspendedYield,    // Function is paused at an await point
  kExecuting,         // Function is currently running
  kCompleted          // Function has finished execution
};

// Internal structure storing async function data in its context
struct AsyncFunctionData {
  AsyncFunctionState state;       // Current state
  int resume_offset;              // Bytecode offset where execution should resume
  Handle<Object> awaited_value;   // The value resolved from the awaited promise
  Handle<JSPromise> promise;      // The implicit return promise of the async function
};

E. Bytecode Level

V8’s Ignition interpreter generates specialized bytecode for async functions to manage suspension and resumption:

// Simplified bytecode for: await somePromise()

SuspendGenerator       // Saves the current execution state and yields control
Return                 // Temporarily exits the function

// --- Execution resumes here when the awaited promise resolves ---
ResumeGenerator        // Restores the saved state
GetGeneratorResumeValue // Retrieves the resolved value from the awaited promise
// Continues with the next operations in the function...

6. Piecing It All Together: A Complete Example Trace

Let’s trace a simple async function:

async function example() {
  console.log('Before await');
  const result = await Promise.resolve(42); // Awaiting an immediately resolved promise
  console.log('After await:', result);
  return result * 2;
}

const promise = example();
console.log('Function called');

Step-by-Step Execution:

  1. example() is called:
    • V8 creates a JSAsyncFunctionObject.
    • An implicit return Promise is created for example().
    • The async function begins synchronous execution.
  2. console.log('Before await'):
    • This executes immediately, printing “Before await”.
  3. await Promise.resolve(42) is hit:
    • V8’s AsyncFunctionAwait builtin is invoked.
    • An internal Promise for Promise.resolve(42) is created and immediately resolved with 42.
    • Promise reaction (continuation) functions are created.
    • The example() function’s state is saved, its execution is suspended, and control returns to the event loop.
  4. console.log('Function called'):
    • The main thread continues, printing “Function called”.
  5. Awaited Promise Resolves:
    • The internal Promise (from Promise.resolve(42)) resolves, triggering its reactions.
    • V8 enqueues a microtask that represents the continuation of example(), with 42 as the resolved value.
  6. Microtask Queue Runs:
    • After the main script completes, the event loop processes its microtask queue.
    • The enqueued microtask runs, calling the continuation function.
    • V8 restores the example() function’s state and resumes its execution from the await point, injecting 42 as the result.
  7. console.log('After await:', result):
    • This now executes, printing “After await: 42”.
  8. return result * 2:
    • The function completes, resolving its implicit return Promise with 84.

7. Performance Implications

Understanding these layers offers insights into writing more performant async/await code:

  • Redundant await:
    // ❌ Slower (creates an unnecessary extra promise and microtask)
    async function slow() {
      return await someAsyncOperation();
    }
    
    // ✅ Faster (returns the promise directly)
    async function fast() {
      return someAsyncOperation();
    }
    
  • Sequential vs. Parallel Operations:
    // ❌ Sequential (slower if operations are independent)
    async function sequential() {
      const a = await fetchA();
      const b = await fetchB();
      return [a, b];
    }
    
    // ✅ Parallel (faster for independent operations)
    async function parallel() {
      const [a, b] = await Promise.all([fetchA(), fetchB()]);
      return [a, b];
    }
    

Key Takeaways

  • Syntactic Sugar: async/await is a high-level abstraction built upon Promises and generator functions.
  • Generators for Pause/Resume: Generator functions are the underlying mechanism enabling the ability to suspend and resume function execution.
  • Event Loop & Microtasks: The JavaScript event loop and its microtask queue are crucial for scheduling the resumption of async functions after await.
  • V8’s C++ Implementation: V8 manages async functions with specialized C++ objects, builtins, and bytecode, meticulously tracking state and scheduling continuations.
  • Overhead Considerations: Each await introduces some overhead (promise wrapping, microtask enqueueing), which can have minor performance implications in highly sensitive scenarios.

A deeper understanding of these layers empowers you to write more efficient, robust, and debuggable asynchronous JavaScript code.

Further Reading

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed