Repository navigation
Full GC
Status: supported opt-in (src/core/mem/gc.c,
include/cwist/core/mem/gc.h). cwist_full_gc(true) is a real, callable
toggle today, not a future one - see Tutorial 30
(tutorials/30-graceful-shutdown/) for a minimal example. All six design
sections below, including section 5 (transparent malloc interception),
are implemented and tested. This document remains the design contract for
what the mode does and does not cover; the explicit destroy-family model
stays fully supported (and is the default) whether or not full-GC mode is
ever enabled.
CWIST today relies on disciplined explicit cleanup: cwist_app_destroy(),
connection/session teardown at the transport layer, and cwist_free() paired
with request arenas. Two gaps remain:
- Thread/process exit does not close connections. If a worker thread (or the whole process) ends without the explicit destroy path running, its connections linger until the peer or the OS gives up on them.
-
cwist_allocstill requires manualcwist_free. Miss one and the object leaks for the life of its owner.
Full-GC mode (cwist_full_gc(true)) closes both gaps. When disabled (the
default), the current explicit model runs unchanged at zero cost.
cwist_full_gc(true); /* enable automatic reclamation */One process-wide switch. This cuts across cwist_multiport_get_app()'s
per-port sub-applications. Multiport advertises detached ports as
"independently tunable sub-applications," but that independence is
app-level config only (routes, middleware, TLS, size limits) -- it was
never true of process-wide subsystems, and cwist_full_gc() is one.
Several cwist_app instances can share a process (that's the whole point
of multiport), but they cannot have different full-GC settings: enabling
it from any one of them enables it for all of them, with no per-app
opt-out, because the toggle, the pending-sweep bookkeeping, and the
epoch-retire pipeline it drives are all singletons scoped to the process,
not to a cwist_app *. (The cJSON allocator hook installed at process
start via a constructor function is the same shape, for the same reason:
there's one cJSON allocator per process, not per app.) A deployment that
genuinely needs different memory-management behavior for different
sub-apps needs separate processes -- separate cwist_app instances in one
process cannot get it.
When enabled:
- Connections are closed automatically on worker-thread exit and on process exit, even when no explicit destroy-family call was made.
-
cwist_allocobjects are registered with the reclamation engine and freed by epoch rotation instead of manualcwist_freecalls.
When disabled, none of the tracking machinery is active; the explicit model keeps its current performance profile.
The engine is libttak's epoch GC (ttak_epoch_gc, see
lib/libttak/include/ttak/mem/epoch_gc.h), already wrapped by CWIST in
src/core/mem/gc.c (cwist_gc, cwist_reg_ptr, cwist_gc_rotate).
- Each worker thread registers its live connections and
cwist_allocblocks against the current epoch. - Reclamation is epoch-deferred: a swept object is not reclaimed until the epoch rotates past every thread that could still reference it. A connection closed at thread exit can therefore never use-after-free a thread that is mid-request on it.
- Rotation can be automatic (background rotate thread with adaptive cadence
driven by allocation hints) or manual (
cwist_gc_rotatefrom the event loop), matching the existing wrapper semantics.
- Thread exit: a per-thread connection registry lives in thread-local storage; a pthread TLS destructor sweeps and closes whatever the thread still owns when it exits.
-
Process exit: an
atexitsweep performs the same close-out for the whole process, so stray connections are shut (TLS shutdown, socket close, session teardown) rather than abandoned.
For stack-scoped handles, CWIST provides pseudo-RAII guards:
- GCC/Clang
__attribute__((cleanup))scoped guards that run the matching teardown when the variable leaves scope - C's practical equivalent of RAII. - Raw borrowing of LibTTAK RAII primitives where the cleanup-attribute trick is unavailable.
Users will habitually write malloc, not cwist_alloc - depending on
finger discipline is how leak-free claims fail. <cwist/core/mem/intercept.h>
makes the two spellings equivalent, opt-in per translation unit:
#define CWIST_INTERCEPT_MALLOC
#include <cwist/core/mem/intercept.h>
#include <cwist/app.h>
static void handle(cwist_http_request *req, cwist_http_response *res) {
char *buf = malloc(256); /* -> cwist_malloc_shim() */
...
/* no free(buf) needed: under cwist_full_gc(true), this evaporates
* exactly like a forgotten cwist_alloc() would. free(buf), if called,
* still works normally either way. */
}-
cJSON's own internal allocations are separately redirected onto
cwist_allocviacJSON_InitHooks(src/core/mem/alloc.c) - this was already true before the shim below existed and needs no opt-in. -
A handler's own
malloc/calloc/realloc/freeare redirected bycwist_malloc_shim/cwist_calloc_shim/cwist_realloc_shim/cwist_free_shim(src/core/mem/alloc.c) once the translation unit definesCWIST_INTERCEPT_MALLOCbefore including the header.malloc/callockeep their real semantics (uninitialized vs. zeroed memory respectively - neither becomescwist_alloc's always-zeroed behavior) and register with the pending-sweep list only whencwist_full_gc_enabled();reallocpreserves or drops tracking based on whether the incoming pointer was tracked, never opting an untracked pointer in on its own;freeuntracks first (a safe no-op if the pointer was never tracked) then calls the realfree.
Why header-scoped, not link-level -Wl,--wrap=malloc: the link-level
wrap intercepts every malloc reference in the final binary, including
inside vendored dependencies (BoringSSL, lsquic, cnats, sqlite3) that never
see CWIST's headers and have no reason to expect a non-libc allocator
underneath them. This is not a hypothetical risk: BoringSSL calls
OPENSSL_cleanse() to zero secret key material on free, on the assumption
of plain heap semantics - redirecting its allocations into the
epoch-deferred GC arena would mean cleanse-then-free no longer reliably
erases the memory before it becomes reclaimable, a security regression,
not a performance footnote. A header-scoped #define only ever takes
effect in a translation unit that explicitly opts in, and vendored
dependencies structurally never do (they don't include CWIST headers at
all) - this excludes them by construction. The header also pulls in
<stdlib.h> before defining the macros, specifically so a later
#include <stdlib.h> (this translation unit's own, or transitively via
another header) is a no-op against its own include guard instead of
re-declaring malloc/calloc/realloc/free through the now-active
macros - which reliably fails to compile otherwise.
Reclaim cadence reuses the existing cwist_gc_scope_track/
cwist_gc_scope_flush pipeline unchanged (per-thread tracking, thread-exit
TLS sweep as the last resort, explicit flush at natural completion points
such as the per-job flush already wired into src/sys/io/io_queue.c) - the
same mechanism cwist_alloc() already uses, not a separate policy. A
policy that reclaimed only at thread exit would be close to a no-op for
the C1M reactor's intentionally long-lived worker threads, exactly the
deployment model this feature is meant to help most.
Cross-thread handoff reuses the existing cwist_gc_scope_disown()
escape hatch (see below and tests/test_full_gc_ownership_handoff.c) - a
pointer that legitimately needs to outlive its allocating scope (cached in
a connection pool, handed to a background job) calls this once at the
handoff point and becomes the new owner's responsibility. No separate API
exists for shimmed pointers; they use the same one cwist_alloc()
pointers do.
Measured overhead (tests/bench_malloc_intercept.c, single-threaded,
mixed 16-512 byte allocations with an occasional realloc; GitHub Actions
ubuntu-latest, AMD EPYC 7763, median of 3 runs):
| configuration | ns/op | vs. baseline |
|---|---|---|
| baseline (no shim) | 16.7 | - |
| shim, full-GC off (default) | 17.6 | +5% |
| shim, full-GC on | 24.3 | +46% |
The "off" cost is one relaxed atomic load (cwist_full_gc_enabled()) plus
one extra function call per operation, the same overhead cwist_alloc()/
cwist_free() already pay. The "on" cost is the safety net itself: one
insertion into and one removal from the calling thread's pending set per
malloc/free pair. See "Known performance caveat" below for how that
cost behaves with many live blocks and several threads.
src/core/mem/alloc.c gains a registration hook: under full-GC mode every
cwist_alloc block is recorded with the epoch GC (size-aware via
cwist_reg_ptr_sized) so epoch rotation reclaims it; explicit cwist_free
remains correct and simply unregisters the block early.
| Event | Without full_gc (default) | With cwist_full_gc(true)
|
|---|---|---|
| Worker thread exits | Connections must be closed explicitly | TLS sweep closes owned connections |
| Process exits |
cwist_app_destroy() required |
atexit sweep closes remaining connections |
cwist_alloc object |
Manual cwist_free
|
Epoch rotation reclaims; explicit free still fine |
cJSON's internal malloc
|
N/A (cJSON manages its own memory) | Redirected to cwist_alloc via cJSON_InitHooks; freed by epoch rotation |
Handler calls bare malloc() (opt-in via CWIST_INTERCEPT_MALLOC) |
Heap leak if forgotten | Redirected to the pending-sweep list; freed by epoch rotation |
| Teardown safety | Caller discipline | Epoch-deferred; no reclaim while referenced |
- Full-GC mode is opt-in; the default build keeps the explicit model with zero tracking overhead.
- This is not a tracing garbage collector: reclamation is epoch-based and
scoped to framework-managed resources (connections,
cwist_allocblocks, handler-contextmalloc). - Kernel-level resources (file descriptors, TLS sessions) are closed deterministically by the exit sweeps; the epoch deferral governs only the memory reclamation behind them.
With cwist_full_gc(true), every cwist_alloc() inserts the block into
the calling thread's pending set and every cwist_free() removes it (or
looks it up and misses, for blocks that were never tracked, such as
cwist_strdup() results). The set is an open-addressing hash table, so
both operations are O(1) on average no matter how many blocks the thread
holds, and the thread's set is found through one thread-local load. Each
thread has its own set; there is no shared lock.
tests/bench_full_gc_tracking.c (make bench_full_gc_tracking) measures
this with N tracked blocks kept alive on the thread. GitHub Actions
ubuntu-latest (AMD EPYC 7763, 4 vCPUs), 200000 pairs per measurement,
median of 3 runs, ns per cwist_alloc() + cwist_free() pair:
| live tracked blocks | full-GC off | full-GC on |
|---|---|---|
| 0 | 31.2 | 38.6 |
| 64 | 31.3 | 40.7 |
| 1024 | 19.2 | 32.2 |
| 16384 | 19.2 | 34.1 |
A cwist_strdup() + cwist_free() pair (the free's lookup misses) costs
22.4-23.5 ns with full-GC on against 16.6-17.2 ns with it off, again flat
across the same live-set sizes. With 1024 live blocks per thread, per-thread
cost with 1/2/4/8 threads churning at once is 34.0/33.2/65.5/104.1 ns with
full-GC on and 25.6/24.9/49.6/79.8 ns with it off; the growth past two
threads appears with full-GC off too (the allocator, and 8 threads on 4
vCPUs), not in the pending sets.
Before issue #65 the set was a list scanned on every removal, so each free cost time proportional to the blocks the thread held: on the same runner, 288.6 ns per pair at 1024 live blocks and 3853.9 ns at 16384.
Practical guidance:
- Use full-GC mode for rapid prototyping and convenience-first code where
manual
cwist_free()bookkeeping would slow you down. It is the safe default for experiments, internal tools, and workloads where raw throughput is not the primary concern. - Keep the default explicit mode for high-throughput production
services where every nanosecond of allocation overhead matters. The
explicit
cwist_alloc()/cwist_free()model has zero tracking cost and remains fully supported. - If you opt into full-GC mode on a high-throughput service, profile your
allocation hot path first. The overhead is only active when
cwist_full_gc(true)has been called; all default builds (full-GC off) are unaffected.
Source: docs/GC.md on dev. Edit the source file in the repository; this page is regenerated from it.
CWIST wiki, written against the dev branch of c4punks/CWIST. Pages marked "Source:" are generated from files under docs/; fix those in the repository. Questions: Discord.
Getting started
- Installation
- Quick Start
- Linking
- Server Modes
- Configuration and Environment
- Project CLI
- Tutorial / Korean
- Examples and Tutorials
Guides
Core
- API Reference
- Application
- Routing
- Middleware
- Requests and Responses
- Async Handlers
- Streaming Responses
- Error Handling
- Graceful Shutdown
- Multiport
Protocols
- HTTPS and TLS
- HTTP/2
- HTTP/3 and QUIC
- WebTransport
- WebSocket
- Server-Sent Events
- gRPC Server
- gRPC Client
- Protobuf and Codegen
- GraphQL
- WebRTC DataChannels
- HTTP Clients
Web features
- Static Files and Assets
- Big Dumb Reply Cache
- Compression
- Cookies
- Sessions and Flash
- Query Maps
- Multipart Uploads
- HTML Components
- Templates
- JSON
- Validation
- OpenAPI
Security
Data
Runtime
- Memory Management
- Full GC
- Async GC Ownership
- SString
- Reactor and I/O
- Metrics and Health
- Logging
- Testing
Platforms
Performance notes
- Benchmark Methodology
- C1M File Limits
- Reactor Fairness
- Cooperative Queuing
- Classic Pool Starvation
- Reactor Wakeup
- wrk Dual Histogram
- FIXED Endpoint Cache
- ADR-0001
- Durable Queue Gate
- Mux References
Project