Skip to content

Commit 6a9c754

Browse files
committed
src: use per-env cached strings for coroutine type names
1 parent 6b6c6a3 commit 6a9c754

File tree

2 files changed

+59
-40
lines changed

2 files changed

+59
-40
lines changed

src/coro/README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ adds three phases around `Start()`:
128128
cancellation during teardown. If async\_hooks listeners are active
129129
(`kInit > 0` or `kUsesExecutionAsyncResource > 0`), a resource object
130130
is created for `executionAsyncResource()` and the `init` hook is emitted.
131-
The type name V8 string is cached per template instantiation via
132-
`v8::Eternal<v8::String>`, so only the first coroutine of a given type
133-
pays the `String::NewFromUtf8` cost.
131+
The type name V8 string is cached per Isolate in
132+
`IsolateData::static_str_map`, so only the first coroutine of a given
133+
type per Isolate pays the `String::NewFromUtf8` cost.
134134
135135
3. **`Start()`**: Marks the task as detached (fire-and-forget) and resumes
136136
the coroutine. Each resume-to-suspend segment is wrapped in an
@@ -194,6 +194,13 @@ frames up to 4096 bytes (which covers typical coroutine frames). Frames larger
194194
than 4096 bytes fall through to the global `operator new`. Since all coroutines
195195
run on the event loop thread, the free-list requires no locking.
196196
197+
Each bucket has a high-water mark of 32 cached frames. When a frame is freed
198+
and its bucket is already at capacity, the frame is returned directly to the
199+
system allocator instead of being cached. This bounds the retained memory
200+
per bucket to at most 32 \* bucket\_size bytes (e.g., 32 \* 1024 = 32KB for the
201+
1024-byte size class), preventing unbounded growth after a burst of concurrent
202+
coroutines.
203+
197204
After the first coroutine of a given size class completes, subsequent
198205
coroutines of the same size class are allocated from the free-list with zero
199206
`malloc` overhead.
@@ -239,14 +246,14 @@ coroutine does not necessarily pay N times the `uv_fs_t` cost.
239246
uses the provided name; the `destroy` trace event currently uses a generic
240247
`"coroutine"` category name rather than the per-instance name.
241248
242-
* **Free-list growth**: The thread-local free-list does not have a cap on the
243-
number of cached frames per size class. Under a workload that creates a
244-
large burst of concurrent coroutines and then goes idle, the free-list will
245-
retain all of those frames until the thread exits. A maximum per-bucket
246-
count could be added if this becomes a concern.
247-
248-
* **Static Eternal handles**: The cached type name `v8::Eternal<v8::String>`
249-
is a static variable per template instantiation. It is never freed and is
250-
shared across all Isolates on the same thread. This is safe for the
251-
single-Isolate case (the common case for Node.js), but would need
252-
per-Isolate caching if multiple Isolates use the same coroutine types.
249+
* **Free-list retention**: The thread-local free-list retains up to 32 frames
250+
per size class bucket after a burst of concurrent coroutines. These frames
251+
are held until reused or the thread exits. The bound is configurable via
252+
`kMaxCachedPerBucket`.
253+
254+
* **Cached type name strings**: The type name `v8::Eternal<v8::String>` is
255+
cached in `IsolateData::static_str_map`, keyed by the `const char*` from
256+
the `ConstString` template parameter. This is per-Isolate and safe with
257+
Worker threads (each Worker has its own `IsolateData`). The Eternal handles
258+
are never freed, but there is at most one per unique type name string per
259+
Isolate.

src/coro/uv_tracked_task.h

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ inline constexpr size_t kSizeClassGranularity = 256;
6060
// Number of size class buckets.
6161
inline constexpr size_t kNumSizeClasses =
6262
kMaxCachedFrameSize / kSizeClassGranularity;
63+
// Maximum number of frames to cache per size class bucket.
64+
// Bounds memory retained after a burst of concurrent coroutines.
65+
inline constexpr size_t kMaxCachedPerBucket = 32;
6366

6467
inline size_t SizeClassIndex(size_t n) {
6568
return (n + kSizeClassGranularity - 1) / kSizeClassGranularity - 1;
@@ -69,20 +72,26 @@ inline size_t RoundUpToSizeClass(size_t n) {
6972
return (SizeClassIndex(n) + 1) * kSizeClassGranularity;
7073
}
7174

75+
struct FreeBucket {
76+
FreeBlock* head = nullptr;
77+
size_t count = 0;
78+
};
79+
7280
// Thread-local free lists, one per size class.
7381
// Safe without locks because coroutines run on the event loop thread.
74-
inline FreeBlock*& GetFreeList(size_t index) {
75-
static thread_local FreeBlock* free_lists[kNumSizeClasses] = {};
76-
return free_lists[index];
82+
inline FreeBucket& GetBucket(size_t index) {
83+
static thread_local FreeBucket buckets[kNumSizeClasses] = {};
84+
return buckets[index];
7785
}
7886

7987
inline void* CoroFrameAlloc(size_t n) {
8088
if (n <= kMaxCachedFrameSize) {
8189
size_t idx = SizeClassIndex(n);
82-
FreeBlock*& head = GetFreeList(idx);
83-
if (head != nullptr) {
84-
FreeBlock* block = head;
85-
head = block->next;
90+
FreeBucket& bucket = GetBucket(idx);
91+
if (bucket.head != nullptr) {
92+
FreeBlock* block = bucket.head;
93+
bucket.head = block->next;
94+
bucket.count--;
8695
return block;
8796
}
8897
// Nothing on free list; allocate at rounded-up size.
@@ -94,11 +103,16 @@ inline void* CoroFrameAlloc(size_t n) {
94103
inline void CoroFrameFree(void* p, size_t n) {
95104
if (n <= kMaxCachedFrameSize) {
96105
size_t idx = SizeClassIndex(n);
97-
FreeBlock*& head = GetFreeList(idx);
106+
FreeBucket& bucket = GetBucket(idx);
107+
if (bucket.count >= kMaxCachedPerBucket) {
108+
// Bucket is full; return directly to the system allocator.
109+
::operator delete(p, RoundUpToSizeClass(n));
110+
return;
111+
}
98112
auto* block = static_cast<FreeBlock*>(p);
99-
block->next = head;
100-
block->size = n;
101-
head = block;
113+
block->next = bucket.head;
114+
bucket.head = block;
115+
bucket.count++;
102116
return;
103117
}
104118
::operator delete(p, n);
@@ -183,9 +197,7 @@ struct TrackedPromiseBase : PromiseBase {
183197
static void* operator new(size_t n) { return CoroFrameAlloc(n); }
184198
static void operator delete(void* p, size_t n) { CoroFrameFree(p, n); }
185199

186-
void init_tracking(Environment* env,
187-
const char* type_name,
188-
v8::Eternal<v8::String>& cached_type_name) {
200+
void init_tracking(Environment* env, const char* type_name) {
189201
env_ = env;
190202
v8::Isolate* isolate = env->isolate();
191203
v8::HandleScope handle_scope(isolate);
@@ -217,14 +229,19 @@ struct TrackedPromiseBase : PromiseBase {
217229
}
218230

219231
if (hooks->fields()[AsyncHooks::kInit] > 0) {
220-
// Cache the type name V8 string per template instantiation.
221-
// Eternal handles are immortal and free to dereference.
222-
if (cached_type_name.IsEmpty()) {
223-
cached_type_name.Set(
232+
// Cache the type name string in the per-isolate static_str_map.
233+
// The key is the const char* from ConstString, which is a unique
234+
// pointer per template instantiation. This gives us per-isolate
235+
// caching that is safe with Worker threads (each Worker has its
236+
// own Isolate and IsolateData).
237+
auto& str_map = env->isolate_data()->static_str_map;
238+
v8::Eternal<v8::String>& eternal = str_map[type_name];
239+
if (eternal.IsEmpty()) {
240+
eternal.Set(
224241
isolate,
225242
v8::String::NewFromUtf8(isolate, type_name).ToLocalChecked());
226243
}
227-
v8::Local<v8::String> type = cached_type_name.Get(isolate);
244+
v8::Local<v8::String> type = eternal.Get(isolate);
228245
AsyncWrap::EmitAsyncInit(
229246
env, resource_.Get(isolate), type, async_id_, trigger_async_id_);
230247
}
@@ -372,11 +389,7 @@ class UvTrackedTask {
372389
}
373390

374391
void InitTracking(Environment* env) {
375-
// Per-template-instantiation cache for the V8 type name string.
376-
// Eternal handles are immortal, zero-cost to dereference, and
377-
// safe across GC cycles.
378-
static v8::Eternal<v8::String> cached_type_name;
379-
handle_.promise().init_tracking(env, Name.c_str(), cached_type_name);
392+
handle_.promise().init_tracking(env, Name.c_str());
380393
}
381394

382395
void Start() {
@@ -449,8 +462,7 @@ class UvTrackedTask<void, Name> {
449462
void await_resume() { handle_.promise().rethrow_if_exception(); }
450463

451464
void InitTracking(Environment* env) {
452-
static v8::Eternal<v8::String> cached_type_name;
453-
handle_.promise().init_tracking(env, Name.c_str(), cached_type_name);
465+
handle_.promise().init_tracking(env, Name.c_str());
454466
}
455467

456468
void Start() {

0 commit comments

Comments
 (0)