You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 67c3379
Browse filesBrowse the repository at this point in the historyBrowse files
Hold a settings scope per flow instead of per thread (#857)
MathS.Settings kept its fourteen values in [ThreadStatic] fields, so a scope stopped
at the first await:
using var _ = MathS.Settings.MaxExpansionTermCount.Set(1);
// 1
await Task.Delay(20).ConfigureAwait(false);
// 2000 -- the default, silently
The continuation resumed on a pool thread that had never seen the scope. The same
mechanism ran the other way: a thread going back to the pool still carried whatever
scope was left on it, so the next caller to borrow it could compute under a precision
or codomain it never asked for. Neither surfaces as an error; both change the answer.
The obvious repair -- AsyncLocal<Setting<T>> on the static field -- is wrong. An
AsyncLocal flows the reference, and Setting<T> was mutable, so two concurrent tasks
would have pushed onto one shared stack. What is per-flow has to be the value stack
itself, so the stack moved inside Setting<T> as an immutable chain behind an
AsyncLocal, and the static fields became ordinary singletons. They also had to stop
being lazily created: `field ??= default` on a non-thread-static field races, and a
scope opened on the loser of that race would vanish.
Scopes are identified by a counter rather than by their frame. Releasing a scope that
is not on top rebuilds the frames above it, and a rebuilt frame is a different object,
so keying on the frame left everything above it impossible to release afterwards --
which the out-of-order test caught. Dropping the Guid this replaces is most of why
Set() got cheaper.
Measured, 20M reads and 2M scopes:
read PrecisionErrorZeroRange.Value 7.3-8.0 ns -> 1.2 ns
read DowncastingEnabled.Value 0.79 ns -> 0.96 ns
Set() + Dispose() 388-395 ns, 32 B -> 46 ns, 112 B
a Simplify workload 5.6-5.8 s -> 5.5-5.6 s, +1.5% allocated
Reads got faster because the old getter re-tested a [ThreadStatic] field for null on
every access. Opening a scope allocates more, since assigning an AsyncLocal copies the
flow's value map, but reads outnumber scope openings by orders of magnitude.
The recursion-depth counters and per-thread scratch caches elsewhere keep
[ThreadStatic] deliberately: an lHopital depth must not follow a call into a sibling.
RewriteRecording has the same per-thread ambient shape and is left alone here; its
documentation already says "on this thread".
Verified: 6069 C# tests and 130 F# tests pass, and the public surface is unchanged.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
0 commit comments