A modern, high-performance, zero-allocation .NET binding for Eclipse Cyclone DDS, with idiomatic C# API.
See detailed technical overview.
Install the CycloneDDS.NET package from NuGet:
dotnet add package CycloneDDS.NETThis single package includes:
- Runtime Library: High-performance managed bindings.
- Native Assets: Pre-compiled
ddsc.dllandidlc.exe(Windows x64). - Build Tools: Automatic C# code generation during build.
Important: This package relies on native libraries that require the Visual C++ Redistributable for Visual Studio 2022 to be installed on the target system.
If you want to build the project from source or contribute:
-
Clone the repository (recursively, to get the native submodule):
git clone --recursive https://github.com/pjanec/CycloneDds.NET.git cd CycloneDds.NET -
Build and Test (One-Stop Script): Run the developer workflow script. This will automatically check for native artifacts (building them if missing), build the solution, and run all tests.
.\build\build-and-test.ps1
-
Requirements:
- .NET 8.0 SDK
- Visual Studio 2022 (C++ Desktop Development workload) for native compilation.
- CMake 3.16+ in your PATH.
- Zero-Allocation Writes: Custom marshaller writes directly to pooled buffers (ArrayPool) using a C-compatible memory layout.
- Zero-Copy Reads: Read directly from native DDS buffers using
ref structviews, bypassing deserialization. - Unified API: Single reader provides both safe managed objects and high-performance zero-copy views.
- Lazy Deserialization: Only pay the cost of deep-copying objects when you explicitly access
.Data.
- Code-First DSL: Define your data types entirely in C# using attributes (
[DdsTopic],[DdsKey],[DdsStruct],[DdsQos]). No need to write IDL files manually. - Automatic IDL Generation: The build tools automatically generate standard OMG IDL files from your C# classes, ensuring perfect interoperability with other DDS implementations (C++, Python, Java) and tools. See IDL Generation.
- Modern Language Integration: Full support for C# 12
[InlineArray](safe fixed-size arrays withoutunsafe), typed enums (enum E : byteemits@bit_bound(8)), and default topic naming based on namespaces. - Auto-Magic Type Discovery: Runtime automatically registers type descriptors based on your schema.
- IDL Import: Convert existing IDL files into C# DSL automatically using the
IdlImportertool. - 100% Native Compliance: Uses Cyclone DDS native serializer for wire compatibility.
- Auto-Magic Type Discovery: No manual IDL compilation or type registration required.
- Async/Await:
WaitDataAsyncfor non-blocking, task-based consumers. - Client-Side Filtering: High-performance predicates (
view => view.Id > 5) compiled to JIT code. - Instance Management: O(1) history lookup for keyed topics.
- Sender Tracking: Identify the source application (Computer, PID, custom app id) of every message.
- Modern C#: Events, Properties, and generic constraints instead of listeners and pointers.
- Partition Support: Isolate traffic using DDS partitions. Set a partition on a participant once and every reader/writer inherits it automatically, or override per-reader/writer with a named argument.
- Zero-Allocation WaitSet: Monitor 100+ readers on a single OS thread.
DdsWaitSet.Wait(Span<IDdsReader>, timeout, ct)never allocates in the hot path and supportsCancellationTokenfor instant, safe interruption.
Define your data using standard C# partial structs. The build tools generate the serialization logic automatically.
Use this for high-frequency data (1kHz+).
using CycloneDDS.Schema;
namespace Factory.Monitoring;
// Topic name defaults to namespace + class name ("Factory_Monitoring_SensorData") if omitted
[DdsTopic]
public partial struct SensorData
{
[DdsKey, DdsId(0)]
public int SensorId;
[DdsId(1)]
public double Value;
// Fixed-size buffer (maps to char[32]). No heap allocation.
[DdsId(2)]
public FixedString32 LocationId;
// Safe, zero-allocation fixed array using C# 12 [InlineArray] (no 'unsafe' needed!)
[DdsId(3)]
public FloatBuffer8 Measurements;
// Byte-backed enum yields IDL @bit_bound(8) automatically for optimal native network usage
[DdsId(4)]
public SensorStatus Status;
}
[System.Runtime.CompilerServices.InlineArray(8)]
public struct FloatBuffer8 { private float _element0; }
public enum SensorStatus : byte
{
Offline,
Online,
Error
}For scenarios requiring direct memory manipulation or porting legacy C/C++ structs, you can use unsafe fixed arrays. The runtime maps these directly to native memory with zero allocation.
[DdsTopic("CustomTopicNameForVideoFrame")]
public unsafe partial struct VideoFrame
{
[DdsKey]
public int FrameId;
// Classic C# unsafe fixed-size buffer
public fixed byte Pixels[1920 * 1080 * 3];
}Use this for business logic where convenience outweighs raw speed.
[DdsStruct] // Helper struct to be used in the topic data struct (can be nested)
public partial struct GeoPoint { public double Lat; public double Lon; }
[DdsTopic("LogEvents")]
[DdsManaged] // Opt-in to GC allocations for the whole type
public partial struct LogEvent
{
[DdsKey]
public int Id;
// Standard string (Heap allocated)
public string Message;
// Standard List (Heap allocated)
public List<double> History;
// Nested custom struct
public GeoPoint Origin;
}You can define Quality of Service settings directly on the type using the [DdsQos] attribute. The Runtime automatically applies these settings when creating Writers and Readers for this topic.
[DdsTopic("MachineState")]
[DdsQos(
Reliability = DdsReliability.Reliable, // Guarantee delivery
Durability = DdsDurability.TransientLocal, // Late joiners get the last value
HistoryKind = DdsHistoryKind.KeepLast, // Keep only recent data
HistoryDepth = 1 // Only the latest sample
)]
public partial struct MachineState
{
[DdsKey]
public int MachineId;
public StateEnum CurrentState;
}using Factory.Monitoring;
using var participant = new DdsParticipant();
// Auto-discovers topic type and its default name ("Factory_Monitoring_SensorData")
using var writer = new DdsWriter<SensorData>(participant);
// Zero-allocation write path
var data = new SensorData
{
SensorId = 1,
Value = 25.5,
LocationId = new FixedString32("Factory_A"),
Status = SensorStatus.Online
};
// With C# 12, InlineArrays can be accessed directly by index
data.Measurements[0] = 1.0f;
writer.Write(data);Reading uses a Scope pattern to ensure safety and zero-copy semantics. You "loan" the data, read it, and return it by disposing the scope.
using Factory.Monitoring;
using var reader = new DdsReader<SensorData>(participant);
// POLL FOR DATA
// Returns a "Loan" which manages native memory
using var loan = reader.Take(maxSamples: 10);
// Iterate received data
foreach (var sample in loan)
{
// `sample.IsValid` indicates whether a full payload is present.
// IMPORTANT: even when `ValidData == 0` (lifecycle/metadata-only samples),
// the middleware provides the native memory with the topic key fields populated.
// Therefore `sample.Data` is safe to call for every sample and will return
// a managed object where key fields are set and non-key fields are defaulted.
// Always obtain the managed copy (safe for metadata-only samples too)
var data = sample.Data;
if (sample.IsValid)
{
// OPTION A: Simple (Managed)
// `data` is a full managed copy populated from native memory
Console.WriteLine($"Received: {data.SensorId} = {data.Value}");
}
else
{
// Lifecycle event (e.g., instance disposed). Key fields are available in `data`.
Console.WriteLine($"Instance {data.SensorId} state: {sample.Info.InstanceState}");
}
// OPTION B: Fast (Zero-Copy) — you can still use AsView() when you only need
// transient, zero-allocation access to the native buffer (stack-only).
// var view = sample.AsView();
}Bridge the gap between real-time DDS and .NET Tasks. No blocking threads required.
Console.WriteLine("Waiting for data...");
// Efficiently waits using TaskCompletionSource (no polling loop)
while (await reader.WaitDataAsync())
{
// Take all available data
using var scope = reader.Take();
foreach (var sample in scope)
{
await ProcessAsync(sample);
}
}Filter data before you pay the cost of processing it. This implementation uses C# delegates but executes on the raw buffer view, allowing JIT optimizations to make it extremely fast.
// 1. Set a filter predicate on the Reader
// Logic executes during iteration, skipping irrelevant samples instantly.
// Since 'view' is a ref struct reading raw memory, this is Zero-Copy filtering.
reader.SetFilter(view => view.Value > 100.0 && view.LocationId.ToString() == "Lab_1");
// 2. Iterate
using var scope = reader.Take();
foreach (var highValueSample in scope)
{
// Guaranteed to be > 100.0 and from Lab_1
}
// 3. Update filter dynamically at runtime
reader.SetFilter(null); // Clear filterFor systems tracking many objects (fleets, tracks, sensors), efficiently query a specific object's history without iterating the entire database.
// 1. Create a key template for the object we care about
var key = new SensorData { SensorId = 5 };
// 2. Lookup the Handle (O(1) hashing)
DdsInstanceHandle handle = reader.LookupInstance(key);
if (!handle.IsNil)
{
// 3. Read history for ONLY Sensor 5
// Ignores Sensor 1, 2, 3... Zero iteration overhead.
using var history = reader.ReadInstance(handle, maxSamples: 100);
foreach (var snapshot in history)
{
Plot(snapshot.Value);
}
}Identify exactly which application instance sent a message. Essential for multi-process debugging.
var config = new SenderIdentityConfig
{
AppDomainId = 1,
AppInstanceId = 100
};
// Enable tracking BEFORE creating writers
participant.EnableSenderTracking(config);
// Now, every writer created by this participant automatically broadcasts identity
using var writer = new DdsWriter<LogEvent>(participant, "Logs");// Enable tracking on the reader
reader.EnableSenderTracking(participant.SenderRegistry);
using var scope = reader.Take();
for (int i = 0; i < scope.Count; i++)
{
// O(1) Lookup of sender info
// Returns: ComputerName, ProcessName, ProcessId, AppDomainId, etc.
var sender = scope.GetSender(i);
var msg = scope[i];
if (sender != null)
{
Console.WriteLine($"[{sender.ComputerName} : PID {sender.ProcessId}] says: {msg.Message}");
}
}Know when peers connect or disconnect using standard C# Events.
// Writer Side
writer.PublicationMatched += (s, status) =>
{
if (status.CurrentCountChange > 0)
Console.WriteLine($"Subscriber connected! Total: {status.CurrentCount}");
else
Console.WriteLine("Subscriber lost.");
};
// Reliable Startup (Wait for Discovery)
// Solves the "Lost First Message" problem
await writer.WaitForReaderAsync(TimeSpan.FromSeconds(5));
writer.Write(new Message("Hello")); // Guaranteed to have a routeProperly manage the lifecycle of data instances in the Global Data Space.
var key = new SensorData { SensorId = 1 };
// 1. Data is invalid/deleted
// Readers receive InstanceState = NOT_ALIVE_DISPOSED
writer.DisposeInstance(key);
// 2. Writer is shutting down (graceful disconnect)
// Readers receive InstanceState = NOT_ALIVE_NO_WRITERS (if ownership exclusive)
writer.UnregisterInstance(key);DDS partitions let you divide a domain into named logical channels. Readers and writers only communicate within the same partition, making it easy to run multiple isolated subsystems on the same DDS domain (e.g. separate a monitoring plane from a control plane, or multiplex tenants).
// All readers and writers created from this participant will use "monitoring" automatically.
using var participant = new DdsParticipant(domainId: 0, defaultPartition: "monitoring");
// Topic name comes from [DdsTopic("SensorData")] — no need to repeat it.
using var reader = new DdsReader<SensorData>(participant);
using var writer = new DdsWriter<SensorData>(participant);using var participant = new DdsParticipant(0, defaultPartition: "*"); // wildcard default
// This writer specifically targets the "control" partition.
using var controlWriter = new DdsWriter<SensorData>(
participant, "SensorData", partition: "control");
// This reader stays on the default "*" partition — sees everything.
using var broadcastReader = new DdsReader<SensorData>(participant);per-reader / per-writer partition → participant.DefaultPartition → (no partition)
DdsWaitSet provides a native-backed mechanism for sleeping on many readers simultaneously on a single OS thread. This is ideal for monitoring applications that track 100+ topics and do not want the overhead of spawning a background Task per reader.
using var participant = new DdsParticipant(0, defaultPartition: "*");
// Create readers for every topic you want to monitor
using var tempReader = new DdsReader<TemperatureEvent>(participant);
using var pressReader = new DdsReader<PressureEvent>(participant);
using var statusReader = new DdsReader<MachineStatus>(participant);
// Create WaitSet and attach all readers
using var waitset = new DdsWaitSet(participant);
waitset.Attach(tempReader);
waitset.Attach(pressReader);
waitset.Attach(statusReader);
// Pre-allocate result buffer once — no allocation inside the loop
IDdsReader[] triggered = new IDdsReader[16];
var cts = new CancellationTokenSource();
while (!cts.IsCancellationRequested)
{
// Blocks until at least one reader has data, or the timeout expires, or ct is cancelled.
// Zero allocation in this hot path.
int count = waitset.Wait(triggered.AsSpan(), timeout: TimeSpan.FromSeconds(1), cts.Token);
for (int i = 0; i < count; i++)
{
switch (triggered[i])
{
case DdsReader<TemperatureEvent> r:
using (var loan = r.Take()) { /* handle temp */ }
break;
case DdsReader<PressureEvent> r:
using (var loan = r.Take()) { /* handle pressure */ }
break;
case DdsReader<MachineStatus> r:
using (var loan = r.Take()) { /* handle status */ }
break;
}
}
}Readers can be added or removed while the WaitSet is not waiting, making the monitored set dynamic:
// Start watching a new topic at runtime
var newReader = new DdsReader<AlarmEvent>(participant);
waitset.Attach(newReader);
// Stop watching (and dispose the reader when no longer needed)
waitset.Detach(newReader);
newReader.Dispose();Pass a CancellationToken to Wait to interrupt the blocking native call safely from any thread:
cts.Cancel(); // triggers the native guard condition, unblocks Wait() instantly| Operation | Allocation | Notes |
|---|---|---|
Wait(...) hot path |
0 Bytes | ArrayPool rent inside; result written into caller's Span |
Attach / Detach |
Small (one-time) | GCHandle + dictionary entry per reader |
| Cancellation callback | 0 Bytes | Triggers native guard condition via P/Invoke |
If you have existing DDS systems defined in IDL, you can generate the corresponding C# DSL automatically.
# Import IDL to C#
CycloneDDS.IdlImporter MySystem.idl ./src/GeneratedThis generates C# [DdsTopic] structs that are binary-compatible with your existing system.
See IDL Import Guide for advanced usage including multi-module support.
A complete "Hello World" example that demonstrates creating a topic, publishing, and subscribing in a single application can be found in examples/HelloWorld.
This example is designed to verify the NuGet package installation and basic functionality using the locally built package.
To run it:
- Build the packages:
.\build\pack.ps1 - Run the example:
cd examples/HelloWorld dotnet run
The CycloneDDS.NET package bundles these internal components:
- Managed Libraries:
CycloneDDS.Core,CycloneDDS.Schema,CycloneDDS.CodeGen,CycloneDDS.Runtime - Native Assets:
ddsc.dll(Cyclone DDS),idlc.exe(IDL Compiler),cycloneddsidljson.dll(IDL JSON plugin)
| Feature | Allocation Cost | Performance Note |
|---|---|---|
| Write | 0 Bytes | Uses ArrayPool + NativeArena |
| Read (View) | 0 Bytes | Uses .AsView() + Ref Structs |
| Read (Managed) | Allocates | Uses .Data (Deep Copy) |
| Take (Polling) | 0 Bytes | Uses Loaned Buffers |
| Filtering | 0 Bytes | Manual loop filtering with Views |
| Sender Lookup | 0 Bytes | O(1) Dictionary Lookup |
| Async Wait | ~80 Bytes | One Task per await cycle |
| WaitSet.Wait | 0 Bytes | Span output + ArrayPool rent; no heap in hot path |
Built for speed. Designed for developers.