Messaging vs MessagePipe
This page compares Onity.Messaging with MessagePipe 1.8.1, the typed pub/sub library it replaces in a Unity project: first the feature shape, then the measured results of the 0.8.0 core with their conditions and caveats. The numbers come from one evidence record, messaging-surpass-messagepipe-2026-10-02.md, whose summaries were produced by the comparison harness in this repository. The summaries print “Onity 0.7.0” because the package version was not yet raised when they ran; the measured runtime is source 4cc5763, as the evidence record explains. The mapping for moving code over is Migrating from MessagePipe.
Contents: Scope, Feature comparison, Measured results for 0.8.0, The .NET build, What changed in 0.8.0, Conditions, Fairness notes, Caveats, The 0.7.0 baseline.
Scope
The comparison is a timing comparison of nine main-thread workloads: publish to no, one and eight subscribers; subscribe and dispose with one and with 64 resident subscribers; keyed publish and keyed subscribe and dispose over 16 keys; and async publish to one and to eight handlers that complete synchronously, consumed without awaiting. Each library runs the same workload through its public interfaces with its documented delegate Subscribe overloads, and a library-independent model checks that both produced the same results. It is not a feature-parity test, it measured no allocation, and it covers neither MessagePipe’s filters, request/response, buffered publishers, interprocess transports and Parallel async strategy nor Onity’s Observe<T>() bridge and OnityTask receives.
Feature comparison
| Area | Onity.Messaging (0.8.0) | MessagePipe 1.8.1 (Unity package) |
|---|---|---|
| Setup | Every context binds MessageBroker (IMessageBroker) and OnityEventHub; no registration per message type. BindMessageChannel<T>() injects IPublisher<T> or ISubscriber<T> directly; DeclareMessage<T>() and DeclareAsyncMessage<T>() create standalone channels | AddMessagePipe() plus AddMessageBroker<T>() per message type on a DI builder (BuiltinContainerBuilder in the package; the VContainer and Zenject integrations are not part of it), or GlobalMessagePipe over a service provider |
| Publisher and subscriber | IPublisher<T>, ISubscriber<T>; Subscribe(MessageHandler<T>) returns IDisposable | IPublisher<T>, ISubscriber<T> with singleton and scoped variants; Subscribe(IMessageHandler<T>, filters) and the Subscribe(Action<T>, ...) extension with an optional predicate |
| Keyed | KeyedMessageChannel<TKey, T> (IKeyedPublisher, IKeyedSubscriber), one inner channel per key, bound with BindInstance; synchronous only | IPublisher<TKey, T>, ISubscriber<TKey, T> and the async keyed pair |
| Async delivery | AsyncMessageChannel<T>: PublishAsync returns ValueTask, delivers sequentially, checks the token before each handler and never throws synchronously; PublishOnityTask, SubscribeOnityTask; SubscribeQueued puts a bounded queue in front of one consumer | IAsyncPublisher<T>, IAsyncSubscriber<T> over UniTask; PublishAsync with AsyncPublishStrategy.Parallel (the default) or Sequential, and a non-awaited Publish(message, ct) |
| Filters | None on the publish path; a per-consumer rule lives in the stream: Observe<T>().Where(...) | MessageHandlerFilter<T> and its async form as a pipeline per subscription, by attribute, globally through MessagePipeOptions, or as a predicate overload |
| Request and response | Not shipped; a query is a service call | IRequestHandler<TRequest, TResponse>, IRequestAllHandler and their async forms, with their own filters |
| Latest-value replay | Not shipped; current state is a ReactiveProperty<T> bound through DI | IBufferedPublisher<T> and IBufferedSubscriber<T> (sync and async), which deliver the latest message to a new subscriber; EventFactory for owner-created events |
| Streams and awaits | Observe<T>() returns the same IOnityObservable<T> as Subject<T>, so a message takes the operator chain without an adapter; ReceiveAsync, ReceiveAllAsync with an overflow policy | AsObservable() (System.IObservable<T>; operators need a reactive library), AsAsyncEnumerable() (UniTask async LINQ), FirstAsync |
| Subscription lifetime | IDisposable retained with AddTo(this), TakeUntilDisable(this), AddTo(scope) or AddTo(bag); OnityEvent.Subscribe(owner, handler) also disposes when owner is destroyed | IDisposable collected with DisposableBag; HandlingSubscribeDisposedPolicy decides whether a subscribe on a disposed broker is ignored (the default) or throws |
| Threading | Main thread only; a channel takes no lock, and the broker locks its channel table (creation, count, diagnostics, dispose), never the publish path | Thread-safe brokers: subscribe and dispose take a lock, and so does every keyed publish |
| Error handling | A throwing handler propagates out of Publish and the remaining handlers for that message do not run; PublishAsync faults its task | The broker loop has no exception handling either (MessageBrokerCore<T>.Publish), so a throwing handler stops that publish the same way |
| Diagnostics | MessageBroker.GetDiagnostics (channels and subscriber counts), shown in Onity/Tools/Monitor | MessagePipeDiagnosticsInfo (subscribe count, optional stack-trace capture) and the Window/MessagePipe Diagnostics Editor window |
| Interprocess and distributed | Not shipped | IDistributedPublisher and IDistributedSubscriber with an in-memory implementation; the Redis and interprocess transports are separate packages |
| Runtime dependencies | none beyond Unity (Onity.Messaging is engine-free) | UniTask (the package’s assembly references UniTask and UniTask.Linq); engine-free |
| Maintenance | this repository | Cysharp; 1.8.1 is the measured release |
Onity ships a smaller surface by design: no filters, no request/response, no buffered or replayed messages and no interprocess transport. Migrating from MessagePipe maps each of these to the Onity shape that replaces it.
Measured results for 0.8.0
IL2CPP is the headline backend. A ratio is Onity’s time divided by MessagePipe’s time for the same workload in the same process; below 1 means Onity took less time. A row is faster when the median ratio over the processes is at most 0.95 and the worst process is at most 1.00, slower when the median is at least 1.05, and on par otherwise; the rule was fixed before any measurement.
MessagePipe comes in two builds with the same synchronous source and a different async API: the Unity package, which Unity users install, compiles its async publish as an async UniTask method, and the NuGet .NET build as an async ValueTask method. The claim stands on the Unity package. In the run that measured the shipped 0.8.0 runtime against it (source 52e209a, which is the runtime commit 4cc5763 plus the harness’s Unity-package flavor; MessagePipe 1.8.1 as MessagePipe.1.8.1.unitypackage compiled against UniTask 2.5.10; Unity 2022.3.62f2, Windows x64, IL2CPP Release Players, three processes), Onity.Messaging was faster than MessagePipe in all nine rows, with medians from 0.150 to 0.846 and no process above 0.895:
| Scenario | Onity ns/op | MessagePipe ns/op | Onity/MessagePipe median (worst) | Class |
|---|---|---|---|---|
| PublishNoSubscribers | 2.25 | 5.32 | 0.416 (0.423) | faster |
| Publish1 | 6.21 | 9.16 | 0.726 (0.767) | faster |
| Publish8 | 20.97 | 39.40 | 0.526 (0.532) | faster |
| SubscribeDispose | 63.49 | 377.52 | 0.168 (0.193) | faster |
| SubscribeDispose64 | 60.13 | 417.30 | 0.150 (0.216) | faster |
| KeyedPublish | 28.35 | 67.83 | 0.418 (0.436) | faster |
| KeyedSubscribeDispose | 104.37 | 552.26 | 0.172 (0.189) | faster |
| AsyncPublish1 | 27.32 | 43.86 | 0.623 (0.740) | faster |
| AsyncPublish8 | 103.19 | 126.20 | 0.846 (0.895) | faster |
Mono is reported, not gated. The same Players measured Onity faster in all nine rows, with medians from 0.280 to 0.756 and no process above 0.862:
| Scenario | Onity ns/op | MessagePipe ns/op | Onity/MessagePipe median (worst) | Class |
|---|---|---|---|---|
| PublishNoSubscribers | 5.72 | 7.95 | 0.706 (0.720) | faster |
| Publish1 | 8.09 | 14.37 | 0.563 (0.682) | faster |
| Publish8 | 25.43 | 33.92 | 0.756 (0.786) | faster |
| SubscribeDispose | 80.04 | 294.98 | 0.297 (0.334) | faster |
| SubscribeDispose64 | 82.89 | 296.37 | 0.280 (0.287) | faster |
| KeyedPublish | 26.04 | 44.47 | 0.539 (0.586) | faster |
| KeyedSubscribeDispose | 98.78 | 338.57 | 0.292 (0.304) | faster |
| AsyncPublish1 | 59.80 | 117.66 | 0.514 (0.548) | faster |
| AsyncPublish8 | 255.98 | 366.75 | 0.665 (0.862) | faster |
The per-process ratios behind every median, the process medians and the gen-0 collection counts are in the evidence record.
The .NET build
The NuGet build (lib/netstandard2.0/MessagePipe.dll of messagepipe.1.8.1.nupkg) was measured first, with the same workloads and the same rule. Against it the same runtime (4cc5763, IL2CPP, three processes) was faster in all nine rows, with medians from 0.197 to 0.807 and a worst process of 0.974 (one Publish1 process); on Mono it was faster in all nine rows, medians 0.280 to 0.762, worst process 0.830:
| Scenario | Onity ns/op | MessagePipe ns/op | IL2CPP median (worst) | Class | Mono median (worst) | Class |
|---|---|---|---|---|---|---|
| PublishNoSubscribers | 2.80 | 7.16 | 0.459 (0.493) | faster | 0.709 (0.718) | faster |
| Publish1 | 9.22 | 9.47 | 0.807 (0.974) | faster | 0.570 (0.707) | faster |
| Publish8 | 17.98 | 35.14 | 0.513 (0.554) | faster | 0.762 (0.830) | faster |
| SubscribeDispose | 99.34 | 484.50 | 0.197 (0.205) | faster | 0.319 (0.334) | faster |
| SubscribeDispose64 | 114.17 | 495.46 | 0.218 (0.230) | faster | 0.280 (0.314) | faster |
| KeyedPublish | 26.43 | 59.43 | 0.427 (0.482) | faster | 0.548 (0.563) | faster |
| KeyedSubscribeDispose | 118.67 | 529.00 | 0.224 (0.243) | faster | 0.287 (0.341) | faster |
| AsyncPublish1 | 24.95 | 78.49 | 0.313 (0.334) | faster | 0.287 (0.298) | faster |
| AsyncPublish8 | 95.31 | 167.42 | 0.569 (0.604) | faster | 0.522 (0.543) | faster |
The ns/op columns are the IL2CPP values. The two builds share the synchronous source and differ in the async API, and the async rows show it: on IL2CPP MessagePipe’s AsyncPublish1 measured 43.86 ns in the Unity package against 78.49 ns in the .NET build, and AsyncPublish8 126.20 against 167.42 ns. The likely reason is a code fact consistent with these numbers, not one the measurement isolates: UniTask’s method builder runs the state machine without an ExecutionContext scope, which the async ValueTask builder pays on every publish. The Unity package is therefore the harder target in the async rows and the one the claim stands on; the .NET build is reported because it was the first gate.
What changed in 0.8.0
The results above come from a redesign of the channel subscriptions and of the async publish pass, with no change to the public API:
- A subscription is one object: the entry the channel keeps and the
IDisposablereturned to the subscriber, which knows its slot. Disposing it runs at most once and removes its slot in constant time: outside a publish pass the last subscriber moves into the freed slot; during a pass the slot is cleared and the slots are compacted in order after the outermost pass, after the async channel has copied its handler array once so the passes in flight keep the array they hold. The 0.7.0 runtime allocated an unsubscribe closure, its delegate and aDisposableActionper subscription and searched for the entry on dispose. - In
MessageChannel<T>, delivery order, delivery to a handler added during a pass, skipping of a handler removed during a pass, nested publish and exception propagation are unchanged; the async channel still delivers each pass to the handlers registered when it started.PublishandPublishAsyncreturn at once when the channel has no subscriber. AsyncMessageChannel<T>.PublishAsyncis no longer anasyncmethod. Handlers whoseValueTaskcompletes synchronously run inline; the first pending one hands the rest of the pass to an awaiting continuation. Failures complete the returnedValueTaskexactly as anasyncmethod would: canceled forOperationCanceledException, faulted otherwise, never thrown synchronously.- The async channel copies its handler array only when a subscription is disposed while a pass holds it, instead of a snapshot copy on every publish; every pass still delivers to the handlers registered when it started. In-flight passes are counted, where a bool flag used to be cleared early by a nested publish.
- On IL2CPP, null checks and array-bounds checks are off on the hot messaging members, through
Onity.Messaging’s own internal copies of theUnity.IL2CPP.CompilerServicesattributes. - Measured effect beyond the ratios: gen-0 collections during the three subscribe-and-dispose rows fell from 426 to 430 per row in the baseline to 90 to 93 in the Unity-package run and 92 to 93 in the NuGet run (IL2CPP, sum over three processes), while MessagePipe’s stayed at 153 to 162.
One behavior changed, and it is listed under Changed in the changelog. Because PublishAsync is no longer an async method, a handler that is not itself an async method runs its synchronous work in the caller’s context, whether it returns a completed ValueTask or is the first to return a pending one: an AsyncLocal<T> value or a SynchronizationContext it sets stays visible to the caller after PublishAsync returns, as with the synchronous Publish, OnityTask at its default (FlowExecutionContext off) and MessagePipe’s Unity build for a handler of that shape. An async handler keeps its own scope, as in 0.7.0: its builder restores the context, so the caller does not see its change. Handlers after the first pending one run inside the continuation, and the caller does not see their changes either.
The measurements ran as two pre-registered gates with the same rule. Gate A, against the .NET build: the released 0.7.0 runtime, measured first as the baseline, was faster in seven IL2CPP rows and slower in AsyncPublish1 (1.138) and AsyncPublish8 (1.301); the first attempt at the new core (4cc5763) passed in all nine rows on both backends. Gate B, against the Unity package: its first attempt measured that runtime unchanged (52e209a adds only the harness flavor) and passed in all nine rows on both backends; it is the 0.8.0 result above.
Conditions
- Unity 2022.3.62f2, Windows x64, non-development Release Players, AMD Ryzen 9 5900X; three processes per backend, 2 warmup and 15 measured samples per library and scenario, library order alternating per sample; each process at High priority with an affinity mask that excludes cores 0 and 1.
- MessagePipe 1.8.1 as the Unity package for the headline (
MessagePipe.1.8.1.unitypackagefrom the official Cysharp release, SHA-25636ff7aa0611272e22c0c596616f2b01dfb42507a206194d9da2a11d8976544b0, its 54 entries verified byte for byte in the host, compiled by Unity against UniTask 2.5.10) and as the NuGetnetstandard2.0dll for the first gate (SHA-256cf8a702ab31bbb7da9ea6de4349f6dec9e214be396234f7e527d6ebe06a94f1b). Both unmodified. - The MessagePipe brokers are built from the package’s public types with a default
MessagePipeOptionsand no filters, the wayAddMessagePiperegisters them, without a DI container. The async rows passAsyncPublishStrategy.Sequentialexplicitly, because Onity’s async channel is sequential and MessagePipe defaults toParallel. Every other library default was left alone. - The message is a
readonly structwith oneint; keys areint. Handlers are cached delegates created outside timing through each library’s documented delegate overload; async handlers return an already completed task of their library’s type. - The harness (
Packages/com.onity.framework/Benchmarks/Messaging/**and the two host scripts) was frozen before the first measurement, and the classification rule was pre-registered. The only later change, before any gate-B run, added the Unity-package flavor: the async handler’s task type and the check that it completed, a provenance check against the.unitypackage, and the flavor fields in the reports. - A library-independent model checked every sample’s checksum, count and probe totals in every process, and the checksums of the two libraries were identical.
Fairness notes
- The user code is the same shape for both libraries: the same loops, the same message, the same handler bodies, one cached delegate per handler kind and workload. The two workload files differ in their
usingline, the delegate types and the broker construction. - Each library wraps the handler its own way, and the wrapper is part of what is measured. Onity stores the
MessageHandler<T>in its channel’s slot array and invokes it directly; a subscription allocates one object, the subscription itself. MessagePipe wraps theAction<T>in anAnonymousMessageHandler<T>and calls it throughIMessageHandler<T>.Handle; a subscription allocates that wrapper and aSubscription, and runs the handler factory (global and attribute filter lookups, which find nothing here). - MessagePipe’s brokers take locks where Onity’s channels do not: on subscribe and dispose, and on every keyed publish. Onity is main-thread only. That is a design difference, not a benchmark artifact, and nothing was disabled in either library.
- The async rows measure the publish pass with handlers that complete synchronously; no handler is awaited. Onity runs that pass inline; MessagePipe runs its
async UniTask(Unity package) orasync ValueTask(.NET build) method, the likely reason the two builds measure differently in those rows. - The whole measurement runs synchronously inside one
AfterSceneLoadcallback, so no frame or PlayerLoop work interleaves with it. In the Unity-package run both Players also contain UniTask, whose PlayerLoop runners never execute inside the measurement. - Samples are short, so the per-process ratios, not one number, carry a small difference.
Caveats
- Timing on one Windows PC. Other machines, platforms, Unity versions and Development Players are not covered.
- Allocation was not measured on either backend. The counter is never called on IL2CPP, and on Mono its positive control read 0 bytes, so no allocation claim follows from this comparison; the evidence record lists gen-0 collection counts instead.
- Other Unity Editors and batch builds of other projects ran during the runs. The noise screen flagged every IL2CPP process of all three runs and every Mono process but one. Both libraries run in every process with alternating order, so the ratios carry the comparison, and the rule reads the worst process.
- The nine rows do not measure MessagePipe’s filters, request/response, buffered publishers, interprocess transports or
Parallelstrategy, nor Onity’sObserve<T>()bridge,OnityEventor theOnityTaskreceives. UniTask 2.5.10 was the local copy; 2.5.11 was not measured. - The async rows time handlers that complete synchronously. A pass that awaits a pending handler, and the continuation that finishes it, were not timed.
The 0.7.0 baseline
The released 0.7.0 runtime was measured first against the .NET build with the same harness (harness commit 441c441, three processes per backend). On IL2CPP it was faster in seven rows, from Publish8 (0.424) to SubscribeDispose64 (0.865, worst process 0.964), and slower in AsyncPublish1 (1.138) and AsyncPublish8 (1.301), so the gate failed:
| Scenario | Onity ns/op | MessagePipe ns/op | Onity/MessagePipe median (worst) | Class |
|---|---|---|---|---|
| PublishNoSubscribers | 3.98 | 6.06 | 0.656 (0.662) | faster |
| Publish1 | 5.59 | 10.37 | 0.539 (0.573) | faster |
| Publish8 | 16.73 | 39.93 | 0.424 (0.436) | faster |
| SubscribeDispose | 314.05 | 416.02 | 0.748 (0.839) | faster |
| SubscribeDispose64 | 356.03 | 411.58 | 0.865 (0.964) | faster |
| KeyedPublish | 24.11 | 54.56 | 0.440 (0.442) | faster |
| KeyedSubscribeDispose | 336.88 | 483.35 | 0.697 (0.710) | faster |
| AsyncPublish1 | 85.37 | 74.71 | 1.138 (1.151) | slower |
| AsyncPublish8 | 210.44 | 163.26 | 1.301 (1.305) | slower |
On Mono the 0.7.0 runtime was faster in five rows, on par in SubscribeDispose (0.959) and slower in SubscribeDispose64 (1.169), AsyncPublish1 (1.151) and AsyncPublish8 (1.248). Its subscribe and dispose rows ran 426 to 430 gen-0 collections per row against MessagePipe’s 135 to 179. The full baseline tables are in the evidence record; the redesign above is what changed between the baseline and the 0.8.0 results.