contextvars: continuation-local storage - #702
Conversation
978634a to
251d00a
Compare
…buffer std/deques' popFirst is a proc returning by value, which on refc costs three write-barrier calls per ref field on every dequeue — a dead zero-initialization of the hidden return slot, the copy-out, and clearing the vacated slot. The dispatcher pays this on every callback it runs, on the closure-environment field it has always carried. Replace the three internal Deque fields (callbacks, idlers, ticks) with a purpose-built CallbackQueue whose dequeue is a template with a barrier-free move-out, cutting the refc dequeue cost to one barrier call per ref field. The queue uses wraparound-safe unsigned head/tail counters, grows by whole-region relocation, compiles under --os:standalone, and carries a debug-build canary asserting drain integrity (active on toolchains with sink support; a dedicated test leg pins the no-sink variant). A new test file covers growth during reentrant drain, ordering, integrity across a Defect unwind, and ref-field preservation under repeated growth. Also fixes the nimble test task's benchmark-compile step, which compared walkDirRec paths against a filename prefix and therefore never built any benchmark, and passes --threads:on explicitly so the step works on compilers without a threaded default. The re-enabled check exposed two latent 32-bit integer-division truncations in bench_http_fetch, fixed here.
… fire Give the dispatcher the machinery for continuation-local storage: every scheduling site captures the active context chain into the callback it enqueues, and every fire site installs that chain around the invocation, restoring the previous one on all exit paths. When no context is bound, capture stores nil and the fire path reduces to a TLS load and a predicted branch; when the ambient chain already equals the captured one, the restore is skipped entirely. On refc this keeps the contextvars-free hot paths barrier-free; on orc the context copy is skipped when no binder is live. Windows completion paths (IOCP) carry the registration-time context with the armed completion, so waitable and stream-server callbacks fire under the registrant's context like their POSIX counterparts. Cross-thread callSoon callbacks deliberately fire with an empty context: the MPSC payload cannot carry a GC ref across thread-local heaps, so propagation begins at the first in-loop scheduling point on the receiving thread.
251d00a to
f6cedc9
Compare
Add chronos/contextvars: context variables in the spirit of Python's
contextvars/PEP 567. A context variable is a first-class typed key,
ContextVar[T], constructed with newContextVar (defaulted) or
newRequiredContextVar, or - on Nim 2.x - declared through the
{.contextVar.} pragma, which derives the variable's name and
introspection visibility from the declaration itself; a
per-declaration argument overrides the derived visibility when a
key's export marker and its dump visibility need to differ. The pragma is gated to 2.x because macro pragmas on let/var
sections are a 2.x language capability; on 1.6 the constructors are
the declaration path and the feature is otherwise identical. Reads are cv.value or ctx[cv], binding is
cv.withValue(v), and every operation is an ordinary generic defined
once in the library - nothing mints per-variable identifiers, so
declarations cannot collide with existing API, and keys can be passed
to procs, stored in collections, and used as Table keys like any
other value.
Bindings nest, restore on every exit path including exceptions and
cancellation, propagate across await and into tasks spawned within
the binder's extent, and concurrent tasks stay isolated.
newRequiredContextVar declares a required variable, whose unbound
read raises a Defect - a contract violation rather than a tracked
exception, so readers stay usable inside raises-annotated async
procedures; cv in ctx and cv.isBound answer boundness without
raising. The constructors are distinct procs rather than overloads
of one name: a bool-typed default in the second positional slot
would otherwise be ambiguous against the trailing visibility
parameter.
currentContext and withContext snapshot and restore the full binding
chain for callbacks that share no call stack, such as a resource's
separately-registered setup and teardown hooks. Snapshots are
first-class: identity == and hash, snapshot reads through ctx[cv],
and dumpContext/$ for logs and debugging, backed by a registry that
keeps every key alive for the life of the process and lists only
keys registered as visible. Key construction is contractually
single-threaded before any createThread; every build checks the
same-thread contract automatically, pinning registration to the
first constructing thread by a chronos-assigned generation that,
unlike an OS thread id, is never recycled, with a chronosDebug
opt-in one-way lock for the stricter before-any-thread boundary.
Key types are marked acyclic: the registry's linkage is append-only
and cycle-free, registered keys are immortal by design so no cycle
through a key is collectable regardless, and the annotation keeps a
key independent of its constructing thread's per-thread
cycle-collector bookkeeping under --mm:orc. The snapshot type holds its chain in a
private field, so every construction route expressible in safe Nim
goes through currentContext's own capture. There is no imperative
set/reset token API: within one logical task the binder
expresses every lifecycle, and across independently-scheduled
callbacks the dispatcher's restore discipline would unwind any push
a callback left behind.
Includes the user documentation and a benchmark exercising the
schedule/fire, await-chain, future-churn, and chain-depth paths in
bound and unbound configurations under both memory managers.
Cover the synchronous binder contract, async propagation across the dispatcher's scheduling sites, cancellation and Defect unwind on both the identity and the write-restore fire paths, suspension inside withContext, snapshot and boundness probes, introspection and its visibility filtering, required-variable semantics, key identity (same-name keys never alias, same-type keys stay distinct, keys as Table keys and generic parameters), key lifetime against a captured context outliving its declaration scope, the cross-thread callSoon empty-context contract, the automatic cross-thread key-construction guard including against a recording thread that has since exited, and the chronosDebug context-corruption detection net layer by layer: the identity arm's postcondition assert, the restore arm's unconditional self-heal, and the cross-batch guard, pinned through the assertion message that surfaces when a corrupting callback trips both layers in one batch. Compile-time guardrails pin the boundaries that runtime tests cannot: unauthorized construction of the internal callback types, snapshot forgery from internal-module imports, access to privatized dispatcher, chain-node, and registry fields, the pragma's one-symbol expansion and its let/var grammar (on Nim 2.x, with the pragma itself), coexistence with std/tables.withValue, and the public surface of import chronos plus import chronos/contextvars, so a future refactor that reopens a capture bypass fails to compile rather than silently regressing. The leak-guard, cross-thread-construction, recorder-death, and construction-lock checks share one standalone driver binary that runs each suite in its own subprocess: the leak-guard's corrupting case lets an AssertionDefect escape poll(), the lock is deliberately one-way for the process's lifetime, and the recorder-death suite needs a process in which no key has yet been constructed, so isolation comes from the process boundary rather than from a load-bearing execution order.
f6cedc9 to
a3945ab
Compare
|
@vladopajic tagging you since I noticed #690 is circling the same problem and you've clearly been thinking about it. I built continuation-local storage for a downstream project and carried it on a fork of chronos for a while before folding it into this series; the core mechanic ended up the same as your experiment, capture a context reference and save/restore it around the resume, which I take as a good sign it's the right substrate. The extra scope here came from requirements I ran into downstream rather than a different opinion on the design. I needed context to follow timer and The pieces #690 has that this series doesn't, |
|
hey @coreyleavitt, notification from this PR found me in the bed, and i was intrigued to read it. reading it was interesting to see that there are others stumbling on this problem, and i was also puzzled why i was getting this notification (later realized the tag). effort on my experiment was lazy, ai generated, and i haven't been dedicated enough to drive it forward with maintainers. may this be opportunity to come to a solution for this in chronos. @arnetheduck is the wizard that can bring wisdom to this topic. in the meantime i can get ready #690 (at least add some context to that PR explaining problem and solution). i will also read code from #702. one note about #702 is that i noticed that diff is big 6k lines so naturally every PR of this size is "wow, hold on! hold on! take it easy my friend" :) |
|
@vladopajic thanks for taking a look! I certainly don't expect this to merge as is; I maintain a fork for downstream projects, so I tried to be as thorough as possible. The runtime code changes are about 550 lines, and the rest of the diff is tests, docs, and benchmarks. If you want the short read, the second commit is the equivalent of your #690 experiment, and the typed API sits on top of it in the third. Appreciate the warm response to a cold ping. Added context on #690 sounds great. |
Sweep fork-original files (the simulation substrate and its verify-side benchmark) from chronos's Status Research & Development copyright and dual Apache-2.0/MIT header to a tonalli header naming Corey Leavitt as copyright holder, under Apache-2.0 only. Inherited files and the contextvars/callbackqueue series offered upstream via PR status-im#702 keep their chronos headers untouched, since those carry Status's copyright and are meant to land under upstream's own convention. Give the README a tonalli identity: title, a relationship-to-chronos note, a license section explaining the Apache-2.0 election and why LICENSE-MIT stays in-tree, and a pointer to the deterministic simulation docs. Drop the upstream CI/license badges, which point at status-im and no longer apply.
Summary
This PR adds
chronos/contextvars: continuation-local storage for chronos, in the spirit of Python'scontextvars/PEP 567. A context variable's binding follows a logical task throughawaitsuspensions, callback registrations, and combinators (race,allFutures, ...), while concurrent tasks stay isolated from each other. Typical uses are request IDs, authenticated users, tracing spans, and other "ambient" data that would otherwise have to be threaded through every procedure signature by hand.We run this in production downstream and will maintain the branch regardless; it is offered upstream because both the feature and the queue fix it surfaced seem generally useful. No pressure or timeline attached: this is shared in the spirit of open source, and we're equally content if it lands, gets split up, or simply serves as a reference.
A context variable is a first-class typed key,
ContextVar[T]. The{.contextVar.}pragma declares one as an ordinarylet, deriving the variable's name and its introspection visibility from the declaration itself (letwith a default,varwithout one for a required variable, enforced at compile time); the rawnewContextVarandnewRequiredContextVarconstructors are the underlying primitives and stay public. The pragma requires Nim 2.x, since macro pragmas onlet/varsections are a 2.x language capability; on 1.6 the constructors are the declaration path and the feature is otherwise identical, with the pragma compiled out entirely rather than emulated. Every operation is an ordinary generic defined once in the library: the ambient readcv.value, the snapshot readctx[cv], the scoped bindercv.withValue(value): body(which binds for the dynamic extent ofbodyand restores on every exit path: normal, exception, cancellation), and the boundness probescv in ctx/cv.isBound. Nothing mints per-variable identifiers, so declarations cannot collide with existing API, and keys are ordinary values: they can be passed to procs, stored in collections, and used asTablekeys. Reading a required variable while unbound raises aDefect(unbound-read is a contract violation, and using aDefectkeeps readers out ofraiseseffect tracking, so{.async: (raises: []).}procedures can read context variables freely). Bindings nest, innermost wins, and propagate into tasks spawned within the binder's extent. Two further primitives,currentContext()andwithContext(ctx, body), snapshot and restore the full binding chain for cases that fall outside a single dynamic extent, such as a resource's independently-registered setup and teardown callbacks, which share no call stack. Snapshots are first-class values: they compare with==(chain identity), read withctx[cv]without installing anything, anddumpContext(ctx)/$render a snapshot's contents for logs and debugging, backed by a registry that keeps every key alive for the life of the process and lists only keys registered as visible. There is no imperative token API (PEP 567'sContextVar.set()/Token.reset()): within one logical taskwithValuealready expresses every binding lifecycle, and across independently-scheduled callbacks a token could not work, since the dispatcher's restore-at-fire discipline unwinds any push a callback leaves behind regardless.Full usage, semantics, and implementation notes are in
docs/src/contextvars.md(added by this PR).Performance
The design goal is cost proportional to use: a program that never declares a context variable should pay a cost indistinguishable, on each hot path, from a build without the feature at all.
Headline (refc: chronos's flagship consumer, nimbus-eth2, pins
--mm:refcunconditionally). Cross-commit comparison of the submitted commits against their pre-contextvars base,callSoonschedule+fire (the hottest path a contextvars-free program pays on), 15 interleaved trials per side: refc 1.04x, bootstrap 95% CI [0.82, 1.26], Mann-Whitney p = 0.76, statistically indistinguishable from the base. orc 0.99x, CI [0.80, 1.20]. Earlier independent measurement sessions during development read 1.01x–1.11x on refc; the raw per-trial data behind the final numbers is retained and reproducible.Per-call-class bill, both memory managers, verified by inspecting the generated C at each site (not inferred from throughput alone):
Bound-value lookup walks the binding chain comparing key identities: one pointer compare per node, with no per-node runtime type test (measured: reading through a 16-deep binding chain costs ~12 ns on refc).
Struct growth:
sizeof(AsyncCallback)+8 B (one pointer field);sizeof(Future)+16 B after accounting for two embedded callbacks.Both-worlds intra-commit detail, measured on the submitted series (release builds; unused arm = no binder active in the whole program; bound arm = one context variable bound around the hot loop), refc:
orc:
(Rows where the bound column is at or below the unused column are within run-to-run noise of each other; construction skips the context copy entirely when no binder is live. The sleepAsync rows carry the largest run-to-run spread, with individual runs swinging between the bound arm faster and the bound arm slower, so their bound-vs-unused deltas are noise-dominated; the callSoon rows are the tighter signal for the schedule/fire path.)
benchmarks/bench_contextvars.nim(included in this PR) reproduces these tables; the cross-commit comparisons above used a small self-contained harness that needs a second checkout to compare against, which we are happy to provide if useful.Queue transport: a named, separate improvement
While measuring the above, a pre-existing defect in chronos's callback queue surfaced:
std/deques.popFirstreturns its element by value, which is aproc, not a template, so every dequeue pays a copy-out and, under refc, three write-barrier calls per ref field (a dead zero-initialization of the hidden return slot, the copy-out itself, and clearing the vacated slot). This cost existed before this PR, on the queue's original single ref field (the callback's closure environment); adding contextvars' second ref field (the captured context) doubled it, and that doubling is what our own benchmarks caught.This PR fixes it by replacing the three internal
Dequefields on the dispatcher (callbacks,idlers,ticks) with a small purpose-builtCallbackQueue, inchronos/internal/callbackqueue.nim, whose dequeue is a template with a barrier-free copy-out. Effect on refc: the dequeue side drops from three write-barrier calls per ref field to one, restoring per-hop cost to parity with what the pre-contextvars codebase already paid on its single ref field. orc is unaffected either way: its ownership model does not incur this cost regardless of queue shape, and the orc cross-commit ratio above shows no shift.This change is the first, standalone commit of the series; it has no dependency on contextvars and is structured to be reviewable and cherry-pickable on its own. If maintainers would prefer to review and merge the queue fix as a preliminary PR before the contextvars feature itself, we are glad to split it out; the commit boundary already does the work.
Test coverage
awaits, exception and cancellation paths, spawn-time inheritance into tasks started within a binder's extent, and per-scheduling-site capture coverage (callSoon,setTimer/sleepAsync,callIdle,addReader,race/allFutures,closeSocket/closeHandle).Tablekeys and generic-proc parameters, and a captured context outliving a key's declaration scope stays sound.currentContext()/withContext()carrying a binding from an enter hook into a separately-scheduled exit hook.{.contextVar.}pragma's one-symbol expansion and itslet/vargrammar (on Nim 2.x, where the pragma exists), no custom==on keys, coexistence withstd/tables.withValue, and constructor-string fidelity indumpContextandUnboundContextVarDefect.varName, so a future refactor that reopens a capture bypass fails to compile rather than silently regressing.import chronosplusimport chronos/contextvarsexpose exactly the intended API and none of the dispatcher internals; introspection-visibility semantics for pragma (star-driven) and raw-constructor (privateparam) declarations, including the registration/export decoupling case.CallbackQueue(growth during reentrant drain across capacity boundaries, zero-value-empty, sentinel fidelity, front-insertion ordering, queue integrity across aDefectunwind, and repeated-growth-under-memory-pressure coverage for the ref-field payload case).Defectraised from a callback fired under a binding that differs from the ambient context (the write-and-restore arm, for both the normal and the cancel-callback fire sites) leaves the prior ambient context intact;awaitinsidewithContextis pinned against the suspend-across-binder hazard; and the cross-threadcallSoonempty-context contract has a dedicated test (bound origin thread, posting thread, receiving callback observes the default).==,ctx[cv]snapshot reads, boundness probes, required (must-bind) variables including async propagation, anddumpContext/$rendering (including the placeholder path for value types without a$).poll(), and the opt-in construction lock is one-way for the process's lifetime.nimble testmatrix gains one leg combining-d:chronosDebugwith-d:chronosPreviewV5, so the strict-reentrancy callback-drain discipline is exercised together with the debug-mode invariant asserts rather than only apart from them.nimble testmatrix passes unmodified on both--mm:refcand--mm:orc. The new queue module additionally compiles under--os:standalone, keeping it available to bare-metal configurations (cf. Support compiling timer on bare metal #697).nimble testtask's benchmark-compile step matchedstartsWith("bench_")against paths returned bywalkDirRec, which always carry thebenchmarks/prefix, so the step had never compiled anything. This PR corrects the match (and passes--threads:onexplicitly, since Nim 1.6 has no threaded default), which puts the benchmark files back under CI's compile check; the re-enabled check exposed two latent 32-bit integer-division truncations inbench_http_fetch.nim, also fixed here.Platform semantics, non-goals, and known gaps
chronosFutureTracking-style build flag as a condition of merging, we can add one; happy to discuss the tradeoff (double test surface, a define that does not compose through nimble) given the measured numbers above.registerWaitable, stream-serverstart(), explicitaccept()), and the completion dispatch fires under it, so Windows waitable and stream-server callbacks propagate context like their epoll/kqueue counterparts (verified by a dedicated test on Windows CI). Low-level per-operation read/write completion trampolines intentionally stay context-free: they only drive an internal future, whose awaiter carries its own captured context.callSoon(the MPSC mechanism from add thread-safe dispatcher callback mechanism #694) fires its callbacks with an empty context, by construction: the queue's payload is a barenimcallproc and a raw pointer, and a captured context chain is a GC ref that must not cross thread-local heaps under refc. This fails closed: bindings from another thread's tasks can never leak in; propagation begins at the first in-loop scheduling point on the receiving thread. This contract has a dedicated test.{.cast(gcsafe).}block interact with a Nim compiler defect: when a nested cast block exits, it cancels the enclosing block's cast for the statements that follow, so code after a.valueread inside such a block is flagged as not GC-safe. The documentation carries a hoist-the-read workaround. The compiler defect is fixed upstream (Nested{.cast(gcsafe).}blocks: exiting the inner block cancels the outer block's cast for the statements after it nim-lang/Nim#26092, fix merged in fixes #26092; restore enclosing cast block state when a nested cast block exits nim-lang/Nim#26093); the fix lives on devel only, so the workaround stays relevant for released toolchains.chronosDebugbuild additionally carries an opt-in assertion hook for embedders who want the stricter before-any-thread boundary checked at their own thread-creation point. Registered keys live for the life of the process, matching static-global semantics.--os:standaloneverification of the contextvars substrate itself (the queue module compiles standalone;asyncengine.nimdoes not, for a pre-existing, unrelated reason: an unconditional "operation system is not yet supported" import guard that predates this PR).Public API surface
Frozen for this PR:
ContextVar[T]withnewContextVar(defaulted) andnewRequiredContextVar(distinct procs, because a bool-typed default in the second positional slot would be ambiguous against the trailingprivatevisibility param under a single overloaded name), the{.contextVar.}declaration pragma,value,[],withValue,contains/inandisBound, thename/hasDefault/privateread-only accessors and an identityhashon keys,AsyncContextwith==,hash, and$,currentContext,withContext,dumpContextandContextVarEntry, andUnboundContextVarDefect(carryingvarName). No other new public identifiers. No version bump is included; that is left to maintainers' release process.