The OPC UA .NET Standard stack has supported asynchronous operations for a long time. The asynchronous operations are based on the IAsyncResult pattern, which is also called the APM (Asynchronous Programming Model) or the Begin/End pattern. This pattern was introduced with .NET Framework 1.0 and is still supported in .NET 8.0.
In addition to the APM, the TAP (Task Asynchronous Pattern) is now also supported for server operations. The TAP is based on the Task and async/await keywords introduced in .NET Framework 4.0 and is the recommended way to implement asynchronous operations in modern .NET applications.
In the future APM support will be deprecated, as it was never implemented for NodeManagers.
Starting with 1.5.378 the server library allows users to also implement Task based NodeManagers. Implementing the TAP allows to improve the scalability of the server, as the TAP is significantly more efficient in terms of resource usage and performance.
In order to support the TAP pattern, the following changes have been made to the server library:
- Introduce a Task based
RequestQueue - Introduce a Task based
TransportListenerCallback - Update the generated Code to support Task based operations
- Update of the
MasterNodeManagerto support Task based operations - Introduce a Task based
IAsyncNodeManagerinterface - Introduce
AsyncNodeManagerAdapterandSyncNodeManagerAdapterclasses to support sync and async node managers side by side.
- Update
INodeManager.CreateMonitoredItemsto support the newMonitoredItemIdFactory. - In a future release an
CustomNodeManagerAsyncclass will be provided to simplify the creation of fully async NodeManagers. - The existing
CustomNodeManager2class can be used as is, and async operations can be implemented as needed using the different interfaces provided by the server library:IAsyncNodeManagerfor full async supportICallAsyncNodeManagerfor async method callsIReadAsyncNodeManagerfor async readingIWriteAsyncNodeManagerfor async writingIHistoryReadAsyncNodeManagerfor async history readIHistoryUpdateAsyncNodeManagerfor async history updateIConditionRefreshAsyncNodeManagerfor async condition refreshITranslateBrowsePathAsyncNodeManagerfor async translate browse pathIBrowseAsyncNodeManagerfor async browsingISetMonitoringModeAsyncNodeManagerfor async monitoring mode changesITransferMonitoredItemsAsyncNodeManagerfor async monitored item transferIDeleteMonitoredItemsAsyncNodeManagerfor async monitored item deletionIModifyMonitoredItemsAsyncNodeManagerfor async monitored item modificationICreateMonitoredItemsAsyncNodeManagerfor async monitored item creation
--> The MasterNodeManager automatically detects if a NodeManager implements any of the async interfaces and uses the async implementation if available. If no async interface is implemented, the sync implementation is used.
- The Server already allows to register fully async NodeManagers, which implement the
IAsyncNodeManagerinterface. To register a fully async Nodemanager useStandardServer.RegisterNodeManager(IAsyncNodeManagerFactory). For compatibility reasons the IAsyncNodeManager has a propertySyncNodeManager, this needs to be implemented by passing your IAsyncNodeManager to theSyncNodeManagerAdapter.
Support for async method callbacks is already implemented by CustomNodeManager2 to enable the support just add IAsyncNodeManager to your NodeManager implementation.
All generated code already has support for Async Methods e.g. UpdateCertificateMethodState.OnCallAsync. If the NodeManager implements IAsyncNodeManager the async callback is used automatically.
If a generic Method handler shall be used the MethodState.OnCallMethod2Async handler shall be used.
AsyncCustomNodeManager is the recommended base class for building fully async, TAP-native node managers.
Unlike the older CustomNodeManager2, it implements IAsyncNodeManager directly rather than
INodeManager3. This has two practical consequences:
- All virtual methods are
async ValueTask-returning from the start, so there is no boilerplate wrapping of synchronous code insideTask.Runor similar helpers. - The
SyncNodeManagerproperty (required byIAsyncNodeManager) is satisfied automatically: the constructor callsthis.ToSyncNodeManager()and stores the resultingINodeManager3adapter. Callers that still require anINodeManager3reference (e.g. legacy subscription code) use that adapter; the node manager itself never needs to implement the synchronous interface.
Use the IAsyncNodeManagerFactory overload of StandardServer.RegisterNodeManager:
server.RegisterNodeManager(context =>
new MyAsyncNodeManager(server, configuration));CustomNodeManager2 protects its entire address space with a single coarse-grained monitor
lock stored in the Lock property:
lock (Lock)
{
// all reads and writes go through this single lock
}While simple, this serialises all concurrent requests for the whole node manager and blocks the
calling thread, which prevents the use of await inside the critical section.
AsyncCustomNodeManager replaces this with a two-tier, await-compatible locking model:
A SemaphoreSlim(1, 1) that serialises all write operations across the node manager.
Because it is a SemaphoreSlim it can be acquired with await:
await m_writeSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// safe to write any node
}
finally
{
m_writeSemaphore.Release();
}Only one write request runs at a time, preventing concurrent modifications of the address space. Read operations do not acquire this semaphore.
A second SemaphoreSlim(1, 1) that serialises all monitored-item management operations
(create, modify, delete, set-monitoring-mode, subscribe-to-events, condition-refresh, transfer).
This keeps subscription state consistent without blocking reads or writes:
await m_monitoredItemSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// create / delete / modify monitored items
}
finally
{
m_monitoredItemSemaphore.Release();
}Reads do not acquire any manager-wide lock. Instead they lock only the individual NodeState
object being accessed. This allows many reads to run truly in parallel across different nodes:
lock (handle.Node)
{
errors[ii] = handle.Node.ReadAttribute(
systemContext,
nodeToRead.AttributeId,
nodeToRead.ParsedIndexRange,
nodeToRead.DataEncoding,
value);
}The same per-node lock is used for the old-value read inside WriteAsync and for
FindChildBySymbolicName lookups in the component cache.
| Concern | CustomNodeManager2 |
AsyncCustomNodeManager |
|---|---|---|
| Address-space reads | Global lock (Lock) |
Per-node lock (node) (parallel) |
| Address-space writes | Global lock (Lock) |
await m_writeSemaphore (serial) |
| Monitored-item management | Global lock (Lock) |
await m_monitoredItemSemaphore |
await inside critical section |
Not possible | Supported everywhere |
| Implemented interface | INodeManager3 |
IAsyncNodeManager |
The constructor accepts an optional useSamplingGroups flag:
// Default: change-triggered (MonitoredNodeMonitoredItemManager)
public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
: base(server, config) { }
// Opt-in: timer-based sampling (SamplingGroupMonitoredItemManager)
public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
: base(server, config, useSamplingGroups: true) { }MonitoredNodeMonitoredItemManager(default): node value changes are propagated to subscribers immediately by callingNodeState.ClearChangeMasksafter every successful write. No background threads are created per subscription.SamplingGroupMonitoredItemManager: a background timer thread samples the current node value at the negotiatedSamplingInterval. Write changes are not pushed immediately; instead the next scheduled sample detects and delivers them. Choose this mode when the data source produces values independently of OPC UA write requests (e.g. hardware polling).
Derive from AsyncCustomNodeManager and override only the virtual methods you need:
public class MyNodeManager : AsyncCustomNodeManager
{
public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
: base(server, config, "http://my.org/UA/Data/")
{
}
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
// build your nodes here
var myVar = new BaseDataVariableState(null);
myVar.NodeId = new NodeId("MyVar", NamespaceIndex);
myVar.Value = 42;
await AddNodeAsync(SystemContext, default, myVar, cancellationToken)
.ConfigureAwait(false);
}
}