Async deinit: Semantics

In this post I'll try to analyse what are the options for the semantics of the async deinit, without touching implementation details and optimizations.

1. Create new task and execute concurrently

As demonstrated by the isolated sync deinit, this is relatively simple to implement.

class A {
    var foo: Foo
    isolated deinit {
        // Important! Must capture copy of foo, not self
        Task { [foo] in
            await foo.shutdown()
        }
    }
}

class B {
    var foo: Foo
    deinit async {
        await foo.shutdown()
    }
}

Compared to isolated synchronous deinit, async deinit removes few lines of boilerplate code,
but more importantly it eliminates the possibility of capturing of self in hand-written task spawning in isolated deinit.

This approach nicely unifies releases from sync and async code, and does not require major changes in the type system or reference counting runtime. But it does not allow executing cleanup actions sequentially.

2. Sequentially execute deinit in the current task

This is not possible with synchronous isolated deinit, so implementing async deinit in this manner would extend functionality available to developers. But there are a lot of challenges in producing a viable design for this approach.

To allow async deinit to be executed sequentially, release function itself needs to become async.
A new runtime function would be needed for that.

SWIFT_RUNTIME_EXPORT
void swift_release_async(HeapObject *object, AsyncContext *);

As an optimization compiler can use regular swift_release for cases, which are known to not use async deinit, but for cases where it cannot be proven, compiler will have to use swift_release_async. This comes with performance cost. To minimize this cost, we would need
to have a mechanism for providing compiler with proofs that certain objects are guaranteed to have synchronous deinit. See 2.2.1 for more details.

Note that release often happens from setters or even completely compiler-generated functions, like assigners and destroyers in VWT. Introducing sequential async deinit would require to be able to have async versions of those entities.

2.1. Explicit vs implicit awaiting

With sequential execution, every release becomes a suspension point. This similar to a challenge faced with async let. Proposal for async-let gave up on attempting to make them explicit, and I'm afraid this case is no different.

Explicit awaiting could be achieved if Swift gets support for linear types in the future:

@noImplicitCopy
@noImplicitDeinit
class C {
    var foo: Foo

    deinit async {
        await foo.shutdown()
    }
}

func foo() async {
    let x = C()
    await drop(x) // Must be explicit and in async context
}

Until we get linear types, poor-man's solution for explicitly awaiting for cleanup in the current task would be manual reference counting with dynamic checks:

class D {
    var foo: Foo
    var rc: Int = 1 // TODO: Atomic

    func retain() {
        rc += 1
    }

    func release() async {
        rc -= 1
        if rc == 0 {
            await shutdown()
        }
    }

    deinit {
        assert(rc == 0)
    }
}

func foo() async {
    let x = D()
    await x.release()
}

Note that in poor-man's solution cleanup code resides in the regular method and deinit is a no-op. If compiler gets support for linear types, and can enforce that cleanup method is called explicitly, then functionality of the async deinit can be implemented in a regular method. Async deinit won't be needed.

2.2. Integration with sync code

We can either forbid releasing objects with async deinit from sync code, or we can async deinit non-sequentially if last release happened from the sync code.

2.2.1. Forbid releasing objects with async deinit from sync code

For that we would need to know about async deinit in the type system.

Async code can handle both sync deinit and async deinit, while sync code can handle only sync deinit. So looks like that types with async deinit could be supertypes for corresponding types without async deinit.

Note that this applies not only to the classes declared with async deinit, but also to any entity which may contain reference to such class - other classes, structs, enums, tuples, closures and existential containers. Including Any and AnyObject.

Ideally this subtyping should be modelled by types being maybe-async-destructible by default, and adding guarantees about sync destruction with a protocol. But that would be a huge breaking change, which is unlikely to be accepted. So we would have to go for an ugly negative-requirement scheme, where types require sync destruction by default, and lift this restriction using extra syntax, an attribute for example:

var x: @maybeAsyncDeinit P = X()
var y: P = Y()
x = y // OK
y = x // ERROR

@maybeAsyncDeinit
struct Array<@maybeAsyncDeinit Element> {
    ...
}

Technically it seems to be feasible, but usability of it would be poor. Classes with async deinit make only a fraction of classes with an explicit deinit, who are already a minority on their own. So it would be easy ignore them when writing generic signature or function signature, and to forget to add @maybeAsyncDeinit to lift restrictions. So there would be a lot of code which is unintentionally unusable with classes with async deinit.

2.2.2. Run async deinit non-sequentially if last release happened from sync code

I think this would make code very hard to reason about. Sync code can be called from async code. And subtle code changes may silently change semantics of deinit:

func foo1() async {
    // We are inside async function: perform deinit sequentially
    foo.bar = nil
}

func foo2() async {
    helper {
        // We are now inside sync function: perform deinit concurrently
        foo.bar = nil
    }
}

This undermines the whole idea of sequential execution of async deinit.

Conclusion

Concurrently executed async deinit is ready to implemented.

If there is a demand for sequentially executed async cleanup, linear types might be an answer. Such types would have no deinit, at all, but rather implement cleanup in one or more async consuming methods. With compiler checking that at least one of them is called explicitly on all code paths.

3 Likes