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

134 lines
5.0 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// Unit tests for the in-memory <see cref="OptionsMonitor{T}"/>.
/// </summary>
[TestFixture]
[Category("Unit")]
public sealed class OptionsMonitorTests
{
private static readonly string[] s_expectedNotifications = ["1:b", "2:b", "1:bc", "2:bc"];
[Test]
public void ListenersAreNotifiedInRegistrationOrder()
{
var monitor = new OptionsMonitor<string>("a");
var calls = new List<string>();
using IDisposable? first = monitor.OnChange((v, _) => calls.Add("1:" + v));
using IDisposable? second = monitor.OnChange((v, _) => calls.Add("2:" + v));
monitor.CurrentValue = "b";
monitor.Configure(v => v + "c");
Assert.That(calls, Is.EqualTo(s_expectedNotifications));
Assert.That(monitor.Get(null), Is.EqualTo("bc"));
}
[Test]
public void DisposedListenerIsRemovedAndDoubleDisposeIsNoOp()
{
var monitor = new OptionsMonitor<int>(0);
int first = 0;
int second = 0;
int third = 0;
IDisposable? r1 = monitor.OnChange((_, _) => first++);
IDisposable? r2 = monitor.OnChange((_, _) => second++);
IDisposable? r3 = monitor.OnChange((_, _) => third++);
r2!.Dispose();
r2.Dispose();
monitor.CurrentValue = 1;
Assert.That((first, second, third), Is.EqualTo((1, 0, 1)));
r1!.Dispose();
r3!.Dispose();
monitor.CurrentValue = 2;
Assert.That((first, second, third), Is.EqualTo((1, 0, 1)));
}
[Test]
public void SameCallbackRegisteredTwiceIsCalledTwice()
{
var monitor = new OptionsMonitor<int>(0);
int calls = 0;
void Callback(int value, string? name) => calls++;
IDisposable? r1 = monitor.OnChange(Callback);
using IDisposable? r2 = monitor.OnChange(Callback);
monitor.CurrentValue = 1;
Assert.That(calls, Is.EqualTo(2));
r1!.Dispose();
monitor.CurrentValue = 2;
Assert.That(calls, Is.EqualTo(3));
}
[Test]
public async Task ConcurrentRegistrationAndDisposalKeepsListenersConsistentAsync()
{
var monitor = new OptionsMonitor<int>(0);
int stable = 0;
int leaked = 0;
using IDisposable? keep = monitor.OnChange((_, _) => Interlocked.Increment(ref stable));
var tasks = new Task[Environment.ProcessorCount * 2];
for (int t = 0; t < tasks.Length; t++)
{
tasks[t] = Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
IDisposable? registration = monitor.OnChange(
(_, _) => Interlocked.Increment(ref leaked));
registration!.Dispose();
}
});
}
await Task.WhenAll(tasks).ConfigureAwait(false);
// Only the stable listener is left, no removal was lost
monitor.CurrentValue = 1;
Assert.That((stable, leaked), Is.EqualTo((1, 0)));
int transient = 0;
using IDisposable? probe = monitor.OnChange((_, _) => transient++);
monitor.CurrentValue = 2;
Assert.That((stable, transient), Is.EqualTo((2, 1)));
}
}
}