Senior 3 min · March 06, 2026

C++20 Coroutines — Why co_yield Crashed After 200 Frames

Recursive coroutine generators still exhaust call stacks despite heap frames.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Concepts constrain template parameters with compile-time predicates — a contract for types, not runtime checks.
  • Ranges compose lazy transformations over sequences without allocating intermediate containers.
  • Coroutines enable cooperative multitasking: functions that suspend/resume without blocking threads.
  • Modules replace header files with a proper compilation boundary; no more #include order bugs.
  • The three-way comparison operator (<=>) auto-generates all relational operators for a class.
  • Biggest gotcha: coroutines allocate heap frames by default; not every function should be async.
Plain-English First

Imagine you're a chef in a restaurant kitchen. Before C++20, you could hire any cook and hope they knew how to handle a knife — and only find out they didn't when dinner service collapsed. C++20 Concepts are like requiring a formal knife-skills certificate before they step foot in your kitchen. Ranges are like having a smart sous chef who preps ingredients in exactly the order you need them, on demand, instead of dumping everything on the counter at once. Coroutines are like a cook who can pause mid-recipe, hand the stove to someone else, and pick up exactly where they left off — no wasted effort, no lost progress.

C++20 is the biggest revision to the language since C++11 rewired how we think about modern C++. It didn't just add syntax sugar — it introduced four pillars that fundamentally change how you architect, template, and scale C++ codebases: Concepts, Ranges, Coroutines, and Modules. These aren't academic curiosities. Google, Microsoft, and JetBrains are already shipping production code that leans on these features, and the compilers — GCC 10+, Clang 10+, MSVC 19.28+ — have solid enough support that there's no excuse for ignoring them in greenfield projects.

Before C++20, template error messages were infamous horror shows — pages of substitution failures that pointed nowhere useful. Constraints on template parameters were enforced via SFINAE tricks that even seasoned engineers would copy-paste without fully understanding. Lazy data pipelines required external libraries like range-v3. Asynchronous code meant either raw threads, callback hell, or heavy framework dependencies. Every one of these pain points has a direct answer in C++20.

By the end of this article you'll understand not just what each feature does, but why it was designed that way, what trade-offs the committee made, where the sharp edges are in real production code, and how to answer the interview questions that trip up candidates who only skimmed the release notes. We'll write real, runnable code for each feature and look hard at the moments where things go wrong.

1. Concepts: Contracts for Template Parameters

Concepts allow you to specify constraints on template arguments that are checked at compile time, replacing SFINAE with readable, maintainable predicates. A concept is a compile-time boolean expression evaluated on the type — if it fails, the template is excluded from overload resolution (not an error unless no valid overload exists).

Before C++20, you'd use std::enable_if or tag dispatch to achieve the same. The difference? Concepts produce error messages that actually tell you what's wrong — not a 200-line template instantiation backtrace.

The standard library already provides predefined concepts like std::integral, std::floating_point, std::default_initializable, and std::ranges::range. You compose them with logical operators (&&, ||) and the requires clause.

concepts_example.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <concepts>
#include <iostream>

template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};

template<Addable T>
T add(T a, T b) {
    return a + b;
}

int main() {
    std::cout << add(3, 4) << '\n';           // OK: int satisfies Addable
    // std::cout << add(std::string("Hi"), std::string("!")); // Error: std::string does not satisfy Addable
    return 0;
}
Output
7
Key Insight
Concepts are not runtime checks. They are purely compile-time constraints that participate in overload resolution. A requires expression can also assert on the existence of member functions and nested types — not just operators.
Production Insight
Concepts fail silently when a constraint is too loose — e.g., accepting any type with + but expecting a numeric type. Later operations like sqrt will produce deep template errors.
Always pair concepts with static_assert in the implementation to catch logic errors early.
Rule: Write concepts that are strict enough to reject misuse but not so strict that they reject valid types with the same interface.
Key Takeaway
Concepts replace SFINAE with readable contracts.
They produce errors at the call site, not inside the template.
Write concepts that match the semantic intent, not just syntactic capability.
When to Use Concepts vs SFINAE
IfNeed to constrain a single template parameter with a simple type trait
UseUse a concept from <concepts> or define your own with requires. Avoid SFINAE entirely.
IfNeed to conditionally enable a function based on multiple type properties
UseUse a concept with logical operators (&&, ||). Still cleaner than enable_if.
IfYou must support C++17 or earlier
UseStick with SFINAE (enable_if, void_t). You can't use concepts in C++17.

2. Ranges: Composable Lazy Data Pipelines

Ranges bring the composability of functional programming to C++ containers and views. A range is anything that can be iterated over — arrays, vectors, strings, or custom types. Views (std::views) are composable adaptors that lazily transform or filter ranges without allocating new containers.

Instead of writing nested loops or manually chaining algorithms, you write a pipeline: vec | views::filter(pred) | views::transform(f) | views::take(n). Each stage is lazy — only the elements actually needed are processed.

The performance win is twofold: no memory allocation for intermediate results, and early termination when using take or drop_while. But beware of dangling references: views store iterators, and if the underlying range dies, the view becomes invalid.

ranges_pipeline.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5, 6};
    auto result = data
        | std::views::filter([](int x){ return x % 2 == 0; })   // lazy: none executed yet
        | std::views::transform([](int x){ return x * x; })
        | std::views::take(3);                                  // only first 3

    for (int v : result) {
        std::cout << v << ' ';
    }
    // Output: 4 16 36
    return 0;
}
Output
4 16 36
Dangling View Danger
Views store iterators to the underlying range. If that range is a temporary (prvalue), the view's iterators dangle — accessing them is undefined behaviour. Always ensure the underlying range outlives all views.
Production Insight
Lazy evaluation means errors (like an invalid iterator) are deferred until iteration. A pipeline that compiles fine may crash at runtime if a view depends on a temporary.
Use std::views::common to convert a view into a range that can be used with older APIs expecting begin/end pair.
Tip: In performance-critical loops, avoid views::filter before views::transform if the filter rejects most elements — the transform spens work on filtered-out elements anyway? Actually, filter rejects before transform, so transform only runs on passing elements. That's correct.
Key Takeaway
Ranges compose lazy pipelines without extra allocations.
Watch out for dangling views — keep underlying data alive.
Use take before expensive transforms to limit work.
When to Use Ranges vs Traditional Algorithms
IfNeed multiple transformations on a sequence
UseUse ranges pipeline — cleaner and lazy allocation.
IfWorking with very large data and need early termination
UseRanges with take or drop_while avoid processing the entire sequence.
IfYou need to pass the result to a legacy function expecting vector
UseMaterialise the range into a container: auto vec = range | ranges::to<std::vector>(); (C++23) or manually copy.

3. Coroutines: Cooperative Multitasking Without Threads

Coroutines are functions that can suspend execution and resume later, preserving local state across suspension points. Unlike threads, coroutines don't require OS scheduling — the decision to suspend and resume is cooperative.

C++20 provides three keywords: co_await (suspend and wait for an awaitable), co_yield (produce a value and suspend), and co_return (final return from a coroutine). The compiler transforms the function into a state machine, allocating a coroutine frame on the heap (by default) to hold suspended state.

Coroutines shine in scenarios like streaming data, asynchronous I/O, and generators. But they have subtle pitfalls: heap allocation overhead, potential stack overflow if deeply nested, and difficult debugging because the stack is reconstructed at suspension points.

coroutine_generator.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <coroutine>
#include <iostream>

template<typename T>
struct Generator {
    struct promise_type {
        T current_value;
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        Generator get_return_object() { return Generator{this}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    // ... handle management omitted for brevity
};

Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i)
        co_yield i;
}

int main() {
    for (auto v : range(1, 5)) {
        std::cout << v << ' ';
    }
    return 0;
}
Output
1 2 3 4
Heap Allocation Default
By default, the coroutine frame is dynamically allocated. Use promise_type::get_return_object_on_allocation_failure to avoid allocation failure. Many production coroutine libraries (e.g., boost::asio) provide custom allocators to reduce overhead.
Production Insight
Coroutine frames allocate on the heap by default — make sure the allocation path is fast. Use std::experimental::generator or a custom promise with a pool allocator for high-throughput scenarios.
Debugging suspended coroutines is hard: a debugger may show a truncated stack. Use coroutine_handle::resume explicitly in unit tests to control scheduling.
Rule: Never use coroutines for short-running synchronous tasks — the allocation overhead (typically 50-100 bytes) outweighs the benefit.
Key Takeaway
Coroutines suspend without blocking threads.
Heap allocation per coroutine frame is the default — optimise with custom allocators for hot paths.
Debugging suspended coroutines requires specialised tools; test with explicit resumption.
Use Coroutines When...
IfYou need a lazy sequence that produces values on demand
UseUse co_yield generator pattern.
IfYou have asynchronous I/O bound operations
UseUse co_await with a proper executor (boost::asio, libunifex).
IfThe function is called rarely or latency-insensitive
UseCoroutines add overhead; use plain functions or threads.

4. Modules: Modern Compilation Boundaries

Modules (export, import) provide a new way to organise C++ code that is faster to compile and more hygienic than headers. A module interface unit (.cppm) declares what is exported, and module implementation units (.cpp) define non-exported details. Importing a module gives access only to the exported names, eliminating macro leaks, ODR violations, and include-order dependencies.

Modules also improve build times because the compiler pre-compiles module interfaces into .pcm files (Clang) or .ifc files (MSVC). Translation units that import the module only need to load that precompiled interface — no reparsing of headers.

However, module support is not fully consistent across compilers. You may encounter issues with .cppm vs .ixx extensions, and interaction with precompiled headers (PCH) can be fragile.

module_math.cppmCPP
1
2
3
4
5
6
7
8
9
10
11
12
export module math;
export int add(int a, int b) {
    return a + b;
}
export int multiply(int a, int b) {
    return a * b;
}

// Non-exported: not visible to importers
int helper(int x) {
    return x * 2;
}
Compiler Module Support Differences
GCC uses .cppm and -fmodules-ts. Clang uses .cppm and -fmodules. MSVC uses .ixx for module interface and /interface. The extension matters — using .h for a module interface won't work. Always check your compiler's documentation for exact file extension and flags.
Production Insight
Modules eliminate the preprocessor's textual inclusion, but they introduce a new dependency on compiler-specific binary formats. A module compiled by GCC cannot be imported by Clang.
In mixed-compiler environments (common in large organisations), stick to headers or use a build system that recompiles modules per compiler.
Tip: Start with a single module to isolate commonly used utilities. Gradually expand, but monitor build cache invalidation — module dependencies are fine-grained and can cause rebuild cascades.
Key Takeaway
Modules replace headers with proper compilation boundaries.
Compiler support is uneven — test your build matrix.
Start small: convert the most-frequently-included headers first.
Modules vs Headers Decision
IfProject is header-only and you want faster builds
UseConvert key interfaces to modules. Expect a 20-30% build time reduction for large projects.
IfYou need to distribute a closed-source library
UseModules can expose only the API, hiding implementation details, but binary module format compatibility is poor across compilers. Headers are still safer.
IfProject uses many macros or #pragma once workarounds
UseModules solve include guard issues and macro pollution. Strong incentive to migrate.

5. Three-Way Comparison Operator (Spaceship)

The <=> operator (also called the spaceship operator) performs a three-way comparison, returning a comparison category type that indicates less, equal, or greater. The compiler can automatically generate ==, !=, <, <=, >, >= from <=> if you write = default. This eliminates the boilerplate of writing six comparison operators for a class.

There are three category types: std::strong_ordering (total order: no two values are incomparable), std::weak_ordering (equivalence classes, e.g., case-insensitive strings), and std::partial_ordering (values may be incomparable, e.g., floating-point NaN).

The operator is a two-way operator with reversed candidate generation, which can lead to ambiguities when both <=> and == are custom defined.

spaceship_example.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <compare>
#include <iostream>

struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default; // generates all six
};

int main() {
    Point p1{1, 2}, p2{1, 3};
    std::cout << std::boolalpha;
    std::cout << (p1 < p2) << '\n';  // true, because x==1, y:2<3
    std::cout << (p1 == p2) << '\n'; // false
    std::cout << (p1 <=> p2 < 0) << '\n'; // true (p1 less)
}
Output
true
false
true
Defaulted == and !=
When you = default <=>, the compiler generates == and != only if <=>'s return type is one of std::strong_ordering or std::weak_ordering (i.e., not partial_ordering). For partial_ordering, you must also define == explicitly if you need equality.
Production Insight
Ambiguities arise when a class has both <=> and a custom operator==. The compiler sees reversed candidates for <=> and may refuse to compile. The fix: remove the custom operator== if it does the same as the generated one, or qualify calls with explicit operator<=>.
For floating-point members, <=> returns partial_ordering — be aware that NaN comparisons will be unordered, which affects sorting containers like std::set. Use std::strong_ordering only if you guarantee no NaN.
Key Takeaway
<=> with = default generates all six relational operators.
Watch for ambiguity when manually defining both <=> and ==.
Floating-point members produce partial_ordering — handle NaN explicitly.
When to Default Spaceship
IfClass has simple value semantics (all members comparable)
UseUse auto operator<=>(const T&) const = default;. Generates all six operators cleanly.
IfClass contains floating-point members that may be NaN
UseDefine operator<=> manually to return strong_ordering after handling NaN, or accept partial_ordering but be aware of std::set incompatibility.
IfYou need only equality/hash, not ordering
UseJust define == and a hash function. Don't add <=> unless you need sortability.
● Production incidentPOST-MORTEMseverity: high

Coroutine Stack Exhaustion in a Production Video Transcoding Pipeline

Symptom
Process crashes with SIGSEGV after processing ~200 frames. Stack traces show deep call chains inside an std::generator function. Memory usage grows linearly with frame count until the stack overflows.
Assumption
Engineers assumed coroutines would automatically yield control at each co_yield, preventing deep recursion. The generator was actually calling itself recursively without a base case.
Root cause
The generator coroutine used recursion to produce frames but never returned from the recursive call — it kept suspending at co_yield but never allowed the promise to destroy the frame. The coroutine frame remained on the heap and the call stack grew unboundedly.
Fix
Replace the recursive generator with an iterative loop that yields each frame. Alternatively, use a flat recursion depth check and fallback to thread spawning when depth exceeds a threshold.
Key lesson
  • Coroutines don't automatically protect against infinite recursion — they just shift the allocation to the heap, but the call stack still grows for each nested suspend.
  • Always test coroutines with stress inputs that can cause deep nesting. Stack usage analysis is vital before production deployment.
  • Prefer iterative state machines over recursive generators for unbounded data flows.
Production debug guideSymptom-to-action guide for common production issues4 entries
Symptom · 01
Template instantiation error: 'constraints not satisfied' with deep concept chain
Fix
Use -fconcepts-diagnostics-depth=2 (GCC) or -Xclang -fconcepts-ts (Clang) to print only the first failing constraint. Avoid parsing the full substitute failure tree.
Symptom · 02
Coroutine heap frame is not freed — memory usage grows with each suspension
Fix
Check if the coroutine's return type uses std::noop_coroutine or a custom promise with final_suspend. Ensure the coroutine handle is destroyed or the promise's return_void properly destroys the frame.
Symptom · 03
Module import results in 'not a module' error even though .cppm file exists
Fix
Verify that the module file is compiled with -std=c++20 -fmodules-ts (GCC) or -std=c++20 -fmodules (Clang). Check that the module interface unit has the .cppm extension and includes export module mymodule;.
Symptom · 04
Spaceship operator <=> generates wrong comparison order for custom types
Fix
If you defined operator<=> manually, ensure it returns a comparison category type (std::strong_ordering, etc.). For generated ==/!=, the compiler synthesises from <=> only if operator== is not explicitly defined.
★ C++20 Debug Cheat SheetQuick commands and checks for common C++20 production issues
Concept constraint not satisfied; error message is huge
Immediate action
Enable concept diagnostic trimming
Commands
g++ -std=c++20 -fconcepts-diagnostics-depth=2 file.cpp
clang++ -std=c++20 -Xclang -fconcepts-ts file.cpp
Fix now
Wrap the constraint in a simple requires expression and check each sub-expression individually.
Coroutine leaks memory (heap frame never freed)+
Immediate action
Check the 'final_suspend' return type
Commands
valgrind --leak-check=full ./binary # detect coroutine frame leaks
export ASAN_OPTIONS=detect_leaks=1; ./binary
Fix now
Ensure final_suspend returns std::suspend_never to destroy frame inline, or manually call coroutine_handle::destroy() when the coroutine completes.
Module import fails: 'file not found' or 'not a module'+
Immediate action
Verify compilation flags and file extension
Commands
g++ -std=c++20 -fmodules-ts -c mymodule.cppm -o mymodule.o
clang++ -std=c++20 -fmodules -c mymodule.cppm -o mymodule.o
Fix now
Use .cppm extension for module interface units. With MSVC, use /std:c++20 /interface.
Operator `<=>` causes ambiguous comparisons+
Immediate action
Check if you defined `operator==` manually
Commands
nm -C binary | grep 'operator==' # see if custom equality exists
Use `-Wambiguous-reversed-operator` on Clang
Fix now
If you defined both <=> and ==, the compiler will use == for equality; ensure consistency. Remove custom == if it duplicates the generated one.
Comparison of C++20 Features
FeaturePrimary Use CasePerformance ImpactGotcha
ConceptsCompile-time type constraints on templates0 overhead (compile-time only)Overly loose concepts silently compile; catch with static_assert in body
RangesLazy composable data pipelinesLazy evaluation saves memory, but can add indirection overheadDangling view iterators when underlying range dies
CoroutinesAsynchronous I/O, generatorsHeap allocation per frame (~50-100 bytes); suspension point overheadDeep recursion inside a coroutine causes stack overflow
ModulesCompilation boundaries, faster buildsPrecompiled module files reduce compile time 20-30%File extension and flags vary by compiler; binary format not portable
Spaceship <=>Automatic generation of comparison operatorsNone; generates same code as hand-written operatorsAmbiguity with custom ==; partial_ordering for floats

Key takeaways

1
Concepts provide readable compile-time constraints that replace SFINAE and produce clearer errors.
2
Ranges enable lazy composable pipelines; always ensure underlying ranges outlive views.
3
Coroutines suspend without threads but heap-allocate frames by default
optimise for hot paths.
4
Modules improve build times and eliminate include order issues, but compiler support is uneven.
5
Spaceship operator auto-generates comparisons but watch for floating-point partial ordering and ambiguity with custom ==.
6
Adopt C++20 features incrementally
start with concepts and spaceship, then ranges, then modules and coroutines as your compiler maturity allows.

Common mistakes to avoid

4 patterns
×

Using `requires` without understanding substitution failure

Symptom
A concept matches a type that passes the requires expression but fails on a later constraint. Error messages point to the instantiation site, not the concept, hiding the real cause.
Fix
Test each sub-expression in the requires clause separately. Use static_assert(MyConcept<T>) at the template definition to verify constraints are sufficient.
×

Assuming coroutine frame is stack-allocated or cheap

Symptom
High latency in tight loops that call a coroutine. Profiling shows allocation pressure and memory fragmentation.
Fix
Benchmark the coroutine overhead. For hot paths, consider using a custom promise with a memory pool, or refactor to a plain function with manual state machine.
×

Mixing modules with `#include` in the same translation unit

Symptom
Build errors like 'module declaration must appear before any preprocessor directives'. The compiler rejects the file because #include before export module is not allowed (except for headers used by the module itself).
Fix
Move all #include statements after the export module line, or use import for dependencies. Only import <iostream> style is allowed, not #include.
×

Defining both `<=>` and `==` in a class that should be value-ordered

Symptom
Compiler error about ambiguous overload when comparing two objects — the reversed candidate from <=> conflicts with the custom ==.
Fix
Remove the custom operator== if it does the same as the generated one. If you need different equality semantics, define <=> manually and not use = default.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how concepts differ from SFINAE and why they improve error messa...
Q02SENIOR
When would you use ranges instead of traditional `std::transform` and `s...
Q03SENIOR
Explain the coroutine frame allocation and how to optimise it.
Q01 of 03SENIOR

Explain how concepts differ from SFINAE and why they improve error messages.

ANSWER
SFINAE (Substitution Failure Is Not An Error) uses tools like std::enable_if to conditionally disable templates. When a substitution fails, the compiler silently removes that overload and tries others — resulting in opaque error messages if no viable overload exists. Concepts replace this with explicit compile-time predicates. If a concept is not satisfied, the compiler reports exactly which constraint failed and on what type, at the call site. Concepts also participate in overload resolution as ordered constraints (using constraint subsumption), allowing partial ordering without additional traits. Example: ``cpp template<typename T> requires std::integral<T> T add(T a, T b) { return a + b; } ` If called with float`, error says: 'constraints not satisfied: std::integral<float>' — far clearer than SFINAE noise.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What C++20 features are production-ready in 2026?
02
How do I convert a legacy header to a module?
03
Can I use coroutines without heap allocation?
04
Why does the spaceship operator sometimes cause ambiguous comparisons?
🔥

That's C++ Advanced. Mark it forged?

3 min read · try the examples if you haven't

Previous
C++17 Features
9 / 18 · C++ Advanced
Next
Competitive Programming with C++