mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-25 11:50:03 +08:00
## Problem A client request that lives across a GC is promoted. Publish requests always are: they wait at the server for the publishing interval. When the response arrives, two **dead** old objects still reference it: 1. `UaSCUaBinaryChannel.WriteOperation.MessageBody`: `UaSCUaBinaryClientChannel.SendRequestAsync` read it but never cleared it. 2. The box of the generated `SessionClient.*Async` method. It is `async ValueTask<T>` with the default builder, so a call that completes asynchronously leaves a `Task<T>` box holding the result and the hoisted `response` locals. The GC treats old objects as live. Every gen0 and gen1 collection therefore keeps the whole response alive through the card table until a full collection. With 100k monitored items, every `PublishResponse` (100 `MonitoredItemNotification`s) ended up promoted to gen2. Evidence (ClrMD scan of a live client dump, 25 sessions × 40 subscriptions × 100 items): 1,073 KB of gen0 was kept only by dead old objects, against 136 KB kept by live ones. The dead holders were exactly the two above, holding the same responses. Gen2 held 37 MB of dead notifications. ## Change - `SendRequestAsync` clears `operation.MessageBody` once it has read the response. - `ClientApiTemplates`: generated `SessionClient` async methods use `PoolingAsyncValueTaskMethodBuilder` on .NET 6+. A completed box resets its result and state machine. This is the same pattern #4849 applied to the channel and `ManagedSession`. Contract note: the generated `SessionClient` calls now return pooled value tasks, so each must be awaited once, like any value task. All sync-over-async call sites in the repo use `.AsTask()` first. After the fix, young data kept only by dead old objects is 0 KB (was 1,073 KB). ## Measurements `perf/ServerLoadHarness`, client `client-gc:` line. Master vs this branch, 3 alternating rounds, runs gated on foreign CPU load (shared machine). Ranges are over the rounds. | scenario | build | pause share | GC p50 ms | survived/gen0 | survived/gen1 | gen2 MB | B/op | |---|---|---|---|---|---|---|---| | sub 100k items (25×40×100, pub 500 ms) | master | 4.9–5.4 % | 7.9–8.2 | 2.28–2.57 MB | 1.4–1.7 MB | 121–123 | 235–238 | | | this PR | 4.0–4.5 % | 6.1–6.9 | 0.64–0.71 MB | 0.19 MB | 86 | 233–240 | | sub 50k items (25×20×100) | master | 1.7–2.2 % | 5.5–6.5 | 2.1–2.2 MB | 0.8–1.1 MB | 56–62 | 233–238 | | | this PR | 1.7–1.8 % | 5.2–5.5 | 0.39–0.41 MB | 0.1–0.2 MB | 44–49 | 232 | | read 1 node, 200 sessions | master | 8.9–9.2 % | 2.2–2.4 | 200 KB | | 20 | 1456 | | | this PR | 7.3–7.7 % | 2.2–2.7 | 113–163 KB | | 20 | 1272 | | read 1 node, 20 sessions | master | 3.3–3.6 % | 0.9–1.2 | 50–53 KB | | 4.4 | 1456 | | | this PR | 2.8–2.9 % | 0.85–1.16 | 32–46 KB | | 4.4 | 1272 | The first fix round at 100k ran while another process starved the server (46k notif/s); it is left out of the fix row. - At 50k items, gen1 collections drop from 15 to 1 per 10 s. - GC phases at 100k items (TraceAnalyzer `gc`): card marking 3.3 → 2.55 ms (2.8 → 0.72 MB marked), plan+relocate+compact 6.05 → 3.27 ms. - Allocation: −184 B per request (−12.6 %), because the `Task` box per call is gone. ## Tests net10.0: Opc.Ua.Client.Tests 3283 passed / 0 failed, Opc.Ua.Core.Tests 6535/0, Opc.Ua.SourceGeneration.Core.Tests 4412/0, Opc.Ua.Sessions.Tests 857/0. The net48 Release build of Opc.Ua.Client (with Core) is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>