Files
romanett d7c2ed6caf Client: smaller per-monitored-item footprint (listener array, lazy pending-change queue, shared logger) (#4847)
# Description

Every V2 monitored item (`Opc.Ua.Client.Subscriptions`) cost about 3.8
KB of managed heap on a 24-core machine (2.8 KB on 1 core). Most of that
was per-item infrastructure whose size grows with the processor count.
With 100k items, a client limited to 256 MB (container-like) ran out of
memory during subscription setup.

- **`OptionsMonitor<T>`**: `OnChange` listeners are kept in a
`Listener[]` that is replaced as a whole on each add or remove
(`Interlocked.CompareExchange`), instead of a `ConcurrentDictionary`.
That dictionary's default concurrency level allocated one lock object
per processor for every monitored item (~1.4 KB on 24 cores).
Notification, dispose and double-dispose behave as before; listeners are
now called in registration order.
- **`MonitoredItem`**: pending changes are kept in a `Queue<Change>`
guarded by the existing `m_pendingChangesLock` instead of a per-item
`ConcurrentQueue<Change>` (~810 B, 32-slot segment). The queue is
created on enqueue and dropped once the last change completes, so idle
items hold none. `HasPendingChanges`, `TryGetPendingChange` and
`CompleteChange` now take the lock. `TryRequeue` used to enqueue without
the lock and now enqueues under it.
- **`DefaultSubscription`** shares one `ILogger` across its monitored
items instead of creating one per item.

## Measurements

These numbers come from the `ServerLoadHarness` client GC line (harness
changes are in #4844): `run --scenario sub --sessions 25 --nodes 100
--pub 500 --write-interval 100 --duration 10 --warmup 6`. All figures
are client gen2 MB.

| Config | Items | Before | After |
|---|---|---|---|
| 24 cores, `--subs 20` | 50k | 190.0 / 188.7 | 62.9 / 64.0 |
| `--client-cores 2 --client-memory 256`, `--subs 20` | 50k | 142.6 |
54.6 |
| `--client-cores 2 --client-memory 256`, `--subs 40` | 100k | out of
memory | 122.6 (limit 192) |

That saves about 2.6 KB per item on 24 cores and about 1.8 KB on 2
cores. The whole client heap is now about 1.3–1.4 KB per item.

## Related Issues

- None

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. (`OptionsMonitorTests`:
order, removal, duplicate callbacks, concurrent add/remove)
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. (`Opc.Ua.Client` Release build on all targets,
including net48: 0 warnings)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Only
`Opc.Ua.Client.Tests` on net10.0 was run: 3231 passed, 0 failed, 2
skipped.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:11:11 +02:00
..