Generators and Async Functions: How V8 Suspends a Function

V8 suspends a generator by spilling its register file into a heap object, and resumes it by jumping back into the function with a hand-written assembly trampoline that abuses new.target.

TL;DR: A generator in V8 is not a stack. It is a heap object - JSGeneratorObject - that holds a FixedArray containing the function’s parameters and its whole register file. SuspendGenerator spills that array into the object and returns to the caller; ResumeGenerator imports it back. The resume itself is a hand-written assembly trampoline that jumps into the function’s entry point with the generator object smuggled in as new.target. Async functions are the same machinery plus a JSPromise, and await is a suspend point whose continuation is a promise reaction.

JavaScript has a feature that most other languages do not: a function that can be stopped halfway through, keep all of its local state, and be picked up later - possibly in a completely different call stack. Generators and async functions are that feature.

The naive implementation is to keep the machine stack alive and just not return from the function. Every real engine throws that idea away, because you cannot hold a C stack across an event loop turn. V8’s answer is elegant and slightly surprising: the frame is not on the stack at all while the generator is suspended. It is in a heap array.

A generator is an object, not a stack

The whole state of a suspended generator is one object:

torque
// src/objects/js-generator.tq:15
@cppObjectLayoutDefinition
extern class JSGeneratorObject extends JSObject {
  function: JSFunction;
  context: Context;
  receiver: JSAny;

  // For executing generators: the most recent input value.
  // For suspended generators: debug information (bytecode offset).
  // There is currently no need to remember the most recent input value for a
  // suspended generator.
  input_or_debug_pos: Object;

  // The most recent resume mode.
  resume_mode: Smi;

  // A positive value indicates a suspended generator.  The special
  // kGeneratorExecuting and kGeneratorClosed values indicate that a generator
  // cannot be resumed.
  continuation: Smi;

  // Saved interpreter register file.
  parameters_and_registers: FixedArray;

  yielded_value: JSAny;
}

Read that last comment again: Saved interpreter register file. That is the whole trick. The generator object is not a closure over the locals; it is a heap copy of the machine frame’s register file, plus the context chain, plus the receiver.

The continuation field is a tri-state Smi. A positive value is the index of the suspend point the generator is parked at; two negative sentinels mean “currently running” and “finished”:

cpp
// src/objects/js-generator.h:71
  // Magic sentinel values for the continuation.
  static const int kGeneratorExecuting = -2;
  static const int kGeneratorClosed = -1;

and the resume mode is a four-way enum - next, return, throw, and the internal rethrow used by async generators:

cpp
// src/objects/js-generator.h:26
  enum ResumeMode { kNext, kReturn, kThrow, kRethrow };

The object is created by a runtime call at the top of the function body. The register file array is sized from the bytecode’s frame size, so it is allocated once and reused for every suspend:

cpp
// src/runtime/runtime-generator.cc:36
RUNTIME_FUNCTION(Runtime_CreateJSGeneratorObject) {
  HandleScope scope(isolate);
  DCHECK_EQ(2, args.length());
  DirectHandle<JSFunction> function = args.at<JSFunction>(0);
  DirectHandle<JSAny> receiver = args.at<JSAny>(1);
  CHECK_IMPLIES(IsAsyncFunction(function->shared()->kind()),
                IsAsyncGeneratorFunction(function->shared()->kind()));
  CHECK(IsResumableFunction(function->shared()->kind()));

  // Underlying function needs to have bytecode available.
  DCHECK(function->shared()->HasBytecodeArray());
  int length;
  {
    Tagged<BytecodeArray> bytecode =
        function->shared()->GetBytecodeArray(isolate);

    length = bytecode->parameter_count_without_receiver() +
             bytecode->register_count();
  }
  DirectHandle<FixedArray> parameters_and_registers =
      isolate->factory()->NewFixedArray(length);

parameter_count_without_receiver() + register_count(). That is the exact number of slots in the frame: the parameters, then the temporaries.

Two bytecodes, and a jump table

Ignition has exactly three generator-specific bytecodes:

cpp
// src/interpreter/bytecodes.h:495
  /* Generators */
  V(SwitchOnGeneratorState, ImplicitRegisterUse::kNone, OperandType::kReg,
    OperandType::kConstantPoolIndex, OperandType::kUImm)
  V(SuspendGenerator, ImplicitRegisterUse::kReadAccumulator,
    OperandType::kReg, OperandType::kRegList, OperandType::kRegCount,
    OperandType::kUImm)
  V(ResumeGenerator, ImplicitRegisterUse::kWriteAccumulator,
    OperandType::kReg, OperandType::kRegOutList, OperandType::kRegCount)

The bytecode generator emits SwitchOnGeneratorState in the function prologue, before anything else:

cpp
// src/interpreter/bytecode-generator.cc:2078
void BytecodeGenerator::BuildGeneratorPrologue() {
  DCHECK_GT(info()->literal()->suspend_count(), 0);
  generator_jump_table_ =
      builder()->AllocateJumpTable(info()->literal()->suspend_count(), 0);

  // If the generator is not undefined, this is a resume, so perform state
  // dispatch.
  builder()->SwitchOnGeneratorState(generator_object(), generator_jump_table_);

  // Otherwise, fall-through to the ordinary function prologue, after which we
  // will run into the generator object creation and other extra code inserted
  // by the parser.
}

The jump table is sized by the number of yield/await sites in the function, and each entry is a relative jump offset into the constant pool. So a generator’s resume dispatch is a single load and a jump - no chain of comparisons.

The interpreter’s handler for it reads the continuation field, marks the generator as running, restores the saved context, and jumps:

cpp
// src/interpreter/interpreter-generator.cc:3581
IGNITION_HANDLER(SwitchOnGeneratorState, InterpreterAssembler) {
  TNode<Object> maybe_generator = LoadRegisterAtOperandIndex(0);

  Label fallthrough(this);
  GotoIf(TaggedEqual(maybe_generator, UndefinedConstant()), &fallthrough);

  TNode<JSGeneratorObject> generator = CAST(maybe_generator);

  TNode<Smi> state = CAST(
      LoadObjectField(generator, offsetof(JSGeneratorObject, continuation_)));
  TNode<Smi> new_state = SmiConstant(JSGeneratorObject::kGeneratorExecuting);
  StoreObjectField(generator, offsetof(JSGeneratorObject, continuation_),
                   new_state);

  TNode<Context> context =
      CAST(LoadObjectField(generator, offsetof(JSGeneratorObject, context_)));
  SetContext(context);
  ...
  TNode<IntPtrT> case_value = SmiUntag(state);
  ...
  TNode<WordT> entry = IntPtrAdd(table_start, case_value);
  TNode<Object> constant_entry = LoadConstantPoolEntry(entry);
  CSA_SBXCHECK(this, TaggedIsSmi(constant_entry));
  TNode<IntPtrT> relative_jump = SmiUntag(CAST(constant_entry));
  Jump(relative_jump);

Note the two side effects: continuation is overwritten with kGeneratorExecuting (so a re-entrant next() on a running generator throws), and the saved context becomes the current context - which is exactly how a generator “remembers” its lexical scope after the original call has returned.

Suspending: writing the frame out

yield compiles to SuspendGenerator followed by a bind to the resume label:

cpp
// src/interpreter/bytecode-generator.cc:6112
void BytecodeGenerator::BuildSuspendPoint(int position) {
  // Because we eliminate jump targets in dead code, we also eliminate resumes
  // when the suspend is not emitted because otherwise the below call to Bind
  // would start a new basic block and the code would be considered alive.
  if (builder()->RemainderOfBlockIsDead()) {
    return;
  }
  const int suspend_id = suspend_count_++;

  RegisterList registers = register_allocator()->AllLiveRegisters();

  // Save context, registers, and state. This bytecode then returns the value
  // in the accumulator.
  builder()->SetExpressionPosition(position);
  builder()->SuspendGenerator(generator_object(), registers, suspend_id);

  // Upon resume, we continue here.
  builder()->Bind(generator_jump_table_, suspend_id);

  // Clobbers all registers and sets the accumulator to the
  // [[input_or_debug_pos]] slot of the generator object.
  builder()->ResumeGenerator(generator_object(), registers);
}

suspend_id is a dense integer: the n-th yield in the function gets id n. That is what goes into continuation and what indexes the jump table.

The interpreter handler writes the register file into the object’s FixedArray, and stores the context and the suspend id:

cpp
// src/interpreter/interpreter-generator.cc:3552
IGNITION_HANDLER(SuspendGenerator, InterpreterAssembler) {
  TNode<JSGeneratorObject> generator = CAST(LoadRegisterAtOperandIndex(0));
  TNode<FixedArray> array = CAST(LoadObjectField(
      generator, offsetof(JSGeneratorObject, parameters_and_registers_)));
  TNode<Context> context = GetContext();
  RegListNodePair registers = GetRegisterListAtOperandIndex(1);
  TNode<Smi> suspend_id = BytecodeOperandUImmSmi(3);

  ExportParametersAndRegisterFile(array, registers);
  StoreObjectField(generator, offsetof(JSGeneratorObject, context_), context);
  StoreObjectField(generator, offsetof(JSGeneratorObject, continuation_),
                   suspend_id);

  // Store the bytecode offset in the [input_or_debug_pos] field, to be used by
  // the inspector.
  TNode<Smi> offset = SmiTag(BytecodeOffset());
  StoreObjectField(generator, offsetof(JSGeneratorObject, input_or_debug_pos_),
                   offset);

  Return(GetAccumulator());
}

Here is a detail worth pausing on: the layout of the array is a contract between the interpreter and the optimizing compiler. The parameters occupy slots 0..p-1 and register i occupies slot p+i, and the source says so explicitly:

cpp
// src/interpreter/interpreter-assembler.cc:1688
  {
    // Iterate over register file and write values into array.
    // The mapping of register to array index must match that used in
    // BytecodeGraphBuilder::VisitResumeGenerator.
    TVARIABLE(IntPtrT, var_index);
    var_index = IntPtrConstant(0);

    Label loop(this, &var_index), done_loop(this);
    Goto(&loop);
    BIND(&loop);
    {
      TNode<IntPtrT> index = var_index.value();
      GotoIfNot(UintPtrLessThan(index, register_count), &done_loop);

      TNode<IntPtrT> reg_index =
          IntPtrSub(IntPtrConstant(Register(0).ToOperand()), index);
      TNode<Object> value = LoadRegister(reg_index);

      TNode<IntPtrT> array_index = IntPtrAdd(parameter_count, index);
      StoreFixedArrayElement(array, array_index, value);

That comment is not decoration. A generator can be suspended by Ignition and resumed by TurboFan - the function tier-ups while it is parked at a yield, and the optimized code has to be able to read the array the interpreter wrote. Two independently written copies of the generator frame, in two compilers, have to agree byte for byte, or a generator breaks the first time it is optimized mid-suspension.

Resuming: reading the frame back in

The mirror image, on the way back in:

cpp
// src/interpreter/interpreter-assembler.cc:1718
TNode<FixedArray> InterpreterAssembler::ImportRegisterFile(
    TNode<FixedArray> array, const RegListNodePair& registers) {
  ...
  // Iterate over array and write values into register file.  Also erase the
  // array contents to not keep them alive artificially.
  Label loop(this, &var_index), done_loop(this);
  Goto(&loop);
  BIND(&loop);
  {
    TNode<IntPtrT> index = var_index.value();
    GotoIfNot(UintPtrLessThan(index, register_count), &done_loop);

    TNode<IntPtrT> array_index = IntPtrAdd(parameter_count, index);
    TNode<Object> value = LoadFixedArrayElement(array, array_index);

    TNode<IntPtrT> reg_index =
        IntPtrSub(IntPtrConstant(Register(0).ToOperand()), index);
    StoreRegister(value, reg_index);

    StoreFixedArrayElement(array, array_index, StaleRegisterConstant());

Two things here. First, the array is written with a stale register constant as it is consumed, so a generator that is resumed and then suspended again does not leave stale references to old objects lying around in the array. It is a manual clear of the slots as they are read. Second, the same slot numbering is used on the way in as on the way out, so the two routines are exact inverses.

The ResumeGenerator bytecode itself then just imports the file and sets the accumulator to the value that was sent in:

cpp
// src/interpreter/interpreter-generator.cc:3625
IGNITION_HANDLER(ResumeGenerator, InterpreterAssembler) {
  TNode<JSGeneratorObject> generator = CAST(LoadRegisterAtOperandIndex(0));
  RegListNodePair registers = GetRegisterListAtOperandIndex(1);

  ImportRegisterFile(
      CAST(LoadObjectField(
          generator, offsetof(JSGeneratorObject, parameters_and_registers_))),
      registers);

  // Return the generator's input_or_debug_pos in the accumulator.
  SetAccumulator(LoadObjectField(
      generator, offsetof(JSGeneratorObject, input_or_debug_pos_)));

  Dispatch();
}

next(): the trampoline

So far everything has been “the generator body resumes itself”. But the usual case is that user code calls gen.next(). That is a builtin, and it works like this:

cpp
// src/builtins/builtins-generator-gen.cc:49
void GeneratorBuiltinsAssembler::InnerResume(
    CodeStubArguments* args, TNode<JSGeneratorObject> receiver,
    TNode<Object> value, TNode<Context> context,
    JSGeneratorObject::ResumeMode resume_mode, char const* const method_name) {
  // Check if the {receiver} is running or already closed.
  TNode<Smi> receiver_continuation = LoadObjectField<Smi>(
      receiver, offsetof(JSGeneratorObject, continuation_));
  Label if_receiverisclosed(this, Label::kDeferred),
      if_receiverisrunning(this, Label::kDeferred);
  TNode<Smi> closed = SmiConstant(JSGeneratorObject::kGeneratorClosed);
  GotoIf(SmiEqual(receiver_continuation, closed), &if_receiverisclosed);
  DCHECK_LT(JSGeneratorObject::kGeneratorExecuting,
            JSGeneratorObject::kGeneratorClosed);
  GotoIf(SmiLessThan(receiver_continuation, closed), &if_receiverisrunning);

  // Remember the {resume_mode} for the {receiver}.
  StoreObjectFieldNoWriteBarrier(receiver,
                                 offsetof(JSGeneratorObject, resume_mode_),
                                 SmiConstant(resume_mode));

  // Resume the {receiver} using our trampoline.
  // Close the generator if there was an exception.
  TVARIABLE(Object, var_exception);
  Label if_exception(this, Label::kDeferred), if_final_return(this);
  TNode<JSAny> result;
  {
    compiler::ScopedExceptionHandler handler(this, &if_exception,
                                             &var_exception);
    result = CallBuiltin<JSAny>(Builtin::kResumeGeneratorTrampoline, context,
                                value, receiver);
  }

The continuation value doubles as the state machine: it is negative when running or closed, and positive when suspended at a yield point. So one comparison tells you “closed”, another tells you “running”, and otherwise you are suspended and can be resumed.

The trampoline itself is hand-written assembly, one implementation per architecture. The ARM64 version is the clearest:

cpp
// src/builtins/arm64/builtins-arm64.cc:566
  // Resume (Ignition/TurboFan) generator object.
  {
    // We abuse new.target both to indicate that this is a resume call and to
    // pass in the generator object.  In ordinary calls, new.target is always
    // undefined because generator functions are non-constructable.
    __ Mov(x3, x1);  // new.target
    __ Mov(x1, x5);  // target
    // We jump through x17 here because for Branch Identification (BTI) we use
    // "Call" (`bti c`) rather than "Jump" (`bti j`) landing pads for
    // tail-called code. See TailCallBuiltin for more information.
    DCHECK_NE(code, x17);
    __ Mov(x17, code);
    // Actual arguments count and code start are already initialized above.
    __ Jump(x17);
  }

“We abuse new.target.” A generator function cannot be constructed, so new.target is always undefined in a normal call - which makes it a free channel to smuggle the generator object into the function’s prologue. The prologue then sees a non-undefined new.target and knows this is a resume rather than a first call. That is exactly what SwitchOnGeneratorState checks:

cpp
// src/interpreter/bytecode-generator.cc:2056
    // Either directly use generator_object_var or allocate a new register for
    // the incoming generator object.
    Variable* generator_object_var = closure_scope()->generator_object_var();
    if (generator_object_var->location() == VariableLocation::LOCAL) {
      incoming_new_target_or_generator_ =
          GetRegisterForLocalVariable(generator_object_var);

Before jumping, the trampoline also copies the parameters out of the generator’s register file and back onto the stack, because the callee expects them there:

cpp
// src/builtins/arm64/builtins-arm64.cc:524
  // Copy the function arguments from the generator object's register file.
  {
    Label loop, done;
    __ Sub(x10, argc, kJSArgcReceiverSlots);
    __ Cbz(x10, &done);
    __ LoadTaggedField(
        x6, FieldMemOperand(
                x1, offsetof(JSGeneratorObject, parameters_and_registers_)));

Optimized code does the same thing, differently

TurboFan does not have a “generator frame”. It has nodes. The bytecode graph builder turns SuspendGenerator into a JSGeneratorStore node with one input per live value:

cpp
// src/compiler/bytecode-graph-builder.cc:4061
void BytecodeGraphBuilder::VisitSuspendGenerator() {
  Node* generator =
      environment()->LookupRegister(bytecode_iterator().GetRegisterOperand(0));
  interpreter::Register first_reg = bytecode_iterator().GetRegisterOperand(1);
  // We assume we are storing a range starting from index 0.
  CHECK_EQ(0, first_reg.index());
  int register_count =
      static_cast<int>(bytecode_iterator().GetRegisterCountOperand(2));
  int parameter_count_without_receiver = bytecode_array().parameter_count() - 1;
  ...
  // Maybe overallocate the value list since we don't know how many registers
  // are live.
  // TODO(leszeks): We could get this count from liveness rather than the
  // register list.
  int value_input_count = 3 + parameter_count_without_receiver + register_count;

Note “we don’t know how many registers are live” - and then the liveness analysis fills the dead slots with an optimised-out constant:

cpp
// src/compiler/bytecode-graph-builder.cc:4101
  // Store the registers.
  for (int i = 0; i < register_count; ++i) {
    if (liveness == nullptr || liveness->RegisterIsLive(i)) {
      int index_in_parameters_and_registers =
          parameter_count_without_receiver + i;
      while (count_written < index_in_parameters_and_registers) {
        value_inputs[3 + count_written++] = jsgraph()->OptimizedOutConstant();
      }
      value_inputs[3 + count_written++] =
          environment()->LookupRegister(interpreter::Register(i));

So in optimized code a generator stores only its live registers, and the dead ones are written as an optimised-out constant so the array keeps the same layout the interpreter uses. The resume side mirrors it:

cpp
// src/compiler/bytecode-graph-builder.cc:4198
void BytecodeGraphBuilder::VisitResumeGenerator() {
  ...
  // Mapping between registers and array indices must match that used in
  // InterpreterAssembler::ExportParametersAndRegisterFile.
  for (int i = 0; i < environment()->register_count(); ++i) {
    if (liveness == nullptr || liveness->RegisterIsLive(i)) {
      Node* value = NewNode(javascript()->GeneratorRestoreRegister(
                                parameter_count_without_receiver + i),
                            generator);
      environment()->BindRegister(interpreter::Register(i), value);
    }
  }

and typed lowering turns GeneratorRestoreRegister into a plain array load plus a stale-store, exactly like the interpreter:

cpp
// src/compiler/js-typed-lowering.cc:2607
Reduction JSTypedLowering::ReduceJSGeneratorRestoreRegister(Node* node) {
  DCHECK_EQ(IrOpcode::kJSGeneratorRestoreRegister, node->opcode());
  Node* generator = NodeProperties::GetValueInput(node, 0);
  ...
  Node* array = effect = graph()->NewNode(simplified()->LoadField(array_field),
                                          generator, effect, control);
  Node* element = effect = graph()->NewNode(
      simplified()->LoadField(element_field), array, effect, control);
  Node* stale = jsgraph()->StaleRegisterConstant();
  effect = graph()->NewNode(simplified()->StoreField(element_field), array,
                            stale, effect, control);

The inlining TurboFan actually does

TurboFan does not inline generator bodies. What it inlines is Generator.prototype.next itself: the whole “am I closed? am I running? set the resume mode, call the trampoline, wrap the result” dance becomes inline graph nodes, with a deoptimisation guard for the megamorphic case:

cpp
// src/compiler/js-call-reducer.cc:7205
Reduction JSCallReducer::ReduceGeneratorPrototypeNext(Node* node) {
  ...
  if (can_use_static_maps) {
    inference.RelyOnMapsPreferStability(dependencies(), jsgraph(), &effect,
                                        control, p.feedback());
  } else {
    // If we have no reliable map feedback (e.g. megamorphic next() calls),
    // we can still inline the generator resume by emitting a dynamic
    // instance type check, and deoptimizing if it's not a generator.

    // 1. Check if receiver is a Smi.
    Node* is_smi = graph()->NewNode(simplified()->ObjectIsSmi(), receiver);

and then, still inline:

cpp
// src/compiler/js-call-reducer.cc:7345
  // Emit Call to ResumeGeneratorTrampoline
  Callable callable =
      Builtins::CallableFor(isolate(), Builtin::kResumeGeneratorTrampoline);
  CallDescriptor* descriptor = Linkage::GetStubCallDescriptor(
      graph()->zone(), callable.descriptor(),
      callable.descriptor().GetStackParameterCount(),
      CallDescriptor::kNeedsFrameState);

So the call is inlined; the body is not, because the body lives on the other side of a suspend point.

Async functions are generators with a promise

An async function is the same object with two extra fields:

torque
// src/objects/js-generator.tq:41
@cppObjectLayoutDefinition
extern class JSAsyncFunctionObject extends JSGeneratorObject {
  promise: JSPromise;
  // Resolve/reject closures for await, reused across all await expressions.
  // Initialized to undefined, lazily allocated on first await.
  await_resolve_closure: JSFunction|Undefined;
  await_reject_closure: JSFunction|Undefined;
}

It is created by a builtin that allocates the promise first, then the generator object that owns it:

cpp
// src/builtins/builtins-async-function-gen.cc:85
  // Allocate and initialize the register file.
  TNode<FixedArrayBase> parameters_and_registers =
      AllocateFixedArray(HOLEY_ELEMENTS, parameters_and_register_length);
  FillFixedArrayWithValue(HOLEY_ELEMENTS, parameters_and_registers,
                          IntPtrConstant(0), parameters_and_register_length,
                          RootIndex::kUndefinedValue);

  // Allocate and initialize the promise.
  TNode<JSPromise> promise = NewJSPromise(context);

  // Allocate and initialize the async function object.
  TNode<NativeContext> native_context = LoadNativeContext(context);
  TNode<Map> async_function_object_map = CAST(LoadContextElementNoCell(
      native_context, Context::ASYNC_FUNCTION_OBJECT_MAP_INDEX));
  TNode<JSAsyncFunctionObject> async_function_object =
      UncheckedCast<JSAsyncFunctionObject>(
          AllocateInNewSpace(sizeof(JSAsyncFunctionObject)));

The promise is allocated eagerly, before the body runs, which is what makes async function f() { throw x } return a rejected promise rather than throw synchronously.

The body is compiled inside a try/catch whose handler rejects the promise:

cpp
// src/interpreter/bytecode-generator.cc:1899
void BytecodeGenerator::GenerateAsyncFunctionBody() {
  ...
  // Async functions always return promises. Return values fulfill that promise,
  // while synchronously thrown exceptions reject that promise. This is handled
  // by surrounding the body statements in a try-catch block as follows:
  //
  // try {
  //   <inner_block>
  // } catch (.catch) {
  //   return %_AsyncFunctionReject(.generator_object, .catch);
  // }
  ...
      builder()
          .MoveRegister(generator_object(), args[0])
          .StoreAccumulatorInRegister(args[1]);  // exception
        if (!literal->scope()->is_repl_mode_scope()) {
          builder()->LoadTheHole().SetPendingMessage();
        }
        builder()->CallRuntime(Runtime::kInlineAsyncFunctionReject, args);

Note HandlerTable::ASYNC_AWAIT instead of UNCAUGHT as the catch prediction - async functions report top-level exceptions as promise rejections, not as uncaught exceptions, so the debugger’s “uncaught exception” pause must not fire.

await is a suspend plus a promise reaction

The last piece: what await compiles to. The bytecode generator emits a runtime call, then a suspend point, then a dispatch on the resume mode:

cpp
// src/interpreter/bytecode-generator.cc:6473
void BytecodeGenerator::BuildAwait(int position) {
  ...
    RegisterList args = register_allocator()->NewRegisterList(2);
    builder()
        ->MoveRegister(generator_object(), args[0])
        .StoreAccumulatorInRegister(args[1])
        .CallRuntime(await_intrinsic_id, args);
  }

  BuildSuspendPoint(position);

  Register input = register_allocator()->NewRegister();
  Register resume_mode = register_allocator()->NewRegister();

  // Now dispatch on resume mode.
  BytecodeLabel resume_next;
  builder()
      .StoreAccumulatorInRegister(input)
      .CallRuntime(Runtime::kInlineGeneratorGetResumeMode, generator_object())
      .StoreAccumulatorInRegister(resume_mode)
      .LoadLiteral(Smi::FromInt(JSGeneratorObject::kNext))
      .CompareReference(resume_mode)
      .JumpIfTrue(ToBooleanMode::kAlreadyBoolean, &resume_next);

  // Resume with "throw" completion (rethrow the received value).
  builder()->LoadAccumulatorWithRegister(input).ReThrow();

  // Resume with next.
  builder()->Bind(&resume_next);
  builder()->LoadAccumulatorWithRegister(input);

So the awaited value arrives either as a normal value (resume mode next) or as a thrown exception (resume mode throw). The runtime call before the suspend point is what sets up the continuation.

The AsyncFunctionAwait builtin has a fast path that skips the whole promise machinery when the awaited value is an already-fulfilled native promise:

cpp
// src/builtins/builtins-async-function-gen.cc:226
  // Fast path: if value is an already-fulfilled JSPromise with intact species
  // protector and no hooks/debug active, skip closures entirely and enqueue
  // an AsyncResumeTask directly.  This eliminates the AwaitContext, closure
  // allocation, PromiseFulfillReactionJobTask allocation, and the indirect
  // closure dispatch (AsyncFunctionAwaitResolveClosure).
  Label slow_path(this);
  {
    // Must be a heap object.
    GotoIf(TaggedIsSmi(value), &slow_path);
    TNode<HeapObject> value_obj = CAST(value);
    ...
    // value must be a JSPromise on the canonical initial map for this
    // native context.  Checking the initial map (rather than just the
    // instance type) guarantees:
    //   - no custom "constructor" property on the instance, and
    //   - the promise was created in the current native context (initial
    //     maps are per-context), so microtasks enqueue on the right queue.

and then enqueues a specialised AsyncResumeTask - a microtask that knows how to resume the generator directly, with no closure in between:

cpp
// src/builtins/builtins-async-function-gen.cc:279
    // Extract the fulfilled value.
    TNode<Object> fulfilled_value =
        LoadObjectField(js_promise, offsetof(JSPromise, reactions_or_result_));

    EnqueueAsyncResumeTask(native_context, async_function_object,
                           fulfilled_value,
                           AsyncResumeTask::kAsyncFunctionAwait);
    Return(outer_promise);

The slow path allocates two closures - resolve and reject - that will be called when the awaited promise settles. And here is a nice detail: they are allocated once and reused for every await in the function, because the async function object has slots for them:

cpp
// src/builtins/builtins-async-gen.cc:326
TNode<Object> AsyncBuiltinsAssembler::AwaitWithReusableClosures(
    TNode<Context> context, TNode<JSAsyncFunctionObject> async_function_object,
    TNode<Object> value, TNode<JSPromise> outer_promise) {
  return Await(
      context, async_function_object, value, outer_promise,
      [&](TNode<NativeContext> native_context) {
        // Lazily allocate closures on first await, then reuse them for
        // subsequent awaits.

and the initialisation comment says why they are not allocated eagerly:

cpp
// src/builtins/builtins-async-function-gen.cc:138
  // Initialize closure fields to undefined. They will be lazily allocated
  // on first await. This saves memory for async functions that never suspend
  // (e.g., conditional awaits, early returns).

The await context itself is a tiny synthetic context holding one slot - the generator object:

cpp
// src/builtins/builtins-async-gen.cc:28
TNode<Context> AsyncBuiltinsAssembler::AllocateAwaitContext(
    TNode<NativeContext> native_context, TNode<JSGeneratorObject> generator) {
  static const int kAwaitContextSize =
      FixedArray::SizeFor(Context::MIN_CONTEXT_EXTENDED_SLOTS);
  TNode<Context> await_context =
      UncheckedCast<Context>(AllocateInNewSpace(kAwaitContextSize));
  ...
  StoreContextElementNoWriteBarrier(await_context, Context::EXTENSION_INDEX,
                                    generator);
  return await_context;
}

When the awaited promise settles, the reaction job runs, the handler is recognised as a generator object, and the generator is resumed. The queue that makes this work is described in The microtask queue: how V8 runs promise callbacks.

Async generators add a request queue

An async generator has one more problem: next() returns a promise, and the caller can call next() again before the first one has finished. So the requests are queued rather than run immediately:

torque
// src/objects/js-generator.tq:50
@cppObjectLayoutDefinition
extern class JSAsyncGeneratorObject extends JSGeneratorObject {
  // Pointer to the head of a singly linked list of AsyncGeneratorRequest, or
  // undefined.
  queue: AsyncGeneratorRequest|Undefined;
  // Whether or not the generator is currently awaiting.
  is_awaiting: Smi;
}

Each request carries its own promise, which is what the caller gets back:

torque
// src/objects/js-generator.tq:59
@cppObjectLayoutDefinition
extern class AsyncGeneratorRequest extends Struct {
  next: AsyncGeneratorRequest|Undefined;
  resume_mode: Smi;
  value: Object;
  promise: JSPromise;
}

and the enqueue logic only starts draining if the generator is not already running:

cpp
// src/builtins/builtins-async-generator-gen.cc:152
void AsyncGeneratorBuiltinsAssembler::AsyncGeneratorEnqueue(
    CodeStubArguments* args, TNode<Context> context, TNode<Object> receiver,
    TNode<Object> value, JSAsyncGeneratorObject::ResumeMode resume_mode,
    const char* method_name) {
  // AsyncGeneratorEnqueue produces a new Promise, and appends it to the list
  // of async generator requests to be executed. If the generator is not
  // presently executing, then this method will loop through, processing each
  // request from front to back.
  // This loop resides in AsyncGeneratorResumeNext.
  TNode<JSPromise> promise = NewJSPromise(context);

Trade-offs

What it buys. The generator’s state lives in one heap object with a flat, cheap-to-copy register array. Suspending is O(registers); resuming is O(registers) plus a jump. There is no stack copying, no stack switching, no green thread. The same machinery serves generators, async functions, async generators, and - as the comment in the generator builtin says - top-level await in modules:

cpp
// src/builtins/builtins-generator-gen.cc:23
  // Currently, AsyncModules in V8 are built on top of JSAsyncFunctionObjects
  // with an initial yield. Thus, we need some way to 'resume' the
  // underlying JSAsyncFunctionObject owned by an AsyncModule. To support this
  // the body of resume is factored out below, and shared by JSGeneratorObject
  // prototype methods as well as AsyncModuleEvaluate.

What it costs.

  • The register file is a heap object and it is copied. Every suspend writes the whole live register set out, and every resume reads it back. A generator that yields in a tight loop pays that on every iteration.
  • The interpreter and the optimizing compiler must agree on the frame layout. Ignition writes the register file into the array and scrubs it with stale-register constants as it reads it back; TurboFan writes only the live registers and pads the gaps with optimised-out constants. Both have to use the same slot numbering, because a generator suspended by one tier can be resumed by the other. The comment “The mapping of register to array index must match that used in BytecodeGraphBuilder::VisitResumeGenerator” is the only thing holding that together.
  • The new.target abuse is a protocol between two hand-written pieces of assembly and the bytecode generator. It works because generator functions are not constructable, so new.target is otherwise always undefined. It is the kind of contract that is invisible until it breaks.
  • Async functions pay for a promise even when they never suspend. The promise is allocated eagerly, because the semantics require it.
  • The await fast path is a pile of protectors. The “already fulfilled native promise” fast path has to check the initial map, the species protector, the promise-hook flags, the debug state, and the fulfilled flag. Every one of those checks exists because user code can observe the difference.

The bottom line

The mental model is: a V8 generator is a heap object holding a spilled frame. SuspendGenerator writes the register file into a FixedArray inside that object and parks the object at a suspend index; ResumeGenerator reads it back. The resume path is a per-architecture assembly trampoline that jumps into the function’s entry point with the generator object smuggled in as new.target, where the prologue’s SwitchOnGeneratorState picks the right resume point out of a jump table.

Async functions are the same object with a JSPromise bolted on, and await is a suspend point whose continuation is a promise reaction - which is why await and promise.then share one FIFO queue and one ordering.

The most interesting decision is the one that makes all of this work: the frame is spilled to the heap, not kept on the stack. That is what makes it possible to suspend a function across an arbitrary amount of time and an arbitrary number of intervening stack frames, which is the entire point of the feature.

The thing people get wrong

The common misconception is that “a generator keeps its stack frame alive”. It does not - V8 has no way to keep a stack frame alive across an event loop turn, because the stack is a stack. What is kept alive is a heap copy of the register file, plus the context chain, plus the receiver. The distinction matters when you reason about memory: a suspended generator holds its locals in a FixedArray that is scanned by the GC like any other object, not in a stack frame that the GC ignores.

The second misconception is that await is a language feature with its own runtime. It is not. await is SuspendGenerator plus a promise reaction, and the promise reaction is a microtask. Which is why an await and a .then() callback scheduled in the same turn run in FIFO order relative to each other, and why an await inside a microtask still works - the microtask queue is re-entrant enough to support it.

V8InternalsGeneratorsAsyncInterpreter