libxtc 0.4.0
Async concurrency for C: Tokio + Seastar + BEAM, in one library
Loading...
Searching...
No Matches
coro_common.h
1/*-
2 * Copyright (c) 2026, The XTC Project
3 * Use of this source code is governed by the ISC License.
4 *
5 * src/inc/coro_common.h
6 * Shared, substrate-agnostic memory-management helpers for the
7 * mmap-based coroutine substrates (coro_fctx.c, coro_uctx.c).
8 * Exactly one substrate is compiled per build (the others are empty
9 * TUs), so the `static` definitions here are instantiated once, into
10 * whichever substrate includes this header. This consolidates code
11 * that was previously duplicated byte-for-byte between fctx and uctx:
12 * - the per-thread fiber-stack pool (recycle freed stacks so a
13 * spawn skips mmap+mprotect),
14 * - the S1 stack-reclaim lever (madvise the unused stack tail on
15 * park).
16 *
17 * The winfiber substrate owns its stacks via CreateFiberEx (no mmap,
18 * no madvise), so it does NOT include this header. The sanitizer
19 * fiber-switch annotations remain per-substrate (their switch call
20 * sites are substrate-specific), so they are deliberately NOT here.
21 *
22 * Include AFTER coro_int.h (for struct xtc_coro) and the substrate's
23 * own headers (needs sysconf, madvise, atomics, XTC_THREAD_LOCAL).
24 */
25
26#ifndef XTC_CORO_COMMON_H
27#define XTC_CORO_COMMON_H
28
29/* Self-contained: __stack_pool_drain calls munmap and the reclaim lever
30 * madvise. The includer usually has these already, but the amalgamation
31 * emits this header before coro_uctx.c's own <sys/mman.h>, and macOS
32 * rejects the implicit declaration (and then the late asm-labelled one). */
33#include <sys/mman.h>
34#include <unistd.h>
35
36#include "preempt_int.h" /* __xtc_unsafe_enter/leave around the spare lock */
37
38/* ---- Valgrind stack registration ----------------------------------
39 *
40 * memcheck tracks one stack per thread and treats a stack-pointer jump
41 * into memory it does not know as a stack switch only on a heuristic
42 * ("client switching stacks?"); after a fiber resumes on another thread
43 * it reports the fiber's own frames as invalid reads and writes. Each
44 * coroutine registers its usable stack range when it is created and
45 * deregisters it before the stack is pooled or unmapped, so memcheck
46 * knows every fiber stack. Outside valgrind the requests are skipped: a
47 * client request is an asm with a memory clobber that cost 1.9% of
48 * spawn+exit throughput, so RUNNING_ON_VALGRIND is asked once per process
49 * and the result cached. */
50#if defined(XTC_HAVE_VALGRIND_H)
51# include <valgrind/valgrind.h>
52/* -1 not asked yet, 0 native, 1 under valgrind. Racy first use is
53 * benign: every thread computes the same answer. The requests live in
54 * cold out-of-line functions so their asm (a memory clobber) stays out
55 * of the spawn path; a native run pays one load and branch per spawn and
56 * per exit. */
57static _Atomic int __xtc_vg_on = -1;
58
59__attribute__((noinline, cold)) static unsigned
60__xtc_vg_register_slow(char *lo, size_t sz)
61{
62 int on = atomic_load_explicit(&__xtc_vg_on, memory_order_relaxed);
63 if (on < 0) {
64 on = RUNNING_ON_VALGRIND != 0;
65 atomic_store_explicit(&__xtc_vg_on, on, memory_order_relaxed);
66 }
67 return on ? (unsigned)VALGRIND_STACK_REGISTER(lo, lo + sz) : 0u;
68}
69
70/* Only reached after a register, so the probe has run. Valgrind's ids
71 * start at 0: whether one was taken is a property of the process. */
72__attribute__((noinline, cold)) static void
73__xtc_vg_deregister_slow(unsigned id)
74{
75 VALGRIND_STACK_DEREGISTER(id);
76}
77
78# define __xtc_vg_stack_register(lo, sz) \
79 (XTC_UNLIKELY(atomic_load_explicit(&__xtc_vg_on, \
80 memory_order_relaxed) != 0) ? \
81 __xtc_vg_register_slow((char *)(lo), (sz)) : 0u)
82# define __xtc_vg_stack_deregister(id) \
83 do { \
84 if (XTC_UNLIKELY(atomic_load_explicit(&__xtc_vg_on, \
85 memory_order_relaxed) > 0)) \
86 __xtc_vg_deregister_slow(id); \
87 } while (0)
88#else
89# define __xtc_vg_stack_register(lo, sz) ((void)(lo), (void)(sz), 0u)
90# define __xtc_vg_stack_deregister(id) ((void)(id))
91#endif
92
93/* ---- Fiber-stack pool (per-thread) --------------------------------
94 *
95 * Every fresh fiber stack costs an mmap + an mprotect(PROT_NONE) for its
96 * guard page, and mprotect write-locks the process-wide address-space
97 * lock (mmap_lock) -- so many threads spawning fibers concurrently
98 * serialize in the kernel, capping spawn throughput far below the core
99 * count (192-core finding: spawn flat ~7 M/s while the scheduler
100 * scales to 249 M/s). A per-thread free list recycles freed stacks --
101 * the guard page is already installed, so a reused stack skips BOTH the
102 * mmap and the mprotect, and the list is thread-local so the hot path
103 * takes no lock. Bounded so idle threads do not hoard memory.
104 *
105 * Stacks are pooled only at the current __xtc_stack_size; a stack freed
106 * at a different size (after xtc_set_stack_size) is munmap'd, not
107 * pooled, so the pool never hands back a wrong-sized stack. */
108#define XTC_STACK_POOL_MAX 64
110 void *slots[XTC_STACK_POOL_MAX];
111 size_t size; /* stack_sz these were allocated at */
112 int n;
113 int atexit_armed; /* thread-exit drain registered */
114};
115static XTC_THREAD_LOCAL struct stack_pool __stack_pool;
116
117/* Thread exit: unmap the stacks still pooled. Finished fibers now feed
118 * the pool while a loop runs (not only at loop_fini), so an executor
119 * worker would otherwise strand up to XTC_STACK_POOL_MAX stacks -- two
120 * mappings each -- every time it exits. */
121static void
122__stack_pool_drain(void *arg)
123{
124 struct stack_pool *p = &__stack_pool;
125 long pg = sysconf(_SC_PAGESIZE);
126 size_t guard = pg > 0 ? (size_t)pg : 4096u;
127 (void)arg;
128 while (p->n > 0)
129 (void)munmap(p->slots[--p->n], p->size + guard);
130 p->atexit_armed = 0;
131}
132
133/* Pop a cached stack of `stack_sz` (guard already installed), or NULL. */
134static void *
135__stack_pool_get(size_t stack_sz)
136{
137 struct stack_pool *p = &__stack_pool;
138 if (p->n > 0 && p->size == stack_sz)
139 return p->slots[--p->n];
140 return NULL;
141}
142
143/* Return a stack to the pool if it fits (same size, room left); else the
144 * caller munmaps. Returns 1 if pooled, 0 if the caller must free it. */
145static int
146__stack_pool_put(void *base, size_t stack_sz)
147{
148 struct stack_pool *p = &__stack_pool;
149 if (!p->atexit_armed) {
150 if (__os_thread_atexit(__stack_pool_drain, NULL) != XTC_OK)
151 return 0; /* cannot drain at exit: do not pool */
152 p->atexit_armed = 1;
153 }
154 if (p->n == 0) p->size = stack_sz; /* first entry sets the pool size */
155 if (p->size != stack_sz || p->n == XTC_STACK_POOL_MAX)
156 return 0;
157 p->slots[p->n++] = base;
158 return 1;
159}
160
161/* ---- Fiber-stack spare (per loop) ---------------------------------
162 *
163 * The pool above recycles a stack only on the thread that freed it. A
164 * proc spawned onto ANOTHER loop takes its stack on the spawner's thread
165 * and frees it on the target's (since 1.56 at DONE, not at loop_fini), so
166 * in the round-robin acceptor shape the spawner's pool is always empty
167 * (mmap + mprotect per spawn) and the target's always full (munmap per
168 * exit, which with every loop thread running is a TLB-shootdown IPI to
169 * each of them). So a stack the finishing thread cannot pool goes to a
170 * small locked spare on its task's HOME loop, and a spawn onto that loop
171 * whose thread pool is empty takes from it. Bounded per loop
172 * (XTC_LOOP_STACK_SPARE), filled only on a loop that other threads spawn
173 * onto, and unmapped at xtc_loop_fini, so retention stays bounded by the
174 * live count plus the pools, never by history.
175 * The spinlock is held for a few loads and stores; never across a yield,
176 * an allocation, a syscall or another lock; preemption deferred. */
177static void
178__stack_spare_lock(xtc_loop_t *l)
179{
180 __xtc_unsafe_enter();
181 while (atomic_exchange_explicit(&l->stack_spare_lock, 1,
182 memory_order_acquire))
183 while (atomic_load_explicit(&l->stack_spare_lock,
184 memory_order_relaxed))
185 __os_pause();
186}
187
188static void
189__stack_spare_unlock(xtc_loop_t *l)
190{
191 atomic_store_explicit(&l->stack_spare_lock, 0, memory_order_release);
192 __xtc_unsafe_leave();
193}
194
195/* Pop a spare stack of `stack_sz` from loop l, or NULL. Any thread. */
196static void *
197__stack_spare_get(xtc_loop_t *l, size_t stack_sz)
198{
199 void *base = NULL;
200 int n;
201 if (__xtc_current_loop != l &&
202 atomic_load_explicit(&l->stack_spare_want, memory_order_relaxed) <
203 XTC_LOOP_STACK_SPARE)
204 atomic_fetch_add_explicit(&l->stack_spare_want, 1,
205 memory_order_relaxed);
206 if (atomic_load_explicit(&l->stack_spare_n, memory_order_relaxed) == 0)
207 return NULL;
208 __stack_spare_lock(l);
209 n = atomic_load_explicit(&l->stack_spare_n, memory_order_relaxed);
210 if (n > 0 && l->stack_spare_sz == stack_sz) {
211 base = l->stack_spare[--n];
212 atomic_store_explicit(&l->stack_spare_n, n,
213 memory_order_relaxed);
214 }
215 __stack_spare_unlock(l);
216 return base;
217}
218
219/* Give a stack to loop l's spare. 1 if taken, 0 if the caller must
220 * munmap it (spare full or a different size). Any thread. */
221static int
222__stack_spare_put(xtc_loop_t *l, void *base, size_t stack_sz)
223{
224 int n, ok = 0;
225 if (atomic_load_explicit(&l->stack_spare_want, memory_order_relaxed) <
226 XTC_LOOP_STACK_SPARE ||
227 atomic_load_explicit(&l->stack_spare_n, memory_order_relaxed) >=
228 XTC_LOOP_STACK_SPARE)
229 return 0;
230 __stack_spare_lock(l);
231 n = atomic_load_explicit(&l->stack_spare_n, memory_order_relaxed);
232 if (n == 0)
233 l->stack_spare_sz = stack_sz; /* first entry sets the size */
234 if (l->stack_spare_sz == stack_sz && n < XTC_LOOP_STACK_SPARE) {
235 l->stack_spare[n++] = base;
236 atomic_store_explicit(&l->stack_spare_n, n,
237 memory_order_relaxed);
238 ok = 1;
239 }
240 __stack_spare_unlock(l);
241 return ok;
242}
243
244/* xtc_loop_fini, after every task's cleanup has run (single-threaded). */
245void
246__xtc_coro_stack_spare_fini(xtc_loop_t *l)
247{
248 long pg = sysconf(_SC_PAGESIZE);
249 size_t guard = pg > 0 ? (size_t)pg : 4096u;
250 int n = atomic_load_explicit(&l->stack_spare_n, memory_order_relaxed);
251 while (n > 0)
252 (void)munmap(l->stack_spare[--n], l->stack_spare_sz + guard);
253 atomic_store_explicit(&l->stack_spare_n, 0, memory_order_relaxed);
254}
255
256/* ---- Lever S1: stack-memory reclamation on park -------------------
257 *
258 * OFF by default. When enabled, a parking fiber returns the unused
259 * tail of its stack (everything below its current SP, beyond a live
260 * margin, page-aligned, above the guard) to the OS with
261 * madvise(MADV_DONTNEED); it faults back zero-filled on resume. See
262 * xtc_async.h. */
263#if defined(MADV_DONTNEED)
264static _Atomic int g_reclaim_on = 0;
265static _Atomic size_t g_reclaim_keep = 0; /* live margin below SP */
266static _Atomic uint64_t g_reclaim_count = 0; /* madvise calls made */
267
268int
269xtc_stack_reclaim_enable(size_t keep_bytes)
270{
271 long pg = sysconf(_SC_PAGESIZE);
272 size_t page = (pg > 0) ? (size_t)pg : 4096u;
273 if (keep_bytes == 0)
274 keep_bytes = page; /* default live margin: one page */
275 atomic_store(&g_reclaim_keep, keep_bytes);
276 atomic_store(&g_reclaim_on, 1);
277 return XTC_OK;
278}
279
280void
281xtc_stack_reclaim_disable(void)
282{
283 atomic_store(&g_reclaim_on, 0);
284}
285
286int
287xtc_stack_reclaim_enabled(void)
288{
289 return atomic_load(&g_reclaim_on);
290}
291
292uint64_t
293xtc_stack_reclaim_count(void)
294{
295 return atomic_load(&g_reclaim_count);
296}
297
298/*
299 * Reclaim the unused tail of the current fiber's stack. Called from a
300 * PARK path with `sp` = the fiber's current stack pointer (a stack
301 * address captured by the caller just before it jumps to the
302 * scheduler). Stacks grow DOWN, so the unused region is
303 * [stack_base + guard, sp - keep), page-aligned inward. No-op unless
304 * the reclaimable span exceeds one page (avoids fault churn on shallow
305 * fibers).
306 */
307static void
308coro_stack_shrink(struct xtc_coro *c, void *sp)
309{
310 long pgl;
311 size_t page, keep;
312 uintptr_t lo, hi, guard, base;
313
314 if (c == NULL || c->stack == NULL || !atomic_load(&g_reclaim_on))
315 return;
316 pgl = sysconf(_SC_PAGESIZE);
317 page = (pgl > 0) ? (size_t)pgl : 4096u;
318 guard = page; /* one guard page at the base */
319 keep = atomic_load(&g_reclaim_keep);
320
321 base = (uintptr_t)c->stack + guard; /* first usable byte */
322 hi = (uintptr_t)sp;
323 if (hi <= base + keep)
324 return; /* SP too close to the base */
325 /* Reclaimable tail: [base, hi - keep), rounded to whole pages so we
326 * never touch a partially-live page. */
327 hi = (hi - keep) & ~(uintptr_t)(page - 1); /* round DOWN */
328 lo = (base + page - 1) & ~(uintptr_t)(page - 1); /* round UP */
329 if (hi <= lo || hi - lo < page)
330 return; /* nothing worth a syscall */
331 if (madvise((void *)lo, (size_t)(hi - lo), MADV_DONTNEED) == 0)
332 (void)atomic_fetch_add(&g_reclaim_count, 1);
333}
334#else /* no MADV_DONTNEED: reclaim is a no-op */
335int xtc_stack_reclaim_enable(size_t k) { (void)k; return XTC_E_NOSYS; }
336void xtc_stack_reclaim_disable(void) { }
337int xtc_stack_reclaim_enabled(void) { return 0; }
338uint64_t xtc_stack_reclaim_count(void) { return 0; }
339#define coro_stack_shrink(c, sp) ((void)(c), (void)(sp))
340#endif
341
342#endif /* XTC_CORO_COMMON_H */