Expand description
ProtectPool: an owned root set for R objects, held in one R list.
The pool stores the objects it roots in a single VECSXP (generic list) that
R’s collector traces, and keeps the slot bookkeeping and a generation per
slot on the Rust side. One R_PreserveObject roots that list. Dropping the
pool releases it, and with it every object the pool holds.
§The per-call root set
Code that makes intermediate R objects and needs them alive until its
.Call returns (attribute vectors, results of R calls, sliced columns)
roots each one with root, which returns the object:
struct Columns {
roots: ProtectPool,
cols: Vec<(String, SEXP)>,
}
impl Columns {
fn push(&mut self, name: &str, value: impl IntoR) {
let sexp = self.roots.root(value);
self.cols.push((name.to_owned(), sexp));
}
}A struct can own the pool and drop in any order relative to other pools,
scopes and guards. A ProtectScope moved into a
struct cannot: it unprotects with UNPROTECT(n), which pops the top n
entries of R’s protect stack whoever pushed them, so dropping it out of
order unprotects another scope’s values. The pool takes no protect-stack
entry per value.
§The cross-call pool
Objects that outlive the .Call that made them and are released one at a
time, in any order: insert returns a generational
ProtectKey, and release frees its slot in
O(1). Every owning ExternalPtr is rooted in
one process-wide pool.
§Contract
The pool has the contract of Preserved and the other
owned roots: its functions are safe to call on R’s main thread, and the type
is !Send + !Sync, so it is made, used and dropped there. An object stays
rooted while it occupies a slot: until its key is released, its slot is
replaced, or the pool drops. A SEXP copied out of the pool is not rooted
on its own after that.
§Performance
Benchmarked at 10.1 ns/op for single insert+release. Zero R allocation per
insert (unlike R_PreserveObject, which allocates a CONSXP each time). The
backing list doubles when it is full.
See analysis/gc-protection-benchmarks-results.md for full data.
§When to use
- A temporary within one call:
ProtectScope(7.4 ns/op, LIFO bulk cleanup). - Many values, any drop order, or owned by a struct: this pool.
- One long-lived object:
Preserved.
R_PreserveObject is only appropriate for a singleton object that is
released (if ever) in LIFO order relative to other preserved objects — e.g.
this pool’s own backing VECSXP. It is not appropriate for objects held
in bulk and released in arbitrary or FIFO order: R_ReleaseObject’s O(n)
precious-list scan degrades to O(n²) on exactly that pattern (60–65× slower
than this pool at 10k objects; see
analysis/gc-protection-benchmarks-results.md). ExternalPtr roots through
this pool for that reason — a Vec<ExternalPtr> releases its roots
front-to-back, the worst case for the precious list.
§Architecture
┌─────────────────────────────────────┐
│ R side: VECSXP (GC-traced slots) │ ← one R_PreserveObject at a time
│ [SEXP][SEXP][NIL][SEXP][NIL][SEXP] │
└──────┬──────────────────────────────┘
│ slot indices
┌──────┴──────────────────────────────┐
│ Rust side: Vec<u32> generations │ ← one free list, one generation array
│ + Vec<usize> free_slots │
└─────────────────────────────────────┘No external dependencies for slot management. The generation counter per slot detects stale keys. Single free list for VECSXP slot reuse.
Structs§
- Protect
Key - Generational key for a slot in a
ProtectPool. - Protect
Pool - An owned root set for R objects, held in one R list, with generational keys.
Type Aliases§
- NoSend
Sync 🔒 - Enforces
!Send + !Sync(R API is not thread-safe).