mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-23 18:39:26 +08:00
48871a7e87
## 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>
278 lines
11 KiB
C#
278 lines
11 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/
|
|
* ======================================================================*/
|
|
|
|
namespace Opc.Ua.SourceGeneration
|
|
{
|
|
/// <summary>
|
|
/// Template strings
|
|
/// </summary>
|
|
internal static class ClientApiTemplates
|
|
{
|
|
/// <summary>
|
|
/// Client API file template
|
|
/// </summary>
|
|
public static readonly TemplateString File = TemplateString.Parse(
|
|
$$"""
|
|
{{Tokens.CodeHeader}}
|
|
|
|
using System;
|
|
|
|
namespace {{Tokens.Prefix}}
|
|
{
|
|
{{Tokens.ServiceSets}}
|
|
}
|
|
""");
|
|
|
|
/// <summary>
|
|
/// Client API service set template (interface + class)
|
|
/// </summary>
|
|
public static readonly TemplateString ServiceSet = TemplateString.Parse(
|
|
$$"""
|
|
/// <summary>
|
|
/// An interface used by by clients to access a UA server.
|
|
/// </summary>
|
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")]
|
|
public interface I{{Tokens.ServiceSet}}ClientMethods{{Tokens.BaseInterfaces}}
|
|
{
|
|
{{Tokens.ClientMethod}}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The client side interface for a UA server.
|
|
/// </summary>
|
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")]
|
|
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()]
|
|
public partial class {{Tokens.ServiceSet}}Client :
|
|
global::Opc.Ua.ClientBase,
|
|
global::Opc.Ua.I{{Tokens.ServiceSet}}ClientMethods
|
|
{
|
|
/// <summary>
|
|
/// Intializes the object with a channel and a message context.
|
|
/// </summary>
|
|
public {{Tokens.ServiceSet}}Client(
|
|
global::Opc.Ua.ITransportChannel channel,
|
|
global::Opc.Ua.ITelemetryContext telemetry)
|
|
: base(channel, telemetry)
|
|
{
|
|
}
|
|
|
|
{{Tokens.ClientApi}}
|
|
}
|
|
|
|
""");
|
|
|
|
/// <summary>
|
|
/// Client API service set template (interface only, no class)
|
|
/// </summary>
|
|
public static readonly TemplateString ServiceSetInterfaceOnly = TemplateString.Parse(
|
|
$$"""
|
|
/// <summary>
|
|
/// An interface used by by clients to access a UA server.
|
|
/// </summary>
|
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")]
|
|
public interface I{{Tokens.ServiceSet}}ClientMethods{{Tokens.BaseInterfaces}}
|
|
{
|
|
{{Tokens.ClientMethod}}
|
|
}
|
|
|
|
""");
|
|
|
|
/// <summary>
|
|
/// Client API interface method template
|
|
/// </summary>
|
|
public static readonly TemplateString InterfaceMethods = TemplateString.Parse(
|
|
$$"""
|
|
/// <summary>
|
|
/// Invokes the {{Tokens.Name}} service.
|
|
/// </summary>
|
|
{{Tokens.ClientMethodAsync}}
|
|
|
|
""");
|
|
|
|
/// <summary>
|
|
/// Client API method implementations template
|
|
/// </summary>
|
|
public static readonly TemplateString Methods = TemplateString.Parse(
|
|
$$"""
|
|
/// <summary>
|
|
/// Invokes the {{Tokens.Name}} service.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The state machine is pooled, so the returned task must be awaited once,
|
|
/// like any value task. A completed call keeps no reference to its response:
|
|
/// a call that waited long enough to be promoted (a Publish waits for the
|
|
/// publishing interval) would otherwise keep every later response alive
|
|
/// until the next full collection.
|
|
/// </remarks>
|
|
#if NET6_0_OR_GREATER
|
|
[global::System.Runtime.CompilerServices.AsyncMethodBuilder(
|
|
typeof(global::System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder<>))]
|
|
#endif
|
|
{{Tokens.ClientMethodAsync}}
|
|
{
|
|
{{Tokens.Name}}Request request = new {{Tokens.Name}}Request();
|
|
{{Tokens.Name}}Response response = null;
|
|
|
|
{{Tokens.RequestParameters}}
|
|
|
|
UpdateRequestHeader(request, requestHeader == null, "{{Tokens.Name}}");
|
|
|
|
try
|
|
{
|
|
global::Opc.Ua.IServiceResponse? genericResponse =
|
|
await TransportChannel.SendRequestAsync(request, ct).ConfigureAwait(false);
|
|
|
|
if (genericResponse == null)
|
|
{
|
|
throw new global::Opc.Ua.ServiceResultException(StatusCodes.BadUnknownResponse);
|
|
}
|
|
|
|
ValidateResponse(genericResponse.ResponseHeader);
|
|
response = ({{Tokens.Name}}Response)genericResponse;
|
|
}
|
|
finally
|
|
{
|
|
RequestCompleted(request, response, "{{Tokens.Name}}");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invokes the {{Tokens.Name}} service synchronously.
|
|
/// </summary>
|
|
[global::System.Obsolete("Sync methods are deprecated in this version. Use {{Tokens.Name}}Async instead.")]
|
|
{{Tokens.ClientMethodSync}}
|
|
{
|
|
{{Tokens.Name}}Request request = new {{Tokens.Name}}Request();
|
|
{{Tokens.Name}}Response? response = null;
|
|
|
|
{{Tokens.RequestParameters}}
|
|
|
|
UpdateRequestHeader(request, requestHeader == null, "{{Tokens.Name}}");
|
|
|
|
try
|
|
{
|
|
global::Opc.Ua.IServiceResponse? genericResponse = TransportChannel
|
|
.SendRequestAsync(request, global::System.Threading.CancellationToken.None)
|
|
.AsTask()
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
|
|
if (genericResponse == null)
|
|
{
|
|
throw new global::Opc.Ua.ServiceResultException(StatusCodes.BadUnknownResponse);
|
|
}
|
|
|
|
ValidateResponse(genericResponse.ResponseHeader);
|
|
response = ({{Tokens.Name}}Response)genericResponse;
|
|
|
|
{{Tokens.ResponseParameters}}
|
|
}
|
|
finally
|
|
{
|
|
RequestCompleted(request, response, "{{Tokens.Name}}");
|
|
}
|
|
|
|
return response.ResponseHeader;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Begins an asynchronous invocation of the {{Tokens.Name}} service.
|
|
/// </summary>
|
|
[global::System.Obsolete("Begin/End methods are deprecated in this version. Use {{Tokens.Name}}Async instead.")]
|
|
{{Tokens.ClientMethodBegin}}
|
|
{
|
|
{{Tokens.Name}}Request request = new {{Tokens.Name}}Request();
|
|
|
|
{{Tokens.RequestParameters}}
|
|
|
|
UpdateRequestHeader(request, requestHeader == null, "{{Tokens.Name}}");
|
|
|
|
global::System.Threading.Tasks.Task<global::Opc.Ua.IServiceResponse> task = TransportChannel
|
|
.SendRequestAsync(request, global::System.Threading.CancellationToken.None)
|
|
.AsTask();
|
|
global::System.Threading.Tasks.TaskCompletionSource<global::Opc.Ua.IServiceResponse> tcs =
|
|
new global::System.Threading.Tasks.TaskCompletionSource<global::Opc.Ua.IServiceResponse>(asyncState);
|
|
task.ContinueWith(t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
{
|
|
tcs.TrySetException(t.Exception!.InnerExceptions);
|
|
}
|
|
else if (t.IsCanceled)
|
|
{
|
|
tcs.TrySetCanceled();
|
|
}
|
|
else
|
|
{
|
|
tcs.TrySetResult(t.Result);
|
|
}
|
|
callback?.Invoke(tcs.Task);
|
|
}, global::System.Threading.Tasks.TaskScheduler.Default);
|
|
return tcs.Task;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finishes an asynchronous invocation of the {{Tokens.Name}} service.
|
|
/// </summary>
|
|
[global::System.Obsolete("Begin/End methods are deprecated in this version. Use {{Tokens.Name}}Async instead.")]
|
|
{{Tokens.ClientMethodEnd}}
|
|
{
|
|
{{Tokens.Name}}Response response = null;
|
|
|
|
try
|
|
{
|
|
global::Opc.Ua.IServiceResponse? genericResponse =
|
|
((global::System.Threading.Tasks.Task<global::Opc.Ua.IServiceResponse>)result)
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
|
|
if (genericResponse == null)
|
|
{
|
|
throw new global::Opc.Ua.ServiceResultException(StatusCodes.BadUnknownResponse);
|
|
}
|
|
|
|
ValidateResponse(genericResponse.ResponseHeader);
|
|
response = ({{Tokens.Name}}Response)genericResponse;
|
|
|
|
{{Tokens.ResponseParameters}}
|
|
}
|
|
finally
|
|
{
|
|
RequestCompleted(null, response, "{{Tokens.Name}}");
|
|
}
|
|
|
|
return response.ResponseHeader;
|
|
}
|
|
|
|
""");
|
|
}
|
|
}
|