Skip to content

[expr.await] On the evaluation of await-expression #872

@ddvamp

Description

@ddvamp

Full name of submitter: Artyom Kolpakov

Consider the semantics of await-expression in [expr.await]

On the one hand, the standard in [expr.await]/3, [expr.await]/4, [expr.await]/5 and [intro.execution]/12 treats an await-expression as a single expression. Its subexpressions (await-ready, await-suspend, await-resume) are part of a single evaluation, and the result of the await-expression is the result of the await-resume expression. Thus, the await-expression follows the usual rules of expression evaluation, including sequencing, lifetime of temporaries, and exception propagation.

On the other hand, [expr.await]/5 specifies the semantics of an await-expression in terms of a sequence of conditionally evaluated subexpressions and associated effects, including a potential suspension and subsequent resumption of the coroutine. This introduces an observable discontinuity in execution, even though the await-expression remains a single evaluation in the abstract machine.

As I read [expr.await]/5, the evaluation proceeds as follows.

First, the await-ready expression is evaluated. If its evaluation (or the evaluation of preceding subexpressions of the await-expression) exits via an exception, the coroutine is not suspended, and the exception is thrown in the coroutine context from the await-expression. Otherwise, if the result of await-ready is true, the coroutine is not suspended, and the await-resume expression is evaluated as the result of the await-expression. Otherwise, the result of await-ready is false, and the coroutine is suspended (formally, considered suspended) in the middle of the await-expression evaluation, but control has not yet been transferred to the caller or resumer of the coroutine.

Now await-suspend is evaluated. If its evaluation exits via an exception, the exception is caught, the coroutine is resumed, and the exception is rethrown in the coroutine context from the await-expression, without the evaluation of the await-resume expression. Otherwise, await-suspend completes normally, control is transferred to the caller or resumer of the coroutine, regardless of the result of await-suspend, and the point of its suspension determines suspend point.

If the await-suspend returns false or handle h that refers to the coroutine, the coroutine is immediately resumed implicitly or via h.resume(), respectively. Otherwise, if await-suspend returns the handle h that refers to other coroutine, that coroutine resumed via h.resume(). Otherwise, control is "finally" transferred to the caller or resumer of the coroutine (exiting a coroutine call or a resume call).

And when the coroutine is resumed, the await-resume expression is evaluated.

It is possible to distinguish several stages in the described semantics of a single await-expression:

  1. the coroutine is already considered suspended, while the evaluation of the await-expression continues (and the evaluation of the await-suspend begins);
  2. after the evaluation of await-suspend, control may be transferred to the caller or resumer, and a suspend point is established.

Thus, the evaluation of the await-expression can be suspended and resumed later. At the same time, the standard permits resuming the coroutine via handle that refers to it during the evaluation of the await-suspend, that is, between stages and before the suspend point is established.

The interaction between the fact that the await-expression is a single evaluation, the staged structure of its specified semantics, and the possibility of resumption during await-suspend is not fully specified. This leads to a number of subtle issues.


Consider the following awaiter:

struct Awaiter {
  auto await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> h) { h.resume(); }
  auto await_resume() { return 0; }
};

Although simple, it already reveals a number of problems. Since the coroutine is resumed before the await-suspend evaluation completes, there is no control transfer to caller or resumer of the coroutine between its suspension and resumption, which is inconsistent with [expr.await]/1.sentence-2. This also means that there is no established suspend point when resuming. The control transfer and the establishment of the suspend point will occur later, in fact, when it is no longer necessary. And consistency is lost with the initial suspend point and the final suspend point

A simple example:

auto res = co_await Awaiter{};

It demonstrates receiving the result of the await-expression before its evaluation completes. When the await-suspend resumes the coroutine before its completion, the await-resume evaluation already begins, although the evaluation of the await-expression will not end with it. Moreover, in this way the evaluation of the await-expression extends beyond its full-expression.

Another example:

(co_await Awaiter{})[ptr];

It shows a violation of the ordering. According to [expr.sub]/1, the await-expression should be fully evaluated before evaluation of the ptr. However, due to the resumption from within the await-suspend, the await-expression has not yet completed evaluation. And again, the result of the await-expression is observed before it evaluation completes.

Yet another example:

co_await Awaiter{};
co_await Awaiter{};
// ...
co_await Awaiter{};

It results in overlapping evaluation of consecutive await-expressions. Each subsequent await-expression starts to be evaluated before the previous one is evaluated. This leads to the existence of several await-expressions being evaluated at the same time, which destroys the sequential execution model ([intro.execution]).

More fundamentally, explicit resumption of the coroutine from within the await-suspend does not follow the usual linear call/return execution model and introduces re-entrant execution of the coroutine. Each such resumption may result in additional nested activations associated with the same await-expression, occurring after the coroutine is considered suspended. At the same time, from within the coroutine itself, execution continues to appear as a regular call/return sequence. This creates a mismatch between the apparent linear structure of execution within the coroutine and the externally observable re-entrant control flow introduced by await-suspend (https://godbolt.org/z/fez954Tsz).

This example also demonstrates the blurring of the definition of suspend point. In each await-expression of the example, the actual resumption of the coroutine occurs before the completion of the evaluation of the await-suspend and the formal establishment of the suspend point. As a result, suspend points may "collapse" or be established after control has actually been transferred (https://godbolt.org/z/csq6KcbGz).

All this leads to a loss of intuitive semantics.


The situation becomes more serious if exceptions are used. Consider example:

struct ResumeAndThrowAwaiter {
  auto await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> h) {
    std::cout << 1;
    h.resume(); // #3
    std::cout << 3;
    throw 0; // #4
  }
  auto await_resume() {}
};

struct Coro {
  struct promise_type { /* ... */ };
};

Coro coro() {
  try {
    co_await ResumeAndThrowAwaiter{}; // #1
  } catch (...) {
    std::cout << '?';
  }

  std::cout << 2;

  try {
    co_await std::suspend_always{}; // #2
  } catch (...) {
    std::cout << 4;
  }

  std::cout << 5;
}

I see the execution as follows. The coroutine is suspended at #1, then resumed at #3, after which it is suspended at #2. Next, control is transferred back to #3, after which the evaluation of the await-suspend expression exits via an exception thrown at #4. This exception is rethrown from #2 and should be caught by the try block associated with this point. So I expect the output to be 12345.

The problem with this expectation is that [expr.await] operates on await-ready, await-suspend and await-resume, and also specifies the evaluation and exception handling for a single await-expression. According to [expr.await]/5.2, on the one hand, the coroutine resumes normally at #1, which implies that the corresponding await-resume should be evaluated, but on the other hand, the await-suspend of that same await-expression exits via an exception, which implies that its await-resume should not be evaluated. These conclusions are in conflict when interpreted within a single await-expression.

In this example, however, the execution interleaves the evaluations of two distinct await-expressions, which is not explicitly covered by [expr.await]. Intuitively, one would expect the exception to be thrown from #2, so that the coroutine resumes without evaluating the await-resume corresponding to #2 - effectively behaving as if the exception were propagated across suspension; this behavior is referred to as "exception teleportation". To describe such behavior precisely, it would be necessary to describe the semantics in terms of await-suspend and await-resume of different await-expressions.

If we look at major compilers behavior, they all print 123?2 - a kind of "time travel".

exception teleportation allows an exception that occurred in the context of one await-expression to appear in the context of another await-expression. This leads to strange results. Consider an example:

struct ResumeAndThrowAwaiter {
  auto await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> h) {
    h.resume();
    throw 0;
  }
  auto await_resume() {}
};

struct Coro {
  struct promise_type {
    std::suspend_always final_suspend() noexcept { return {}; }
    // ...
  };
};

Coro coro() {
  co_await ResumeAndThrowAwaiter{};
}

Consider exception teleportation to the final await expression. This is acceptable because the standard does not unify the ways of resuming coroutines, and only prohibits explicit resumption by resume() call from the final suspend point. However, [dcl.fct.def.coroutine]/16 suggests that the intention is to prevent exceptions from being thrown in this context, but exception teleportation bypasses this. It is also unclear what happens when the coroutine is completed by exception.

Moreover, exception teleportation allows exceptions to be thrown in the context of expressions that are nevertheless non-throwing, which appears to be a gap in the specification.

exception teleportation may also be considered in the context of a different type of final suspend point. Example:

struct Awaiter {
  auto await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> h) {
    h.resume(); // #1
    return false;
  }
  auto await_resume() { throw 0; }
};

struct Coro {
  struct promise_type {
    void unhandled_exception() { throw 0; }
    // ...
  };
};

Coro coro() {
  co_await Awaiter{};
}

Here according to [dcl.fct.def.coroutine]/15, the exception thrown from the unhandled_exception() call should propagate to #1, after which the await-suspend evaluation exits via that exception, and the coroutine resumes from its final suspend point with the rethrowing of this exception. Unlike the case of the final await expression, the standard does not indicate any intent regarding exceptions in this context.

The standard does not address resumption of the coroutine at its final suspend point in this scenario at all. It is unclear how this final suspend point relates to the replacement body, in particular where control resumes, and consequently where an exception would be thrown and where it would be caught. If an exception is caught at #1 and the coroutine resumes normally, it is unclear whether execution proceeds to the final-suspend label. Moreover, the final suspend point considered in the example is not associated with any await-expression, and therefore there is no corresponding await-resume. As a result, the semantics of such a resumption, whether with or without an exception, is unclear.


Although an await-expression is specified as a single evaluation, the semantics described for it involve multiple distinct effects that may be separated by suspension and resumption. As a result, it is not explicit how the constituent operations relate to the usual expression model. In particular, it is unclear whether await-ready, await-suspend, and await-resume are to be regarded as parts of a single full-expression, or as belonging to several distinct full-expressions.

If they are treated as separate full-expressions, then it is not specified:

  • which of them form full-expressions,
  • which are subexpressions of other,
  • and what the surrounding expressions are.

Intuitively, await-ready and await-resume are evaluated as part of the coroutine’s execution, whereas await-suspend is evaluated after the coroutine is considered suspended; however, this boundary is not explicitly specified.

This is of particular importance when considering object lifetimes. The expressions within an await-expression (await-ready, await-suspend, and await-resume) involve ordinary function calls. Their parameters may:

  • have default arguments,
  • create temporary objects (via temporary materialization),
  • have non-trivial constructors and destructors,
  • throw exceptions.

Such behavior is allowed by [expr.await]/3.

It is worth noting that [expr.await]/3.5 describes the handle in terms of an object, rather than specifying the value category of the expression used to pass it to await-suspend, leaving the value category unclear (https://godbolt.org/z/h91nPTW7f).

struct T {
  ~T() noexcept (false) { throw 0; }
};

struct H {
  std::coroutine_handle<> h_;

  H(auto h) : h_(h) {}
  ~H() noexcept (false) { throw 0; }
};

struct Awaiter {
  auto await_ready(T = {}) { return false; }
  auto await_suspend(H h) { h.h_.resume(); }
  auto await_resume(T && = {}) {}
};

In this case, it is unclear:

This leads to the following problem: if the destructor of such an object throws an exception, it is not specified at what point in the semantics of the await-expression this exception is observed.

The await-suspend case is particularly problematic. The standard explicitly specifies the exception handling that occurs during the evaluation of await-suspend, but does not specify whether this behavior also applies to exceptions arising from the destruction of objects associated with that call (such as parameters or temporary objects). Thus, it is unclear whether such exceptions fall under [expr.await]/5.1.sentence-3, or are observed in a different context (https://godbolt.org/z/EsW8KrqqP).

An additional complication arises from the fact that the standard permits resumption of the coroutine from within await-suspend. As a result, destruction of parameters may occur after the evaluation of await-suspend, at a point where execution has already passed through the corresponding full-expression within the coroutine. Conversely, temporary objects may be destroyed as part of the full-expression of the await-expression, before the evaluation of await-suspend completes (https://godbolt.org/z/bYfb9bTWT).

Finally, the situation is further complicated when the await-expression appears in a context that extends the lifetime of temporary objects (for example, in the for-range-initializer of a range-based for statement). In such cases, destruction of temporary objects may occur well after completion of the corresponding calls, making it difficult to determine when exceptions are observed and how they are handled (https://godbolt.org/z/YPWEs4zY5).

Moreover, it is unclear whether lifetime extension (for example, in the for-range-initializer of a range-based for statement) applies to temporary objects created during initialization of the parameters of await-suspend. This is particularly unclear because await-suspend is evaluated after the coroutine is considered suspended, and may therefore be observed outside the immediate execution context of the await-expression.

This becomes particularly problematic when combined with resumption from within await-suspend. If lifetime extension applies at the point where the await-expression appears, covering all expressions involved in its evaluation, then resuming the coroutine from within await-suspend may cause that lifetime extension to end before control returns to the await-suspend call. As a result, objects whose lifetimes were extended may already be destroyed, leaving the await-suspend invocation with effectively dangling parameters.

The situation becomes significantly more complex when a full-expression contains multiple await-expressions, whether sequenced or not, as well as in cases where await-suspend performs multiple calls to std::coroutine_handle<>::resume() on the current coroutine.

All of the issues described above can arise even if await-suspend returns the handle h of the current coroutine, resulting in symmetric transfer. In this case, per [expr.await]/5.1.1.sentence-1, await-suspend effectively performs a resumption of the coroutine before the evaluation of the surrounding full-expression has completed, so that neither the evaluation of the await-suspend expression nor that of the enclosing await-expression is complete at the point of resumption.


An additional source of ambiguity arises from the result of await-ready. According to [expr.await]/3.6, the await-ready expression is only required to be contextually convertible to bool, and therefore may have a non-trivial type with side effects, including those occurring during destruction.

The standard specifies the behavior of await-expression in terms of conditional evaluation (depending on the result of await-ready), rather than as a sequence of operations. As a result, it is not specified in which context the result of await-ready is used, nor when its lifetime ends relative to the suspension of the coroutine.

In particular, if the result of await-ready is false, it is unclear at what point the coroutine is considered suspended with respect to the lifetime of that result, and when the destruction of the temporary object occurs. It is also unclear whether such destruction may occur before the evaluation of await-suspend.

Since the destruction of the result may have observable side effects, it is not specified whether the destructor of the result of await-ready may resume the coroutine, including prior to the evaluation of await-suspend (https://godbolt.org/z/nh4Yde9Y4).

A similar issue arises during the evaluation of the arguments of await-suspend. Since function arguments are evaluated prior to the invocation, and their evaluation may have arbitrary side effects, it is possible for a coroutine to be resumed during the evaluation of an argument to await-suspend. This resumption may occur before the invocation of await-suspend itself, which is not explicitly addressed by the standard (https://godbolt.org/z/TbWeM9Ycv).

Thus, resumption may occur at any point where side effects are permitted during evaluation of the await-expression.


A distinct problem arises when evaluating the initial await expression. Consider an example:

struct T {
  ~T() noexcept (false) { throw 0; }
};

struct Awaiter {
  auto await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> h, T && = {}) { h.resume(); }
  auto await_resume() {}
};

struct Coro {
  struct promise_type {
    Awaiter initial_suspend() { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    // ...
  };
};

Coro coro() {
  co_return;
}

The initial await expression determines the transition to the execution of the coroutine function body and is evaluated before it. Resuming the coroutine from within its await-suspend allows the execution of the function body to begin (and even complete) before the evaluation of the initial await expression is completed, blurring the boundary between coroutine start and execution. This also allows objects associated with the initial await expression to outlive the execution of the function body, although they are created before entering it. As a result, the standard does not clearly specify the semantics of destruction of such objects (logically related to the initial await expression), in particular if their destructors throw exceptions, just as if await-suspend would be used as await-suspend.resume().


Consider yet another distinct problem pertaining to the destroy() call.

The coroutine is considered suspended before the start of the evaluation of the await-suspend expression, while the evaluation of the await-expression is still in progress. At this point, the following objects may exist:

  • block variables within the coroutine,
  • objects involved in the evaluation of the await-suspend expression (and, more generally, the await-expression), such as parameters and temporary objects associated with that evaluation.

The standard does not specify how these objects are maintained between suspension and resumption of the coroutine (in terms of storage).

While the lifetime of such objects has not ended, the coroutine can be destroyed by calling destroy(). In this case, control in the coroutine is considered to be transferred out of the function ([dcl.fct.def.coroutine]/12.sentence-2), i.e. beyond the end of the replacement body. [stmt.dcl] specifies the destruction of block variables, but does not cover objects related to the await-expression evaluation. The context and semantics of the destruction of the latter are unclear. The standard does not specify how the lifetime of objects associated with the uncompleted evaluation of the await-expression interacts with the destruction of the coroutine state when destroy() is called.

With respect to block variables, as I read the standard, if a destructor exits via an exception, control is transferred to the nearest matching handler, and the coroutine resumes execution instead of destroying. Thus, a call to destroy() effectively becomes a resume() call, which does not correspond to its specified effect (https://godbolt.org/z/fd1rMYMf7).

It is unclear how this interacts with other parts of the coroutine definition:

  • should the handler in the replacement body be triggered,
  • what happens if the coroutine resumes in this way before initial-await-resume-called is set to true,
  • and if the call to unhandled_exception() exits via an exception, should that exception be rethrown from the call to destroy() (https://godbolt.org/z/voP6erMqK).

Further complications arise if destroy() is invoked from within the await-suspend. It is unclear whether the coroutine can be correctly destroyed in this situation (https://godbolt.org/z/K8KjG4ET6).


The issues described above are not independent corner cases. They all stem from a single underlying inconsistency: an await-expression is specified as a single expression with well-defined evaluation semantics, while its execution is allowed to be split across suspension and resumption, including reentrant resumption before completion. The await-expression is not indivisible with respect to either evaluation or lifetime.

Although the standard describes the behavior of an await-expression in terms of a sequence of logically ordered steps, these steps are not defined as a complete operational model that accounts for all relevant aspects, including the lifetimes of parameters and results, lifetime extension, side effects and exceptions, reentrancy, destruction, and the boundaries of execution at coroutine entry and exit.

This mismatch manifests at multiple levels:

  • within a single await-expression,
  • between multiple await-expressions,
  • at coroutine start (initial suspend point),
  • and at coroutine end (final suspend point and exception handling).

The standard does not provide a unified or consistent model that explains how these behaviors interact.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions