Skip to content

Commit 709124c

Browse files
committed
src: add coroutine README.md
1 parent bef920c commit 709124c

File tree

1 file changed

+209
-0
lines changed

1 file changed

+209
-0
lines changed

src/coro/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# C++20 Coroutine support for libuv
2+
3+
This directory contains an experimental C++20 coroutine layer for writing
4+
asynchronous libuv operations as sequential C++ code using `co_await`.
5+
6+
The primary goal is to allow multi-step async operations (such as
7+
open + stat + read + close) to be written as straight-line C++ instead of
8+
callback chains, while maintaining full integration with Node.js async_hooks,
9+
AsyncLocalStorage, microtask draining, and environment lifecycle management.
10+
11+
## File overview
12+
13+
* `uv_task.h` -- `UvTask<T>`: The lightweight, untracked coroutine return type.
14+
No V8 or Node.js dependencies. Suitable for internal C++ coroutines that do
15+
not need async_hooks visibility or task queue draining.
16+
17+
* `uv_tracked_task.h` -- `UvTrackedTask<T, Name>`: The fully-integrated
18+
coroutine return type. Each resume-to-suspend segment is wrapped in an
19+
`InternalCallbackScope`, giving it the same semantics as any other callback
20+
entry into Node.js. The `Name` template parameter is a compile-time string
21+
that identifies the async resource type visible to `async_hooks.createHook()`.
22+
23+
* `uv_awaitable.h` -- Awaitable wrappers for libuv async operations:
24+
`UvFsAwaitable` (fs operations), `UvFsStatAwaitable` (stat-family),
25+
`UvWorkAwaitable` (thread pool work), and `UvGetAddrInfoAwaitable`
26+
(DNS resolution). Each embeds the libuv request struct directly in the
27+
coroutine frame, avoiding separate heap allocations.
28+
29+
* `uv_promise.h` -- Helpers for bridging coroutines to JavaScript Promises:
30+
`MakePromise()`, `ResolvePromise()`, `RejectPromiseWithUVError()`.
31+
32+
## Usage
33+
34+
### Basic pattern (binding function)
35+
36+
```cpp
37+
// The coroutine. The return type carries the async resource name as
38+
// a compile-time template argument.
39+
static coro::UvTrackedTask<void, "FSREQPROMISE"> DoAccessImpl(
40+
Environment* env,
41+
v8::Global<v8::Promise::Resolver> resolver,
42+
std::string path,
43+
int mode) {
44+
ssize_t result = co_await coro::UvFs(
45+
env->event_loop(), uv_fs_access, path.c_str(), mode);
46+
if (result < 0)
47+
coro::RejectPromiseWithUVError(env, resolver, result, "access",
48+
path.c_str());
49+
else
50+
coro::ResolvePromiseUndefined(env, resolver);
51+
}
52+
53+
// The binding entry point (called from JavaScript).
54+
static void Access(const FunctionCallbackInfo<Value>& args) {
55+
Environment* env = Environment::GetCurrent(args);
56+
// ... parse args, check permissions ...
57+
58+
auto resolver = coro::MakePromise(env, args);
59+
auto task = DoAccessImpl(env, std::move(resolver), path, mode);
60+
task.InitTracking(env); // assigns async_id, captures context, emits init
61+
task.Start(); // begins execution (fire-and-forget)
62+
}
63+
```
64+
65+
### Multi-step operations
66+
67+
Multiple libuv calls within a single coroutine are sequential co_await
68+
expressions. The intermediate steps (between two co_await points) are pure C++
69+
with no V8 overhead:
70+
71+
```cpp
72+
static coro::UvTrackedTask<void, "COROREADFILE"> ReadFileImpl(
73+
Environment* env,
74+
v8::Global<v8::Promise::Resolver> resolver,
75+
std::string path) {
76+
ssize_t fd = co_await coro::UvFs(
77+
env->event_loop(), uv_fs_open, path.c_str(), O_RDONLY, 0);
78+
if (fd < 0) { /* reject and co_return */ }
79+
80+
auto [err, stat] = co_await coro::UvFsStat(
81+
env->event_loop(), uv_fs_fstat, static_cast<uv_file>(fd));
82+
// ... read, close, resolve ...
83+
}
84+
```
85+
86+
### Coroutine composition
87+
88+
`UvTask<T>` and `UvTrackedTask<T, Name>` can be co_awaited from other
89+
coroutines. This allows factoring common operations into reusable helpers:
90+
91+
```cpp
92+
UvTask<ssize_t> OpenFile(uv_loop_t* loop, const char* path, int flags) {
93+
co_return co_await UvFs(loop, uv_fs_open, path, flags, 0);
94+
}
95+
96+
UvTrackedTask<void, "MYOP"> OuterCoroutine(Environment* env, ...) {
97+
ssize_t fd = co_await OpenFile(env->event_loop(), path, O_RDONLY);
98+
// ...
99+
}
100+
```
101+
102+
## Lifecycle
103+
104+
### UvTask (untracked)
105+
106+
`UvTask<T>` uses lazy initialization. The coroutine does not run until it is
107+
either co_awaited from another coroutine (symmetric transfer) or explicitly
108+
started with `Start()`. When `Start()` is called, the coroutine runs until its
109+
first `co_await`, then control returns to the caller. The coroutine frame
110+
self-destructs when the coroutine completes.
111+
112+
### UvTrackedTask (tracked)
113+
114+
`UvTrackedTask<T, Name>` follows the same lazy/fire-and-forget pattern but
115+
adds three phases around `Start()`:
116+
117+
1. **Creation**: The coroutine frame is heap-allocated by the compiler.
118+
The coroutine is suspended at `initial_suspend` (lazy).
119+
120+
2. **`InitTracking(env)`**: Assigns an `async_id`, captures the current
121+
`async_context_frame` (for AsyncLocalStorage propagation), creates a
122+
resource object for `executionAsyncResource()`, emits the async_hooks
123+
`init` event and a trace event, registers in the Environment's coroutine
124+
task list, and reports external memory to V8.
125+
126+
3. **`Start()`**: Marks the task as detached (fire-and-forget) and resumes
127+
the coroutine. Each resume-to-suspend segment is wrapped in an
128+
`InternalCallbackScope` that provides:
129+
* async_hooks `before`/`after` events
130+
* `async_context_frame` save/restore (AsyncLocalStorage)
131+
* Microtask and `process.nextTick` draining on close
132+
* `request_waiting_` counter management for event loop liveness
133+
134+
4. **Completion**: At `final_suspend`, the last `InternalCallbackScope` is
135+
closed (draining task queues), the async_hooks `destroy` event is emitted,
136+
the task is unregistered from the Environment, external memory accounting
137+
is released, and the coroutine frame is freed.
138+
139+
## How the awaitable dispatch works
140+
141+
The `UvFs()` factory function returns a `UvFsAwaitable` that embeds a `uv_fs_t`
142+
directly in the coroutine frame. When the coroutine hits `co_await`:
143+
144+
1. `await_transform()` on the promise wraps it in a `TrackedAwaitable`.
145+
2. `TrackedAwaitable::await_suspend()`:
146+
* Closes the current `InternalCallbackScope` (drains microtasks/nextTick).
147+
* Records the `uv_req_t*` for cancellation support.
148+
* Increments `request_waiting_` (event loop liveness).
149+
* Calls the inner `await_suspend()`, which dispatches the libuv call with
150+
`req_.data = this` pointing back to the awaitable.
151+
3. The coroutine is suspended. Control returns to the event loop.
152+
4. When the libuv operation completes, `OnComplete()` calls
153+
`handle_.resume()` to resume the coroutine.
154+
5. `TrackedAwaitable::await_resume()`:
155+
* Decrements `request_waiting_`.
156+
* Clears the cancellation pointer.
157+
* Opens a new `InternalCallbackScope` for the next segment.
158+
* Returns the result (e.g., `req_.result` for fs operations).
159+
160+
## Environment teardown
161+
162+
During `Environment::CleanupHandles()`, the coroutine task list is iterated and
163+
`Cancel()` is called on each active task. This calls `uv_cancel()` on the
164+
in-flight libuv request (if any), which causes the libuv callback to fire with
165+
`UV_ECANCELED`. The coroutine resumes, sees the error, and completes normally.
166+
The `request_waiting_` counter ensures the teardown loop waits for all
167+
coroutine I/O to finish before destroying the Environment.
168+
169+
## Allocation comparison with ReqWrap
170+
171+
For a single async operation (e.g., `fsPromises.access`):
172+
173+
| | ReqWrap pattern | Coroutine pattern |
174+
|---|---|---|
175+
| C++ heap allocations | 3 | 1 (coroutine frame) |
176+
| V8 heap objects | 7 | 3 (resource + resolver + promise) |
177+
| Total allocations | 10 | 4 |
178+
179+
For a multi-step operation (open + stat + read + close):
180+
181+
| | 4x ReqWrap | Single coroutine |
182+
|---|---|---|
183+
| C++ heap allocations | 12 | 1 |
184+
| V8 heap objects | 28 | 3 |
185+
| Total allocations | 40 | 4 |
186+
| InternalCallbackScope entries | 4 | 5 (one per segment + initial) |
187+
188+
The coroutine frame embeds the `uv_fs_t` (~440 bytes) directly. The compiler
189+
may overlay non-simultaneously-live awaitables in the frame, so a multi-step
190+
coroutine does not necessarily pay N times the `uv_fs_t` cost.
191+
192+
## Known limitations
193+
194+
* **Heap snapshot visibility**: The coroutine frame is a plain `malloc`
195+
allocated by the C++ coroutine machinery. It is not visible to V8 heap
196+
snapshots or `MemoryRetainer`. `AdjustAmountOfExternalAllocatedMemory` is
197+
used to give V8 a rough signal of the external memory pressure, but the
198+
exact frame contents are not inspectable.
199+
200+
* **Snapshot serialization**: `UvTrackedTask` holds `v8::Global` handles that
201+
cannot be serialized into a startup snapshot. There is currently no safety
202+
check to prevent snapshotting while coroutines are active. In practice this
203+
is not a problem because snapshots are taken at startup before I/O begins.
204+
205+
* **Trace event names**: The existing `AsyncWrap` trace events use a
206+
`ProviderType` enum with a switch statement to select the trace event name.
207+
The coroutine pattern uses free-form string names. The `init` trace event
208+
uses the provided name; the `destroy` trace event currently uses a generic
209+
`"coroutine"` category name rather than the per-instance name.

0 commit comments

Comments
 (0)