C++20 Coroutines — Why co_yield Crashed After 200 Frames
Recursive coroutine generators still exhaust call stacks despite heap frames.
- 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.
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.
requires expression can also assert on the existence of member functions and nested types — not just operators.+ but expecting a numeric type. Later operations like sqrt will produce deep template errors.static_assert in the implementation to catch logic errors early.<concepts> or define your own with requires. Avoid SFINAE entirely.&&, ||). Still cleaner than enable_if.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.
std::views::common to convert a view into a range that can be used with older APIs expecting begin/end pair.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.take before expensive transforms to limit work.take or drop_while avoid processing the entire sequence.vectorauto 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.
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.std::experimental::generator or a custom promise with a pool allocator for high-throughput scenarios.coroutine_handle::resume explicitly in unit tests to control scheduling.co_yield generator pattern.co_await with a proper executor (boost::asio, libunifex).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.
.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.#pragma once workarounds5. 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.
= 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.<=> 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<=>.<=> 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.<=> with = default generates all six relational operators.<=> and ==.partial_ordering — handle NaN explicitly.auto operator<=>(const T&) const = default;. Generates all six operators cleanly.operator<=> manually to return strong_ordering after handling NaN, or accept partial_ordering but be aware of std::set incompatibility.== and a hash function. Don't add <=> unless you need sortability.Coroutine Stack Exhaustion in a Production Video Transcoding Pipeline
co_yield, preventing deep recursion. The generator was actually calling itself recursively without a base case.co_yield but never allowed the promise to destroy the frame. The coroutine frame remained on the heap and the call stack grew unboundedly.- 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.
-fconcepts-diagnostics-depth=2 (GCC) or -Xclang -fconcepts-ts (Clang) to print only the first failing constraint. Avoid parsing the full substitute failure tree.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.-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;.<=> generates wrong comparison order for custom typesoperator<=> 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.requires expression and check each sub-expression individually.Key takeaways
==.Common mistakes to avoid
4 patternsUsing `requires` without understanding substitution failure
requires expression but fails on a later constraint. Error messages point to the instantiation site, not the concept, hiding the real cause.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
Mixing modules with `#include` in the same translation unit
#include before export module is not allowed (except for headers used by the module itself).#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
<=> conflicts with 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 Questions on This Topic
Explain how concepts differ from SFINAE and why they improve error messages.
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.Frequently Asked Questions
That's C++ Advanced. Mark it forged?
3 min read · try the examples if you haven't