ManagedSession: release the reader lock from a pooled state machine

ExitReaderOnCompletion registered the release as a continuation on the
service call's task, so the caller's await was the second continuation
and allocated the task's continuation list (~80 B per request). On .NET 6
and later the call is awaited in a pooled state machine that releases the
lock before its own result completes; net48 keeps the continuation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-19 15:04:37 +02:00
co-authored by Claude Opus 5.5
parent 8edf352b93
commit 4528d04676
@@ -28,6 +28,7 @@
* ======================================================================*/
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
@@ -96,7 +97,9 @@ namespace Opc.Ua.Client
/// </summary>
public AsyncReaderWriterLock()
{
#if !NET6_0_OR_GREATER
m_releaseReader = ReleaseReader;
#endif
}
/// <summary>
@@ -187,13 +190,16 @@ namespace Opc.Ua.Client
/// task for the same result.
/// </summary>
/// <remarks>
/// The release is the first continuation registered on the operation's
/// task and runs synchronously when it completes, so the lock is normally
/// free again by the time an awaiter of the returned task resumes, as with
/// a <c>using</c> block around the awaited operation. Only if the task
/// completes while the continuation is being registered does the release
/// run on the thread pool instead, shortly after. The delegate is cached,
/// so this allocates nothing beyond the task's continuation list.
/// The lock is free again by the time an awaiter of the returned task
/// resumes, as with a <c>using</c> block around the awaited operation.
/// On .NET 6 and later the returned task awaits the operation in a pooled
/// state machine, the only continuation of the operation, so this
/// allocates nothing in the steady state; the returned task must be
/// awaited once, like any value task. Otherwise the release is the first
/// continuation registered on the operation's task and the caller's await
/// the second, which allocates the task's continuation list; only if the
/// task completes while the release is being registered does it run on
/// the thread pool, shortly after.
/// </remarks>
/// <typeparam name="T">The result of the operation.</typeparam>
public ValueTask<T> ExitReaderOnCompletion<T>(ValueTask<T> operation)
@@ -203,11 +209,34 @@ namespace Opc.Ua.Client
ReleaseReader();
return operation;
}
#if NET6_0_OR_GREATER
return ExitReaderAfterAsync(operation);
#else
Task<T> task = operation.AsTask();
task.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(m_releaseReader);
return new ValueTask<T>(task);
#endif
}
#if NET6_0_OR_GREATER
/// <summary>
/// Awaits <paramref name="operation"/> and releases the reader lock.
/// </summary>
/// <typeparam name="T">The result of the operation.</typeparam>
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
private async ValueTask<T> ExitReaderAfterAsync<T>(ValueTask<T> operation)
{
try
{
return await operation.ConfigureAwait(false);
}
finally
{
ReleaseReader();
}
}
#endif
/// <summary>
/// Asynchronously acquires the writer lock. Excludes both
/// other writers and all readers for the duration the lock
@@ -390,7 +419,9 @@ namespace Opc.Ua.Client
}
}
#if !NET6_0_OR_GREATER
private readonly Action m_releaseReader;
#endif
private bool m_isDisposed;
private readonly SemaphoreSlim m_writer = new(1, 1);
private readonly Lock m_state = new();