-
Notifications
You must be signed in to change notification settings - Fork 26
Capability Lifecycle Management
There is a major difference between C++ and C#: Whereas C++ takes you in responsibility of explicitly managing the memory for your objects, C# comes with a garbage collector (GC). A GC is a great thing because you usually don't need to worry about freeing allocated memory. But it is not a universal solution. Some resources need explicit management. So do Cap'n Proto capabilities. Once you obtained a capability from some other party, you should Dispose it when you no longer need it. As soon as you Dispose it, the Capnp.Net.Runtime
will send a 'Release
' message to the other side, indicating, that the capability is no longer needed. "Why not just add a Finalizer to the internal Proxy implementation and send the message once the GC collects it?", you might wonder. Well, actually there already is a Finalizer implementation doing exactly this. But this mechanism is just a last line of defense. You should explicitly Dispose any capability. Really. Maybe you've heard GC acts non-deterministically, so you cannot know when the Finalizer gets called. Why does it matter? Because non-deterministically can also mean "never for (nearly) the entire lifetime of your application". This might be the case if memory pressure is quite low, and GC decides that it is just not worth the effort of freeing a few bytes occupied by the Proxy object. Of course, the GC cannot know that there is much more behind the scenes: Image the other side is a small embedded device, with the capability implementation consuming lots of resources, awaiting urgently your release. Here we have the final answer: The GC just cannot make a qualified decision when it's time to release a capability because it will only ever consider memory usage of the local process.
The code generator adds an IDisposable
interface to each capability interface it produces. It is your responsibility to Dispose any capability you receive from the runtime (either by RPC request or answer).
While C# is a great language in so many aspects, it lets you stand in the rain when you require non-trivial explicit resource management. While the C++ ecosystem provides sophisticated smart pointers, all grouping around RAII, C#'s answer is IDisposable
. Considered sober, IDisposable
is a reinforced delete
operator. Especially, IDisposable
does not enforce any model of ownership. After passing an IDisposable
from A to B it remains unclear whether A still claims ownership or gave it up. Rectifying this, we need to specify a "mental model of ownership". It is documented here, but accept that there is nearly no language support for enforcing this model. Be prepared that debugging capability lifetime issues will make you feel like hell.
All capabilities are internally reference-counted. This applies to both importing and exporting party. When an imported capability's reference count reaches zero, a RELEASE
message is sent to the other side, indicating that the capability is no longer needed. When the exported capability's reference count also reaches zero, the Capnp runtime calls its Dispose()
method. The reference counts are digged deeply into low-level objects which you usually won't get in touch with. Every Proxy
object is designed to hold exactly one reference of its underlying low-level capability object. When the proxy is Dispose()
'd it gives up its ownership. Even if the underlying capability is still alive, the proxy object is burnt after disposal. Any attempt to make a call on it will result in an ObjectDisposedException
.
Two more lines of defense shall ensure that capabilities are properly disposed:
- When the
RpcEngine
shuts down (either because you requested it, or because of a problem like protocol error, connection loss), it releases all exported capabilities. -
Proxy
implements a finalizer which releases the underlying capability.
When you pass a capability to some other method, the usual contract is move semantics. In other words: The caller transfers ownership to the callee. This applies to both other capabilities' methods and the Capnp.Net.Runtime API.
That being said it's time to phrase some best practices.
It is highly recommended to guard any capability with a using
statement.
interface Cap1
{
doSomething @0 () -> (r: Int32);
}
interface Cap2
{
doSomethingElse @0 () -> (r: Int32);
}
interface Frobnicator
{
frobnicate @0 (cap1: Cap1, cap2: Cap2);
}
class Frobnicator : IFrobnicator
{
public async Task Frobnicate(ICap1 cap1, ICap2 cap2, CancellationToken cancellationToken_ = default)
{
using (cap1)
using (cap2)
{
int x = await cap1.DoSomething(cancellationToken_);
int y = await cap2.DoSomethingElse(cancellationToken_);
if (x != y) throw new InvalidOperationException("frobnication failed");
}
}
public void Dispose()
{
// Safe to leave that empty here.
}
}
Let's modify the previous example:
struct Box
{
cap1 @0: Cap1;
cap2 @1: Cap2;
}
interface Frobnicator2
{
frobnicate @0 (box: Box);
}
Now, the two capabilities are wrapped in Box
. Don't forget using
them. The framework assumes that you took ownership of every capability, regardless of how deeply nested it is.
public Task Frobnicate(Box box, CancellationToken cancellationToken_ = default)
{
using (box.Cap1)
using (box.Cap2)
{
// do something
}
return Task.CompletedTask;
}
If you ever need to store a capability for later use, do it as early as possible and take care of potential corner-cases. The following example assumes that cap2
needs to be stored while cap1
is just used during the method call.
class Frobnicator : IFrobnicator
{
readonly object _lock = new object();
ICap2 _cap2;
public async Task Frobnicate(ICap1 cap1, ICap2 cap2, CancellationToken cancellationToken_ = default)
{
lock (_lock)
{
if (cap2 != _cap2)
{
_cap2?.Dispose();
_cap2 = cap2;
}
}
using (cap1)
{
// do stuff
}
}
public void Dispose()
{
_cap2?.Dispose();
}
}
The given example covers the following aspects:
- Maybe, the new capability replaces an existing one. Thus, we need to release the previous instance.
- Multithreading
-
cap2
might be the same object instance. Note that this might happen only in a corner-case:cap2
is not aProxy
, but the actual (local) object. You may neglect this case if you never consumeFrobnicator
instances directly.
With this additional interface:
interface Helper
{
takeCap @0 (cap1: Cap1);
}
The following snippet does it the wrong way:
class Frobnicator : IFrobnicator
{
IHelper _helper;
public async Task Frobnicate(ICap1 cap1, ICap2 cap2, CancellationToken cancellationToken_ = default)
{
using (cap1)
{
await _helper.TakeCap(cap1);
await cap1.DoSomething(); // Error: cap1 was moved by line above
}
}
}
Instead, create a copy with Proxy.Share<T>(T obj)
:
class Frobnicator : IFrobnicator
{
IHelper _helper;
public async Task Frobnicate(ICap1 cap1, ICap2 cap2, CancellationToken cancellationToken_ = default)
{
using (cap1)
{
await _helper.TakeCap(Proxy.Share(cap1));
await cap1.DoSomething(); // OK, cap1 still valid
}
}
}