Skip to content

Commit 67c3379

Browse 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>
1 parent 95d9a14 commit 67c3379

6 files changed

Lines changed: 301 additions & 77 deletions

File tree

‎BREAKING-CHANGES.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ read first.
5858
| loud | the target frameworks | `net7.0;netstandard2.0` | `netstandard2.0;net8.0;net10.0` |
5959
| **silent** | `abs(x) = c` for a negative `c` | a set of non-solutions | the empty set |
6060
| loud | implicit `List<Entity>` to `Entity` | made a `FiniteSet`, and made three `params` overloads uncallable | removed |
61+
| **silent** | a `MathS.Settings` scope across an `await`, or inside a task | lost, or somebody else's | follows the call |
6162

6263
---
6364

@@ -149,6 +150,61 @@ Entity set = new Entity[] { 1, 2, 3 }; // the array conversion is unchange
149150
The `Entity[]` conversion is kept: an array argument binds to the `params` overload in its normal
150151
form by an identity conversion, which beats the alternatives outright, so it never produced the
151152
ambiguity.
153+
### A settings scope belongs to the call, not to the thread
154+
155+
`MathS.Settings` values were held in `[ThreadStatic]` fields — fourteen of them. A scope
156+
therefore stopped at the first `await`:
157+
158+
```csharp
159+
using var _ = MathS.Settings.MaxExpansionTermCount.Set(1);
160+
Console.WriteLine(MathS.Settings.MaxExpansionTermCount.Value); // 1
161+
await Task.Delay(20).ConfigureAwait(false);
162+
Console.WriteLine(MathS.Settings.MaxExpansionTermCount.Value); // was 2000, the default
163+
```
164+
165+
The continuation resumed on a pool thread that had never seen the scope, so the setting
166+
silently read its default. The same mechanism ran the other way too: a thread returned to
167+
the pool still carried whatever scope was left on it, so the *next* caller to borrow that
168+
thread could compute under a precision or codomain it never asked for. Neither shows up as
169+
an error; both change the answer.
170+
171+
The values now live in an `AsyncLocal`, which is what the cancellation token in
172+
`MathS.Multithreading` already used.
173+
174+
| | was | is |
175+
|---|---|---|
176+
| a scope across an `await` | lost | kept |
177+
| a scope inside `Task.Run` started under it | not inherited | **inherited** |
178+
| a scope opened in a task, seen by a sibling | no | no |
179+
| a scope opened in a task, seen after it ends | no | no |
180+
| a thread reused by the pool | could carry a stale scope | cannot |
181+
182+
**What breaks.** The second row is the one to read. Work started inside a scope now runs
183+
under it:
184+
185+
```csharp
186+
using var _ = MathS.Settings.Codomain.Set(Domain.Real);
187+
await Task.Run(() => expr.Solve("x")); // now solves over R; used to solve over C
188+
```
189+
190+
That is what the code says, and almost always what was meant — but if you parallelised
191+
inside a scope and relied on the child *not* seeing it, it does now. Move the scope inside
192+
the callback to keep the old behaviour.
193+
194+
**Cost.** Measured over 20 000 000 reads and 2 000 000 scopes:
195+
196+
| | was | is |
197+
|---|---|---|
198+
| read `PrecisionErrorZeroRange.Value` | 7.3–8.0 ns | **1.2 ns** |
199+
| read `DowncastingEnabled.Value` | 0.79 ns | 0.96 ns |
200+
| `Set()` + `Dispose()` | 388–395 ns, 32 B | **46 ns**, 112 B |
201+
| a `Simplify` workload | 5.6–5.8 s | 5.5–5.6 s, +1.5 % allocated |
202+
203+
Reads got faster rather than slower: the old getter re-tested a `[ThreadStatic]` field for
204+
null on every access, and that is dearer than the async-local lookup that replaced it.
205+
Opening a scope got much cheaper because it no longer mints a `Guid` to identify itself,
206+
though it allocates more, since assigning an `AsyncLocal` copies the flow's value map. Reads
207+
outnumber scope openings by orders of magnitude in any real workload.
152208

153209
### `Minusf`'s two operands exchanged names
154210

‎Sources/.editorconfig‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
2727
[Tests/UnitTests/Common/ListArgumentOverloadTest.cs]
2828
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
2929

30+
[Tests/UnitTests/Core/Multithreading/SettingsAcrossAsync.cs]
31+
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
32+
3033
# A new file in a directory whose other files predate it, so the section is on the file
3134
# rather than the folder.
3235
[AngouriMath/Core/Entity/Continuous/Entity.Continuous.{Floors,Rounding}.Classes.cs]

‎Sources/AngouriMath/Convenience/Diagnostic/MathS.Diagnostic.cs‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,14 @@ public static class Diagnostic
2222
/// Explicit output for ToString, that is, no signs or parentheses will be omitted. Useful
2323
/// for debugging and diagnostic.
2424
/// </summary>
25-
public static Setting<bool> OutputExplicit => outputExplicit ??= false;
26-
[ThreadStatic] private static Setting<bool>? outputExplicit;
25+
public static Setting<bool> OutputExplicit { get; } = false;
2726

2827
/// <summary>
2928
/// Set a predicate on the state of <see cref="Entity"/> so that once
3029
/// the predicate turns true in method <see cref="Entity.Simplify"/>,
3130
/// an exception <see cref="DiagnosticCatchException"/> is thrown.
3231
/// </summary>
33-
public static Setting<Func<Entity, bool>> CatchOnSimplify => catchOnSimplify ??= (Func<Entity, bool>)(a => false);
34-
[ThreadStatic] private static Setting<Func<Entity, bool>>? catchOnSimplify;
32+
public static Setting<Func<Entity, bool>> CatchOnSimplify { get; } = (Func<Entity, bool>)(a => false);
3533

3634
/// <summary>
3735
/// Will only occur in debug mode,

‎Sources/AngouriMath/Convenience/MathS.cs‎

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5347,8 +5347,7 @@ public static partial class Settings
53475347
/// Exception, but we still can parse a ^ 2 + 2 * x + a * b + 2 * (g + e) ^ 3
53485348
/// </code>
53495349
/// </example>
5350-
public static Setting<bool> ExplicitParsingOnly => explicitParsingOnly ??= false;
5351-
[ThreadStatic] private static Setting<bool>? explicitParsingOnly;
5350+
public static Setting<bool> ExplicitParsingOnly { get; } = false;
53525351

53535352
/// <summary>
53545353
/// That is how we perform newton solving when no analytical solution was found
@@ -5400,8 +5399,7 @@ public sealed record NewtonSetting
54005399
/// AngouriMath.Entity+Number+Real
54015400
/// </code>
54025401
/// </example>
5403-
public static Setting<bool> DowncastingEnabled => downcastingEnabled ??= true;
5404-
[ThreadStatic] private static Setting<bool>? downcastingEnabled;
5402+
public static Setting<bool> DowncastingEnabled { get; } = true;
54055403

54065404
/// <summary>
54075405
/// Amount of iterations allowed for attempting to cast to a rational
@@ -5448,8 +5446,7 @@ public sealed record NewtonSetting
54485446
/// 5/7
54495447
/// </code>
54505448
/// </example>
5451-
public static Setting<int> FloatToRationalIterCount => floatToRationalIterCount ??= 15;
5452-
[ThreadStatic] private static Setting<int>? floatToRationalIterCount;
5449+
public static Setting<int> FloatToRationalIterCount { get; } = 15;
54535450

54545451
/// <summary>
54555452
/// If a numerator or denominator is too large, it's suspended to better keep the real number instead of casting
@@ -5503,24 +5500,20 @@ public sealed record NewtonSetting
55035500
/// 100/169137
55045501
/// </code>
55055502
/// </example>
5506-
public static Setting<EInteger> MaxAbsNumeratorOrDenominatorValue =>
5507-
maxAbsNumeratorOrDenominatorValue ??= EInteger.FromInt32(100000000);
5508-
[ThreadStatic] private static Setting<EInteger>? maxAbsNumeratorOrDenominatorValue;
5503+
public static Setting<EInteger> MaxAbsNumeratorOrDenominatorValue { get; } = EInteger.FromInt32(100000000);
55095504

55105505
/// <summary>
55115506
/// Sets threshold for comparison
55125507
/// For example, if you don't need precision higher than 6 digits after .,
55135508
/// you can set it to 1.0e-6 so 1.0000000 == 0.9999999
55145509
/// </summary>
5515-
public static Setting<EDecimal> PrecisionErrorCommon => precisionErrorCommon ??= EDecimal.Create(1, -6);
5516-
[ThreadStatic] private static Setting<EDecimal>? precisionErrorCommon;
5510+
public static Setting<EDecimal> PrecisionErrorCommon { get; } = EDecimal.Create(1, -6);
55175511

55185512

55195513
/// <summary>
55205514
/// Numbers whose absolute value is less than PrecisionErrorZeroRange are considered zeros
55215515
/// </summary>
5522-
public static Setting<EDecimal> PrecisionErrorZeroRange => precisionErrorZeroRange ??= EDecimal.Create(1, -16);
5523-
[ThreadStatic] private static Setting<EDecimal>? precisionErrorZeroRange;
5516+
public static Setting<EDecimal> PrecisionErrorZeroRange { get; } = EDecimal.Create(1, -16);
55245517

55255518
/// <summary>
55265519
/// The tolerance within which downcasting rounds a number onto a nearby integer.
@@ -5576,8 +5569,7 @@ internal static EDecimal DowncastingTolerance
55765569
/// { } // nothing was found for 5-degree polynomial without numeric solution
55775570
/// </code>
55785571
/// </example>
5579-
public static Setting<bool> AllowNewton => allowNewton ??= true;
5580-
[ThreadStatic] private static Setting<bool>? allowNewton;
5572+
public static Setting<bool> AllowNewton { get; } = true;
55815573

55825574
/// <summary>
55835575
/// Criteria for simplifier so you could control which expressions are considered easier by you.
@@ -5649,8 +5641,7 @@ internal static EDecimal DowncastingTolerance
56495641
/// By default criteria it cannot simplify it further, however, the custom one
56505642
/// it simplified from 2 to 1.
56515643
/// </example>
5652-
public static Setting<Func<Entity, double>> ComplexityCriteria =>
5653-
complexityCriteria ??= new Func<Entity, double>(expr =>
5644+
public static Setting<Func<Entity, double>> ComplexityCriteria { get; } = new Func<Entity, double>(expr =>
56545645
{
56555646
// Those are of the 2nd power to avoid problems with floating numbers
56565647
const double TinyWeight = 0.5;
@@ -5688,7 +5679,6 @@ internal static EDecimal DowncastingTolerance
56885679
} + Weight; // Number of nodes
56895680
return DefaultCriteria(expr);
56905681
});
5691-
[ThreadStatic] private static Setting<Func<Entity, double>>? complexityCriteria;
56925682

56935683
/// <summary>
56945684
/// Settings for the Newton-Raphson's root-search method
@@ -5703,8 +5693,7 @@ internal static EDecimal DowncastingTolerance
57035693
/// ...
57045694
/// </code>
57055695
/// </summary>
5706-
public static Setting<NewtonSetting> NewtonSolver => newtonSolver ??= new NewtonSetting();
5707-
[ThreadStatic] private static Setting<NewtonSetting>? newtonSolver;
5696+
public static Setting<NewtonSetting> NewtonSolver { get; } = new NewtonSetting();
57085697

57095698
/// <summary>
57105699
/// The maximum number of linear children of an expression in polynomial solver
@@ -5736,15 +5725,12 @@ internal static EDecimal DowncastingTolerance
57365725
/// </item>
57375726
/// </list>
57385727
/// </summary>
5739-
public static Setting<long> MaxExpansionTermCount => maxExpansionTermCount ??= 2_000;
5740-
[ThreadStatic] private static Setting<long>? maxExpansionTermCount;
5728+
public static Setting<long> MaxExpansionTermCount { get; } = 2_000;
57415729

57425730
/// <summary>
57435731
/// Settings for <see cref="EDecimal"/> precisions of <a href="https://github.com/peteroupc/Numbers">PeterO.Numbers</a>
57445732
/// </summary>
5745-
public static Setting<EContext> DecimalPrecisionContext =>
5746-
decimalPrecisionContext ??= new EContext(100, ERounding.HalfUp, -100, 1000, false);
5747-
[ThreadStatic] private static Setting<EContext>? decimalPrecisionContext;
5733+
public static Setting<EContext> DecimalPrecisionContext { get; } = new EContext(100, ERounding.HalfUp, -100, 1000, false);
57485734

57495735
/// <summary>
57505736
/// Whether functions are being read as real-valued or complex-valued. It is a
@@ -5777,8 +5763,7 @@ internal static EDecimal DowncastingTolerance
57775763
/// prints the unevaluated limit, where the default prints <c>-oo</c>.
57785764
/// </example>
57795765
/// </remarks>
5780-
public static Setting<Domain> Codomain => codomain ??= Domain.Complex;
5781-
[ThreadStatic] private static Setting<Domain>? codomain;
5766+
public static Setting<Domain> Codomain { get; } = Domain.Complex;
57825767
}
57835768

57845769
/// <summary>Returns an <see cref="Entity"/> in polynomial order if possible</summary>

0 commit comments

Comments
 (0)