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
The 30 M+ synchronous row never regressed; the ~10 M figure was the legacy
string API's thread-pool mode, and ProcessAsync itself was capped near 4 M
RPC/s on every core count by the AsyncScratch pool lock taken once per
document.
Library
- AsyncScratch keeps one idle scratch per thread in a [ThreadStatic] slot in
front of the 64-slot locked pool, which becomes the miss and overflow path.
Rent clears the slot before exposing the lease; Return caches only a
reusable scratch (reader released, buffers at most 64 KiB) and disposes the
rest. Retention is one scratch per thread that has run ProcessAsync plus
64 shared.
- Handler.AsyncScope.Dispose no longer writes the captured thread frame back.
A Flow scope under a pre/post hook that suspends is disposed on the
completing thread; that thread was handed the starting thread's frame, and
the two then shared it, so RpcContext, RpcRequestId and RpcSetException on
either could read or clear the other's during concurrent dispatch. The
ambient restore already brings back the current thread's own frame.
Harness (TestServer_Console)
- --async N W: ProcessAsync rows at W awaited workers with the same loop as
--sync (barrier start, exact allocation accounting per row).
- --scale [seconds] [workers] [threshold]: the release gate; inline rows at
1, 2 and N workers, three paired runs, medians, exit 1 below the threshold.
- --kestrel [seconds] [async]: the host with EnableAsyncMethods = true, with
an inline row and a yielding-methods row.
- The legacy string API's thread-pool benchmark moves to the 't' menu entry;
Enter runs the synchronous byte API.
CI and docs
- .github/request-path-sync.allowlist and its checker: every lock,
Interlocked, Volatile.Write, [ThreadStatic] field and writable static on
the request-path files (core and both companion serializers) is listed
with a reason. A non-required scaling job runs --scale 3 4 2.0.
- README: async section with the 1- and 16-worker rows, per-shape allocation
table, the cost of a real suspension, Kestrel async rows; charts and the
explorer gain the async and legacy sets; CHANGELOG, Micro and AspNetCore
READMEs updated.
Tests
- AsyncScratchCacheTests: re-entrant, overlapping, cross-thread (16 workers x
400 documents), cancellation after transfer, throwing reader release and
oversized-document trimming.
- AsyncInvocationTests.FlowScope_CompletedOnAnotherThread_LeavesThatThreadItsOwnFrame:
deterministic reproduction of the frame bug.
Measured on a busy machine (single runs, to be re-measured idle before a
release): ProcessAsync inline rows 21.2 to 25.7 M RPC/s at 16 workers (was
3.8 to 4.2 M), a real suspension 8.3 M (was 3.9 M); --scale 3 16 4.0 passes
with 16/1 ratios of 9.1 to 9.6.
# Every `lock (`, `Interlocked.`, `Volatile.Write`, `[ThreadStatic]` field and writable static field in the
2
+
# request-path files (core and both companion serializers), with the reason it is allowed there.
3
+
# Format: file | the code line, trimmed, without its trailing comment | reason, starting with its tag:
4
+
# per-thread the field is thread-static, so no line is shared
5
+
# miss-path touched only when a per-thread cache misses or overflows, never by a warm inline document
6
+
# registration-only written when methods or sessions are bound, never by a request
7
+
# read-only-after-init written once at startup, then only read
8
+
# shared-write a request can write it and every core reads it: the reason must say why it is tolerated
9
+
# Checked by .github/scripts/check_request_path_sync.py in the pull-request workflow. The measurement that backs this
10
+
# list is `TestServer_Console --scale` (README, Benchmarks). A `[ThreadStatic]` attribute on its own line is not
11
+
# listed; the field line under it is.
12
+
Json-Rpc/JsonRpcProcessor.Async.cs | private static int _count; | miss-path: the shared pool's fill count, read and written only under lock (Pool)
13
+
Json-Rpc/JsonRpcProcessor.Async.cs | [ThreadStatic] private static AsyncScratch _slot; | per-thread: the one-slot cache of an idle async scratch
14
+
Json-Rpc/JsonRpcProcessor.Async.cs | lock (Pool) | miss-path: AsyncScratch.Rent, taken only when the thread's slot is empty (cold thread, nested or overlapping document)
15
+
Json-Rpc/JsonRpcProcessor.Async.cs | lock (Pool) | miss-path: AsyncScratch.Return, taken only when the completing thread's slot is already occupied; the array is the bounded overflow
Json-Rpc/Handler.Async.cs | [ThreadStatic] private static InvocationState __unflowedState; | per-thread: the invocation frame of a RpcContextFlow.None call
18
+
Json-Rpc/Handler.cs | private static int _sessionHandlerMasterVersion = 1; | registration-only: written by Interlocked.Increment when a session is created or destroyed; requests read it to validate their per-thread snapshot
19
+
Json-Rpc/Handler.cs | Interlocked.Increment(ref _sessionHandlerMasterVersion); | registration-only: GetSessionHandler creating a session and DestroySession; never on the request path
20
+
Json-Rpc/Handler.cs | private static Dictionary<string, Handler> _sessionHandlersLocal; | per-thread ([ThreadStatic] on the line above): the thread's snapshot of the session registry
21
+
Json-Rpc/Handler.cs | private static int _sessionHandlerLocalVersion = 0; | per-thread ([ThreadStatic] on the line above): the snapshot's version
22
+
Json-Rpc/Handler.cs | private static string _lastSessionId; | per-thread ([ThreadStatic] on the line above): the last-session cache
23
+
Json-Rpc/Handler.cs | private static Handler _lastSessionHandler; | per-thread ([ThreadStatic] on the line above): the last-session cache
24
+
Json-Rpc/Handler.cs | private static InvocationState __state; | per-thread ([ThreadStatic] on the line above): the current invocation frame
Json-Rpc/Jsmn/JsmnSerializer.cs | [ThreadStatic] private static bool _scratchInUse; | per-thread: re-entrancy flag for the tokenizer scratch
27
+
Json-Rpc/Serialization/Utf8KeyTable.cs | lock (_writeLock) | registration-only: Set, Remove, Clear and ReplaceAll rebuild a snapshot under the lock; requests read the published snapshot without it
AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static JsonSerializerOptions _cachedWriterOptions; | per-thread: the options the cached writer was built with
32
+
AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static bool _cachedWriterInUse; | per-thread: re-entrancy flag for the cached writer
AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | private static Entry _last; | shared-write: TypeInfo<T>'s last-options cache, rewritten whenever the options differ from the previous call, so a per-request write when two serializers with different options serve requests concurrently; tolerated because one options object is the common case; the per-thread copy is the first candidate of the 2026-09-25 P6 resolution, pending its A/B
- name: ProcessAsync scales from 1 to 4 workers (4/1 at least 2.0)
71
+
run: |
72
+
set +e
73
+
dotnet run -c Release --no-build --project TestServer_Console -- --scale 3 4 2.0 | tee scale.txt
74
+
status=${PIPESTATUS[0]}
75
+
{
76
+
echo "## ProcessAsync scaling (diagnostic, not required)"
77
+
echo
78
+
grep -E '^\|' scale.txt
79
+
echo
80
+
[ "$status" -eq 0 ] && echo "pass: 4/1 at least 2.0" || echo "**flag: 4/1 below 2.0 on this runner; reproduce with --scale 3 16 4.0 on the reference machine before reading it as a regression**"
Assume.That(completedOn,Is.EqualTo(completingThread),"the completion did not run the scope's cleanup on the completing thread");
360
+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":\"mine\"}",response,"this thread's method sees its own id and context while the other thread dispatches");
0 commit comments