mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
556c9cb797
# Description `NodeBrowser` was synchronous end to end, so a node manager whose browse result depends on I/O had nowhere to `await`. The aggregation server sample is the concrete case: its browser has to browse the aggregated server, and the only way to write it was `Next() => NextAsync().GetAwaiter().GetResult()`, which holds a request worker for the full remote round trip on every browse. This is option (1) from the issue, the small non-breaking step: - `INodeBrowser` and `NodeBrowser` gain `ValueTask<IReference?> NextAsync(CancellationToken cancellationToken = default)`. The default completes synchronously with the result of `Next()`, so every existing browser that only overrides `Next()` keeps working unchanged (the samples' `MemoryBufferBrowser` and the migration-analyzer test snippets compile untouched). - `AsyncCustomNodeManager.BrowseAsync` and `TranslateBrowsePathAsync` iterate browsers through `NextAsync` with the request's cancellation token. The loop bodies already awaited; only the iteration could not. - `DirectoryBrowser` was the one browser the stack ships that blocked on async I/O. It now awaits the file-system provider enumeration in `NextAsync` and keeps `Next()` as the bridge for the remaining synchronous consumers (`UANodeSetHelpers` export, `CustomNodeManager2`). Cancellation now propagates instead of being reported as an empty directory. - The synchronous paths (`CustomNodeManager2`, `CoreNodeManager` diagnostics, nodeset export) are unchanged on purpose; they have no place to await. **Not done, by design.** Browser creation stays synchronous. `OnCreateBrowser` runs under the node's browse lock, so the documented contract is that a browser fetches lazily on its first `NextAsync`. The issue itself notes the async creation seam is not needed for the aggregation case. `IAsyncEnumerable` (option 3) is not attempted. **Compatibility note.** A type that implements `INodeBrowser` directly without deriving from `NodeBrowser` has to add the member. Nothing in this repo or the samples does that; derived browsers are unaffected. **Docs** updated in `NodeManagers.md`, `AsyncServerSupport.md`, `MigrationGuide.md` and `migrate/2.0.x/node-states.md`, with a was/now example for a browser that previously bridged sync-over-async. **Tests** - `NodeBrowserTests` (new): default wrapper completes synchronously and yields the same sequence as `Next()`, push-back through `NextAsync`, a `Next`-only override observed through `NextAsync`, an awaited `NextAsync` override with cancellation. - `AsyncCustomNodeManagerTests`: a browser whose `Next()` throws is driven entirely through `NextAsync` by browse, by the continuation-point round trip (push-back on the async path), and by translate-path. - `DirectoryObjectStateTests`: the async path of the directory browser, including the throwing provider and cancellation. Verified locally: `Opc.Ua.Server` builds on net10.0 and net48 with 0 errors; the browser-related server suites (1342 tests) and the new unit tests pass on net10.0; `dotnet format whitespace --verify-no-changes` is clean on the changed files. ## Related Issues - Fixes #4415 ## 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. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] 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 Fable 5.1 <noreply@anthropic.com>
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
# Support of the TAP (Task Asynchronous Pattern) for server operations
|
|
|
|
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 `MasterNodeManager` to support Task based operations
|
|
- Introduce a Task based `IAsyncNodeManager` interface
|
|
- Introduce `AsyncNodeManagerAdapter` and `SyncNodeManagerAdapter` classes to support sync and async node managers side by side.
|
|
|
|
## Upgrading an existing server
|
|
|
|
- Update `INodeManager.CreateMonitoredItems` to support the new `MonitoredItemIdFactory`.
|
|
- In a future release an `CustomNodeManagerAsync` class will be provided to simplify the creation of fully async NodeManagers.
|
|
- The existing `CustomNodeManager2` class can be used as is, and async operations can be implemented as needed using the different interfaces provided by the server library:
|
|
- `IAsyncNodeManager` for full async support
|
|
- `ICallAsyncNodeManager` for async method calls
|
|
- `IReadAsyncNodeManager` for async reading
|
|
- `IWriteAsyncNodeManager` for async writing
|
|
- `IHistoryReadAsyncNodeManager` for async history read
|
|
- `IHistoryUpdateAsyncNodeManager` for async history update
|
|
- `IConditionRefreshAsyncNodeManager` for async condition refresh
|
|
- `ITranslateBrowsePathAsyncNodeManager` for async translate browse path
|
|
- `IBrowseAsyncNodeManager` for async browsing
|
|
- `ISetMonitoringModeAsyncNodeManager` for async monitoring mode changes
|
|
- `ITransferMonitoredItemsAsyncNodeManager` for async monitored item transfer
|
|
- `IDeleteMonitoredItemsAsyncNodeManager` for async monitored item deletion
|
|
- `IModifyMonitoredItemsAsyncNodeManager` for async monitored item modification
|
|
- `ICreateMonitoredItemsAsyncNodeManager` for 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 `IAsyncNodeManager` interface. To register a fully async Nodemanager use `StandardServer.RegisterNodeManager(IAsyncNodeManagerFactory)`.
|
|
For compatibility reasons the IAsyncNodeManager has a property `SyncNodeManager`, this needs to be implemented by passing your IAsyncNodeManager to the `SyncNodeManagerAdapter`.
|
|
|
|
## Async Method call
|
|
|
|
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
|
|
|
|
`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 inside `Task.Run` or similar helpers.
|
|
- The `SyncNodeManager` property (required by `IAsyncNodeManager`) is satisfied automatically: the
|
|
constructor calls `this.ToSyncNodeManager()` and stores the resulting `INodeManager3` adapter.
|
|
Callers that still require an `INodeManager3` reference (e.g. legacy subscription code) use that
|
|
adapter; the node manager itself never needs to implement the synchronous interface.
|
|
|
|
### Registering an AsyncCustomNodeManager
|
|
|
|
Use the `IAsyncNodeManagerFactory` overload of `StandardServer.RegisterNodeManager`:
|
|
|
|
```csharp
|
|
server.RegisterNodeManager(context =>
|
|
new MyAsyncNodeManager(server, configuration));
|
|
```
|
|
|
|
### Async browse iteration
|
|
|
|
Every hook a custom node manager overrides is awaitable, and that includes browsing.
|
|
`AsyncCustomNodeManager.BrowseAsync` and `TranslateBrowsePathAsync` iterate an
|
|
`INodeBrowser` through `NextAsync(CancellationToken)` rather than `Next()`, so a browser that
|
|
has to reach an underlying system — the aggregation case, where the references come from
|
|
another server — awaits that fetch on the first `NextAsync` instead of holding a request
|
|
worker on a blocking call. The default `NextAsync` wraps `Next()`, so the browsers the stack
|
|
ships and every existing custom browser keep working untouched. See
|
|
[NodeManagers.md](NodeManagers.md#threading-contract-for-nodes-and-browsers) for the browser contract.
|
|
|
|
### Locking strategy vs CustomNodeManager2
|
|
|
|
`CustomNodeManager2` protects its entire address space with a **single coarse-grained monitor
|
|
lock** stored in the `Lock` property:
|
|
|
|
```csharp
|
|
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**:
|
|
|
|
#### 1. Global write semaphore — `m_writeSemaphore`
|
|
|
|
A `SemaphoreSlim(1, 1)` that serialises all **write** operations across the node manager.
|
|
Because it is a `SemaphoreSlim` it can be acquired with `await`:
|
|
|
|
```csharp
|
|
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.
|
|
|
|
#### 2. Monitored-item semaphore — `m_monitoredItemSemaphore`
|
|
|
|
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:
|
|
|
|
```csharp
|
|
await m_monitoredItemSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// create / delete / modify monitored items
|
|
}
|
|
finally
|
|
{
|
|
m_monitoredItemSemaphore.Release();
|
|
}
|
|
```
|
|
|
|
#### 3. Per-node locking for read and attribute access
|
|
|
|
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:
|
|
|
|
```csharp
|
|
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` |
|
|
|
|
### Monitored-item manager selection
|
|
|
|
The constructor accepts an optional `useSamplingGroups` flag:
|
|
|
|
```csharp
|
|
// 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 awaiting `NodeState.ClearChangeMasksAsync` after every successful
|
|
write (see [Fully asynchronous change notifications](#fully-asynchronous-change-notifications)).
|
|
No background threads are created per subscription.
|
|
- **`SamplingGroupMonitoredItemManager`**: a background timer thread samples the current node
|
|
value at the negotiated `SamplingInterval`. 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).
|
|
|
|
### Fully asynchronous change notifications
|
|
|
|
`AsyncCustomNodeManager` propagates value changes and events to monitored items over a fully asynchronous push path, so no thread is blocked when a node exposes an asynchronous value read handler or when the per-node notification channel applies back-pressure.
|
|
|
|
`NodeState` exposes asynchronous counterparts of its change-notification API alongside the existing synchronous members:
|
|
|
|
| Synchronous | Asynchronous |
|
|
|-------------|--------------|
|
|
| `ClearChangeMasks(context, includeChildren)` | `ClearChangeMasksAsync(context, includeChildren, ct)` |
|
|
| `OnStateChanged` / `StateChanged` | `OnStateChangedAsync` / `StateChangedAsync` |
|
|
| `ReportEvent(context, e)` | `ReportEventAsync(context, e, ct)` |
|
|
| `OnReportEvent` | `OnReportEventAsync` |
|
|
|
|
The additions are non-breaking: existing synchronous callers and sinks continue to work unchanged. `MonitoredNodeMonitoredItemManager` wires the asynchronous sinks, so after a successful write `AsyncCustomNodeManager.WriteAsync` flushes changes with `await handle.Node.ClearChangeMasksAsync(...)`. The value for each monitored attribute is read at that moment through `NodeState.ReadAttributeAsync` — honoring an asynchronous `OnReadValueAsync` / `OnSimpleReadValueAsync` handler when one is registered — and the resulting snapshot is enqueued by awaiting the bounded per-node channel. Because the read is materialized before the change is enqueued, every notification carries the node value at the instant of the change (an enqueue-time snapshot); because both the read and the enqueue are awaited, the producing thread is never blocked.
|
|
|
|
The synchronous `ClearChangeMasks` / `ReportEvent` still drive the asynchronous sinks: they complete inline when the node is synchronously readable and the channel has capacity, and block the calling thread only when a genuinely asynchronous read is in flight or the channel is full. A synchronous node manager therefore keeps working without any change, paying a blocking cost only in those two cases.
|
|
|
|
Overflow and discard — the FIFO/LIFO `DiscardOldest` behavior and the `Overflow` status bit — remain a property of each monitored item's own queue. The shared per-node channel is a lossless conduit and does not drop notifications, so a monitored item's configured queue policy is always applied to the full stream of changes.
|
|
|
|
### Creating a custom node manager
|
|
|
|
Derive from `AsyncCustomNodeManager` and override only the virtual methods you need:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|