Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions doc/as_sender.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ struct frame_cb {

Three pointers. Twenty-four bytes on a 64-bit platform. The `resume`
pointer holds the sender's completion callback. The `destroy` pointer
is a no-op - the sender owns its own lifetime. The `data` pointer
is reached only when the awaitable's segment is given up: it destroys
the awaitable and completes the receiver stopped. The `data` pointer
points back to the operation state.

`std::coroutine_handle<>::from_address(&cb)` produces a handle that,
Expand Down Expand Up @@ -83,7 +84,7 @@ Here is what happens, step by step:
inline.

3. Otherwise, the `frame_cb` is filled in: `resume` points to the
completion callback, `destroy` is a no-op, `data` points to the
completion callback, `destroy` gives the operation up, `data` points to the
operation state. A `coroutine_handle<>` is manufactured from the
`frame_cb`'s address. `await_suspend(handle, &env)` is called on
the awaitable.
Expand Down
1 change: 1 addition & 0 deletions doc/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
** xref:4.coroutines/4g.composition.adoc[Concurrent Composition]
** xref:4.coroutines/4h.allocators.adoc[Frame Allocators]
** xref:4.coroutines/4i.lambda-captures.adoc[Lambda Coroutine Captures]
** xref:4.coroutines/4j.lifetimes.adoc[Lifetime and Ownership]
* xref:5.buffers/5.intro.adoc[Buffer Sequences]
** xref:5.buffers/5a.contiguous-buffers.adoc[Contiguous Buffers]
** xref:5.buffers/5b.composite-buffers.adoc[Composite Buffers]
Expand Down
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@

`std::coroutine_handle::destroy()` is the {cpp}20 primitive that frees a coroutine frame. It is not a cancellation mechanism. It has the same flaw as forceful thread interruption. The coroutine is torn down with no opportunity to complete pending I/O, release locks, or run RAII destructors in the expected order.

Capy exposes cpp:task::handle[handle]`()` and cpp:quitter::handle[handle]`()` so that Capy's own launchers (cpp:run_async[], cpp:run[]) and custom integrations can dispatch coroutines through executors. Calling `destroy()` on such a handle while the coroutine is being awaited by a parent produces undefined behavior. The destruction cascades back through the parent's continuation, re-entering frame destruction that is already in progress.
Capy exposes cpp:task::handle[handle]`()` and cpp:quitter::handle[handle]`()` so that Capy's own launchers (cpp:run_async[], cpp:run[]) and custom integrations can dispatch coroutines through executors. Calling `destroy()` on such a handle while the coroutine is being awaited by a parent produces undefined behavior: the parent still holds the frame and will destroy it again. A holder that must give up a suspended coroutine for good, such as an execution context being torn down, passes its continuation to cpp:abandon[] instead, which unwinds the chain from the top on its owner's executor.

Check warning on line 160 in doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.SimpleTense] Avoid needless future/perfect tense — prefer present simple (style guide C4): 'will '.

The rule:

Expand Down
7 changes: 7 additions & 0 deletions doc/modules/ROOT/pages/4.coroutines/4h.allocators.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ When starting many short-lived tasks together, a monotonic buffer resource can b
include::example$snippets/4h_allocators.cpp[tag=batch_allocator]
----

The resource must outlive every task started with it, including the unwind of
any that are abandoned. This function returns only after its tasks have
finished when the executor runs each one to completion before `run_async`
returns. With an executor that queues the work, keep the resource alive until
the tasks have finished. See
xref:4.coroutines/4j.lifetimes.adoc#_shutdown_ordering[Shutdown Ordering].

=== Scope Variables to Reduce Frame Size

Clang decides frame layout after optimization, so declaration scope controls what it keeps. A variable whose scope spans a `co_await` stays in the frame until that scope ends.
Expand Down
233 changes: 233 additions & 0 deletions doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
//
// Copyright (c) 2026 Steve Gerbino
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//

= Lifetime and Ownership
:page-mode: explanation

A coroutine outlives the call that created it. It can suspend in one function
and resume long after that function has returned, on another thread, after the
objects it was given have gone. Most coroutine bugs are lifetime bugs: a
reference that outlived what it referred to, or a frame that outlived what it
held.

This page gathers the rules for composing Capy coroutines: what a coroutine
frame keeps alive, which arguments are safe to pass by reference, who owns a
suspended coroutine, and what happens to it when the thing it waits on is torn
down.

== What a Frame Keeps Alive

Calling a coroutine function allocates a _frame_ and copies or moves each
parameter into it. Locals declared in the body live in the frame too. The
frame lives until the coroutine finishes, or until it is destroyed while
suspended.

What the frame copies is the parameter, not what the parameter refers to. A
`std::string` parameter is a string the frame owns. A `std::string const&`
parameter is a reference the frame holds to a string someone else owns, and
nothing keeps that string alive.

A cpp:task[] is lazy: the coroutine does not start until it is awaited or
launched. The `task` object owns the frame. Awaiting a task lends the frame to

Check warning on line 37 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).
the awaiting coroutine, which resumes when it finishes; destroying an unstarted
or finished task destroys its frame.

== Arguments by Reference

A reference parameter is safe when the referenced object outlives the
coroutine. The common safe case is a task awaited in the same full expression
that creates its argument:

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=ref_param_awaited]
----

The same function is unsafe as soon as the task is stored and awaited later:

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=ref_param_deferred]
----
<1> The temporary string is destroyed at the semicolon. The task is lazy, so
its body has not run yet.
<2> The body runs here and reads the destroyed string.

The fix is to take by value whatever a task reads after it may have been
stored. The string is then moved into the frame:

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=value_param]
----

Tasks are stored more often than it looks. Every launch stores one:

Check warning on line 70 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).
cpp:run_async[] starts the task through its executor, which can run it after
the full expression that launched it has ended:

Check warning on line 72 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=run_async_temporary,indent=0]
----
<1> Undefined behavior: the string may be gone before `count_bytes` reads it.

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=run_async_owned,indent=0]
----

Collecting tasks in a range and awaiting them together with cpp:when_all[] or
cpp:when_any[] stores them too. See the note under
xref:4.coroutines/4g.composition.adoc#_fan_outfan_in[Fan-Out/Fan-In].

The rules:

* Take parameters by value unless every caller awaits the task at once.
* A reference parameter is fine for a task that is always awaited in the
expression that creates it, such as a helper a coroutine calls and
immediately awaits.
* Anything passed to a launched or stored task must be owned by its frame or

Check warning on line 95 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).
outlive it.

== Lambda Captures

A lambda coroutine's captures live in the lambda object, not in the frame. A
lambda invoked and discarded in one expression is gone by the time the
coroutine resumes, and its captures with it. This applies to captures by value
as much as by reference, and to `this`.

Pass what the coroutine needs as parameters instead, which the frame copies.
xref:4.coroutines/4i.lambda-captures.adoc[Lambda Coroutine Captures] shows the
failure and the safe patterns.

== Buffers

An I/O operation does not copy the memory a buffer refers to. The storage must
outlive the operation, which it does when the operation is awaited from the
frame that owns the storage. Returning or storing the awaitable instead of
awaiting it lets the storage go first.
xref:5.buffers/5a.contiguous-buffers.adoc#_lifetime_management[Lifetime Management]
shows both cases.

Take a buffer sequence by value in a coroutine that stores it, for the same
reason as any other parameter. The buffer descriptors are copied; the memory
they describe is not.

== Inside an Awaitable

An awaitable is given an cpp:io_env[] pointer in `await_suspend`. The
environment belongs to the launcher that started the chain and lives until the
awaitable's coroutine resumes or is unwound, so the awaitable can keep the
pointer. The cpp:continuation[] it hands to an executor must keep a stable
address until the executor runs it, so it belongs in the awaitable, not on the
stack.

xref:4.coroutines/4e.io-awaitable.adoc#_implementing_custom_ioawaitables[Implementing Custom IoAwaitables]
lists these rules, and the rules for stop callbacks.

== Who Owns a Suspended Coroutine

A chain of coroutines awaiting one another is split into _segments_. A segment
is the run of coroutines started together: the task cpp:run_async[] launches

Check warning on line 137 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).
and everything it awaits, a task cpp:run[] starts on another executor, or one
branch of cpp:when_all[] or cpp:when_any[]. The coroutine that started the
segment is its _owner_. It is suspended above the segment until the
segment's top task finishes.

While the chain is suspended, whatever it waits on holds its continuation: an
executor's queue, a strand, a mutex, a pending I/O operation. Normally the
holder resumes it. A holder that will never resume it, because it is being torn

Check warning on line 145 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.SimpleTense] Avoid needless future/perfect tense — prefer present simple (style guide C4): 'will '.
down, passes the continuation to cpp:abandon[] instead. The segment's owner
then destroys the segment from its top frame down, on its own executor, and
passes the unwind to the segment above. Nothing in an abandoned chain is
resumed: no code after a suspension point runs, no result is delivered, and
cpp:run_async[] calls neither of its handlers.

What does run is destructors. Each frame's locals are destroyed in the usual
order, so RAII still releases what the chain held:

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=unwind]
----
<1> Runs whether the coroutine finishes or is unwound while suspended.
<2> Not reached if the chain is abandoned while waiting: the frame is destroyed
without being resumed.

Abandoning is not cancellation. To stop work, request a stop on the token the
work observes: it resumes with an error and returns through its own code.
Abandoning is what teardown does to work that was not stopped first. See
xref:4.coroutines/4f.cancellation.adoc[Stop Tokens and Cancellation].

Never call `destroy()` on the handle of a coroutine that another coroutine is
awaiting. The awaiting frame still holds it and destroys it again. A task whose
frame is destroyed while it is awaited, outside the unwind of an abandoned
chain, calls `std::terminate`.

== Shutdown Ordering

Destroying an execution context, such as a cpp:thread_pool[], abandons the
coroutines still queued or suspended in it, including those queued on its
strands. Each chain is unwound
on the executor of the coroutine that started it. When that executor belongs to
the context being destroyed, the unwind runs during the destruction. When it
belongs to another context, the unwind runs there later.

Destroying an cpp:async_mutex[], cpp:async_event[] or cpp:async_waker[] is
different: each waiter is resumed with an error, as a stop request would
resume it, and returns through its own code.

That gives the ordering rules:

* Stop work and let it finish before destroying what it runs on, rather than
relying on teardown to unwind it.
* A chain whose frames hold objects of one context, or wait on one context's
operations, must finish before that context is destroyed. If it was started
on another context, its unwind would run after the first context is gone.
* The executors, memory resources and allocators given to cpp:run_async[] and
cpp:run[] must outlive the chains they start, including their unwind.
* No thread may post to a context during or after its destruction.

The pattern is to request a stop, wait for the work to end, then destroy:

[source,cpp]
----
include::example$snippets/4j_lifetimes.cpp[tag=shutdown_order,indent=0]
----
<1> The work observes the token and finishes through its own code.
<2> `join` waits for the outstanding work, then for the worker threads.
<3> The pool is destroyed with nothing left to abandon.

== Summary

[cols="1,2"]
|===
| Situation | Rule

| Task parameter
| By value, unless every caller awaits the task in the same expression.

| Launched or stored task

Check warning on line 216 in doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.Terminology] Use 'start' for one-term-per-concept consistency (style guide C.1).
| Everything it reads is owned by its frame or outlives it.

| Lambda coroutine
| Pass what it needs as parameters, not captures.

| Buffers
| The storage outlives the operation; await it, do not store it.

| Awaitable
| Keep the `io_env` pointer; keep the `continuation` in the awaitable.

| Suspended coroutine
| Its holder resumes it or abandons it; never `destroy()` an awaited handle.

| Teardown
| Stop, wait for the work to end, then destroy the context.
|===
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/8.examples/8a.hello-task.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ include::example$hello-task/hello_task.cpp[tag=pool,indent=0]

cpp:thread_pool[] provides an execution context with worker threads. By default, it creates one thread per CPU core.

Call `pool.join()` before the pool is destroyed to wait for all outstanding work to finish. The destructor does *not* wait for queued work: `~thread_pool` calls `stop()`, which abandons any work that has not yet started, then joins the worker threads. Without the explicit `pool.join()`, `main` could destroy the pool and exit before `say_hello` ever runs, so `"Hello from Capy!"` might never print.
Call `pool.join()` before the pool is destroyed to wait for all outstanding work to finish. The destructor does *not* wait for queued work: `~thread_pool` calls `stop()` and joins the worker threads, then gives up any work that has not yet started. Without the explicit `pool.join()`, `main` could destroy the pool and exit before `say_hello` ever runs, so `"Hello from Capy!"` might never print.

=== Starting the Task

Expand Down
4 changes: 2 additions & 2 deletions doc/modules/ROOT/pages/8.examples/8n.custom-executor.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ target_link_libraries(custom_executor PRIVATE Boost::capy)
include::example$custom-executor/custom_executor.cpp[tag=inherit]
----

The destructor must call `shutdown()` then `destroy()` to clean up coroutine state.
The destructor must call `shutdown()`, give up any work still queued with cpp:abandon[], then call `destroy()`. Destroying a service can post more work, and unwinding an abandoned chain can create a service, so the destructor repeats `destroy()` and the drain until a drain finds nothing. The queue holds copies of each cpp:continuation[], so the owner that cpp:abandon[] needs travels with the handle.

=== The Executor Concept

Expand All @@ -57,7 +57,7 @@ include::example$custom-executor/custom_executor.cpp[tag=concept_check]
include::example$custom-executor/custom_executor.cpp[tag=dispatch,indent=0]
----

`dispatch` takes a `continuation&` and checks whether the caller is already running on the loop's thread. If so, it returns `c.h` directly for inline resumption via symmetric transfer. Otherwise it enqueues `c.h` and returns `noop_coroutine` so the caller continues without blocking.
`dispatch` takes a `continuation&` and checks whether the caller is already running on the loop's thread. If so, it returns `c.h` directly for inline resumption via symmetric transfer. Otherwise it enqueues a copy of `c` and returns `noop_coroutine` so the caller continues without blocking.

`post` always enqueues, even if already on the right thread.

Expand Down
4 changes: 2 additions & 2 deletions doc/modules/ROOT/pages/8.examples/8q.gui-integration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The program links no GUI library. It uses a stand-in instead.
include::example$gui-integration/gui_integration.cpp[tag=gui_app]
----

`gui_app` records the thread it is constructed on, holds a queue of coroutine handles, and exposes `post_to_gui_thread`. That last operation is the one primitive every toolkit already provides. Qt names it `QMetaObject::invokeMethod`, GTK `g_idle_add`, wxWidgets `wxEvtHandler::CallAfter`, Win32 `PostMessage`.
`gui_app` records the thread it is constructed on, holds a queue of continuations, and exposes `post_to_gui_thread`. That last operation is the one primitive every toolkit already provides. Qt names it `QMetaObject::invokeMethod`, GTK `g_idle_add`, wxWidgets `wxEvtHandler::CallAfter`, Win32 `PostMessage`.

=== The Event Loop

Expand Down Expand Up @@ -158,7 +158,7 @@ The completion handler runs on the GUI thread, because the task completed there.

The stand-in exists to prove the contract, not to model a toolkit. Five pieces change when the toolkit is real, and the rest does not:

* `post_to_gui_thread` forwards to the toolkit's own post operation, and the receiving side calls cpp:safe_resume[] on the handle.
* `post_to_gui_thread` forwards to the toolkit's own post operation, and the receiving side calls cpp:safe_resume[] on the handle. Work still queued when the loop is torn down is given up with cpp:abandon[], so its chain unwinds instead of leaking.
* `gui_app::run` disappears. The toolkit already owns a loop, such as `QApplication::exec`, `g_main_loop_run`, or `wxApp::OnRun`.
* `gui_app::quit` forwards to the toolkit's own quit, such as `QCoreApplication::quit`, `g_main_loop_quit`, or `wxAppConsole::ExitMainLoop`.
* `label` becomes a real widget. Its thread check is the part the toolkit does not do for you.
Expand Down
Loading
Loading