Isolated synchronous deinit

Isolated synchronous deinit

Introduction

This feature lifts restrictions on deinit of actors and GAITs imposed by SE-0327 by providing runtime support for hopping onto executors in __deallocating_deinit()'s.

Motivation

Combination of automatics reference counting and deterministic deinitialization makes deinit in Swift a powerful tool for resource management. It greatly reduces need for close()-like methods (unsubscribe(), cancel(), shutdown(), etc.) in the public API. Such methods not only clutter public API, but also introduce a state where object is already unusable but is still referencable.

Restrictions imposed by SE-0327 reduce usefullness of explicit deinits in actors and GAITs. Workarounds for these limitations may involve creation of close()-like methods, or even manual reference counting, if API should be able to serve several clients.

In cases when deinit belongs to a subclass of UIView or UIViewController which are known to call deinitializer on the main thread, developers may be tempted to silence the diagnostic by adopting @unchecked Sendable in types that are not actually sendable. This undermines concurrency checking by the compiler, and may lead to data races when using incorrectly marked types in other places.

Proposed solution

... is to allow execution of deinit and object deallocation to be the scheduled on the executor, if needed.

Let's consider examples from SE-0327:

In case of several instances with shared data isolated on common actor problem is completely eliminated:

class NonSendableAhmed { 
  var state: Int = 0
}

@MainActor
class Maria {
  let friend: NonSendableAhmed

  init() {
    self.friend = NonSendableAhmed()
  }

  init(sharingFriendOf otherMaria: Maria) {
    // While the friend is non-Sendable, this initializer and
    // and the otherMaria are isolated to the MainActor. That is,
    // they share the same executor. So, it's OK for the non-Sendable value
    // to cross between otherMaria and self.
    self.friend = otherMaria.friend
  }

  deinit {
    // Used to be a potential data race.
    // Now deinit is also isolated on the MainActor.
    // So this code is perfectly correct.
    friend.state += 1
  }
}

func example() async {
  let m1 = await Maria()
  let m2 = await Maria(sharingFriendOf: m1)
  doSomething(m1, m2)
} 

In case of escaping self, race condition is eliminated but problem of escaping self remains. This problem exists for synchronous code as well and is orthogonal to the concurrency features.

actor Clicker {
  var count: Int = 0

  func click(_ times: Int) {
    for _ in 0..<times {
      self.count += 1 
    }
  }

  deinit {
    let old = count
    let moreClicks = 10000
    
    Task { await self.click(moreClicks) } // ❌ This WILL keep `self` alive after the `deinit`!

    for _ in 0..<moreClicks {
        // No data race.
        // Actor job created by the task is either
        // not created yet or is waiting in the queue.
      self.count += 1 
    }

    assert(count == old + moreClicks) // Always works
    assert(count == old + 2 * moreClicks) // Always fails
  }
}

Detailed design

Runtime

Proposal introduces new runtime function that is used to schedule task-less block of synchronous code on the executor by wrapping it into an ad hoc task. If no switching is needed, block is executed immediately on the current thread. It does not do any reference counting and can be safely used even with references that were released for the last time but not deallocated yet.

using AdHocWorkFunction = SWIFT_CC(swift) void (void *);
  
SWIFT_EXPORT_FROM(swift_Concurrency) SWIFT_CC(swift)
void swift_task_performOnExecutor(void *context, AdHocWorkFunction *work, ExecutorRef newExecutor);
@_silgen_name("swift_task_performOnExecutor")
@usableFromInline
internal func _performOnExecutor(_ ctx: __owned AnyObject,
                               _ work: @convention(thin) (__owned AnyObject) -> Void,
                               _ executor: UnownedSerialExecutor)

If deinit is isolated, code that normally is emitted into __deallocating_init gets emitted into new entity - __isolated_deallocating_init. And __deallocating_init is emitted as a thunk that reads executor from self (for actors) or global actor (for GAITs) and calls swift_task_performOnExecutor passing self, __isolated_deallocating_init and desired executor.

Non-deallocting deinit is not affected by the changes.

Rules for computing isolation

Isolation of deinit comes with runtime and code size cost. Classes that don't perform custom actions in deinit and only need to release references don't need isolated deinit. Releasing child objects can be done from any thread. If those objects are concerned about isolation, they should adopt isolation themselves.

@MainActor
class Foo {
    let bar: Bar
    
    // No isolated deinit generated.
    // Reference to Bar can be released from any thread.
    // Class Bar is responsible for correctly isolating its own deinit.
}

actor MyActor {
    let bar: Bar
    
    // Similar
}

If there is explicit deinit, then isolation is computed following usual rules for isolation of class instance members. It takes into account isolation attributes on the parent class, deinit itself, and allows nonisolated keyword to be used to supress isolation of the parent class:

@MainActor
class Foo {
    deinit {} // Isolated on MainActor
}

@FirstActor
class Bar {
    @SecondActor
    deinit {} // Isolated on SecondActor
}

@MainActor
class Baz {
    nonisolated deinit {} // Not isolated
}

actor MyActor {
    deinit {} // Isolated on self
}

actor AnotherActor {
    nonisolated deinit {} // Not isolated
}

When inheritance is involved, classes can add isolation to the non-isolated deinit of the base class, but they cannot change (remove or change actor) existing isolation.

@FirstActor
class Base {} // deinit is not isolated

class Derived: Base {
    @SecondActor deinit { // Isolated on SecondActor
    }
}

class IsolatedBase {
    @FirstActor deinit {} // Isolated on FirstActor
}

class Derived1: IsolatedBase {
    // deinit is still isolated on FirstActor
}

class Derived2: IsolatedBase {
    nonisolated deinit {} // ERROR
}

class Derived3: IsolatedBase {
    @SecondActor deinit {} // ERROR
}

Source compatibility

Proposal makes previously invalid code valid.

Effect on ABI stability

Proposal does not change ABI of the existing language features, but introduces new runtime function.

Effect on API resilience

Isolation attributes of the deinit become part of the public API, but it matters only when inheriting from the class.

Changing deinitializer from nonisolated to isolated is allowed on final classes. But for non-final classes it may effect how deinitializers of the subclasses are generated.

The same is true for changing identity of the isolating actor.

Changing deinitializer from isolated to nonisolated does not break ABI. Any unmodified subclasses will keep calling deinit of the superclass on the original actor.

Future Directions

Managing priority of the deinitialization job

Currently job created for the isolated deinit inherits priority of the task/thread that performed last release. If there are references from different tasks/threads, value of this priority is racy. It is impossible to reason about which tasks/threads can reference the object based on local analysis, especially since object can be referenced by other objects. Ideally deinitialization should happen with a predictable priority. Such priority could be set using attributes on the deinitializer or be provided by the isolating actor.

Clearing task local values

Current implementation preserves task local values if last release happened on the desired executor, or runs deinitializer without any task local values. Ideally deinitialization should happen with a predictable state of task local values. This could be achieved by blocking task-local values even if deinitializer is immediately executed. This could implemented as adding a node that stops lookup of the task-local values into the linked list.

Asynchronous deinit

Similar approach can be used to start a detached task for executing async deinit, but this is out of scope of this proposal.

Alternatives considered

Placing hopping logic in swift_release() instead.

UIView and UIViewController implement hopping to the main thread by overriding release method. But in Swift there are no vtable/wvtable slots for releasing, and adding them would also affect a lot of code that does not need isolated deinit.

18 Likes

My first preference would be to require deinit to opt in to this rather than just inferring it from the class having a global-actor attribute.

5 Likes

I shared the feedback already in the previous thread: Deinit and MainActor - #25 by ktoso

Agreed with John that we'd want opting "in" here; There may be plenty user-defined initializers which may be fine nonisolated, and by allowing deinit async it fits the same "opt into isolation" as init async does.

Worth considering still if isolated deinit is a thing or not... but we so far don't have such spelling anywhere, and rather use async to get isolation in initializers; and methods are the opposite, always isolated and opting out with nonisolated, as such deinit feels closer to deinit.

Re future directions: Clearing task local values

I believe it'd potentially risky/bad to just inherit some random values here so we do have to clear. But I believe you'll need to create a new task to run these cleanups anyway so it can (should?!) be detached. It seems pretty bad if we can get "arbitrary" context here. I don't think the task here is a child task, so there's no barrier needed to be written into the task locals list -- and since it's an unstructured task, we can not copy the locals and that's it.

Pending question on if it's worth trying to reuse/pool tasks for those deinits if they are indeed unstructured; rather than re-creating/allocating new tasks for every deinit hm.

2 Likes

Are you concerned about performance/code size cost or developer intentions?

Any code that works fine in non-isolated deinit should be callable from any thread, so should be callable on isolating executor as well. So there are no reason why developers would need non-isolating deinit.

Note that proposal does not generate isolated deinit if there is no explicit deinit. Which in my experience is the most common case. This kinda serves as an opt-in mechanism.

And even when there is an isolated deinit, I expect that in majority of cases last release happens on isolating executor anyway. In that case isolated deinit is executed immediately without switching costs.

You are speaking about synchronous deinit, right? Current implementation does not create new task at all.

When scheduling, it creates an ad hoc job, which is not part of any task. It does not copy task-local values, so it behaves similar to detached task, but there is no task. withUnsafeCurrentTask will give nil from scheduled deinit.

When running immediately, deinit continues within current task and by default task locals are preserved.

Two cases can be brought to common denominator either by hiding task local values in the second case or by copying them in the first case. Not sure which one is better, or if this is even a problem at all. WDYT?

You gave one in your original post (emphasis mine):

Combination of automatics reference counting and deterministic deinitialization makes deinit in Swift a powerful tool for resource management.

There even exists a roadmap to make it more deterministic.

But deinitialization would become much less deterministic if a subclass can turn a nonisolated deinit into an isolated one. Code like the one below would become fragile:

open class MyClass { ... }

do {
    let instance = MyClass(...)
    ...
}
// <- Here some code that relies on a deinitialized instance

A subclass with an isolated deinit would break the above code, unless I'm mistaken.

Some people will tell that this code was fragile anyway, because who knows where instance could be retained? Well, the author of the code knows, and relies on deterministic deinitialization.

3 Likes

Once the class is marked open, you lose that guarantee.

And if the code inside the do block ever passed the reference to any external code, it also never had that guarantee.

+1 for opt-in deinit async as I previously commented in:

And in my opinion:

"Non-isolated deinit when code is not written" will mislead developers when they just want to write a debug-print to observe when actor instance actually gets deallocated.

// Assume this is implicit isolated deinit.
deinit {
    // This printing may be called slower than non-isolated deinit.
    // (Also, thread printing will get changed which might also be tricky)
    print("deinit in \(Thread.current)") 
}

Correct, but I still think that we should not throw the baby out with the bathwater. Not having formal guarantees does not mean that we can't make contracts. Even non-open but non-final classes (under the responsibility of a single team) create a risk (because people can forget contracts).

My point is that a nonisolated deinitializer can be a contract, and that the current state of the pitch makes it very fragile.

I suggest that a base class should be able to prevent subclasses from isolating its nonisolated deinitializer.

And if the code inside the do block ever passed the reference to any external code, it also never had that guarantee.

This is the answer I tried to prevent with "the author knows" ;-)

I'll never understand why we have deterministic deinitialization AND so many people arguing that one should not rely on it. :man_shrugging:

2 Likes

The simple answer is that Swift does not have deterministic deinitialization. If it did, the output of this program would be guaranteed:

class C {
  deinit { print("C.deinit") }
}

func f() {
  C()
  print("(1)")
  defer {
    print("(3)")
  }
  print("(2)")
}

f()

You’re conflating determinism with the ability to reason about deinit order before compilation. As mentioned, work is being done to make stronger guarantees on that front, but once compiled your program is 100% deterministic. This is in contrast to garbage collected language where the collector may run at any, different point for every run of the program.

1 Like

Not in the general case.

class C: Sendable {
  func foo() { print("C.foo") }
  deinit { print("C.deinit") }
}

let c = C()
async let _ = c.foo() 
print("(1)")

I'm not sure what you think that proves, as the behavior of that program is also deterministic. Your async let does nothing since foo() is isn't async. Now, if we replace that with Task { } then total execution may not be deterministic since the Task may not run before the program exits. But the allocation behavior is still 100% deterministic, as we already know deinits on global references don't run on program exit. Again, you seem to be talking about guarantees (deinits of all values always run before program exit) not simply determinism.

What is “determinism” but a guaranteed ordering of statements?

A fully conforming if very unreasonable implementation of the Swift language could never call deinit on anything ever. Where ARC inserts retains and releases is not currently part of the language specification. It has changed in significant ways across compiler releases.

I restate my point above: Swift does not have deterministic deinitialization.

No, determinism in programming is strictly observational: given the same inputs, you get the same outputs. (see Wikipedia) And like I said, I'm pretty sure the determinism of Swift's memory management was in contrast to active garbage collectors, not something that's been formally proven (but what is?). Of course, I'm not a language designer, so I'll wait for them to weigh in on this point.

3 Likes
var a = A()
var b = B()
a = nil
b = nil

guaranteed to call A deinit first, B deinit second.

do {
    let a = A()
    print("1")
    let b = B()
    print("2")
}

does not give any guarantee about the order. If I compile this app I wouldn't be much surprised the observed deinit order is different, e.g. with the next OS update, or even when I compile the app again using the same compiler on the same system.

var elems: [String: C]? = [:]
elems["a"] = A()
elems["b"] = B()
elems = nil

here deinit order is not guaranteed and can (and will) change from run to run.

Guaranteed deinit order is something developers for Apple platforms were never generally relying upon (aside some exceptional cases). Take autorelease for example - "object will be released at some later point when the pool is drained"...

IMHO it would be logical (and safer?) to have inits / deinits for actors actor-isolated (by default) with ability to opt-out for compatibility or other legacy reasons.

1 Like

That’s an interesting point. I think regardless of the choice of the default, it would be useful for isolated deinit to connect call stack of the thread task/thread that caused last release to the call stack of the isolated deinit for debugging purposes.

Can anyone advise me on what would it take to implement this? How does it work for dispatch_async?

If you need exact guarantees about when a deinit occurs, I'd say classes with shared ownership are ultimately the wrong tool for the job—they may be the least bad tool today, though. Ultimately, when we have move-only types, then since those have unique ownership, we'd be able to reason more strongly about what context their deinit executes in, since it would either happen at the end of the value's original lifetime, or if it's moved to a different owner, when that new owner consumes it. Within move-only types, we could also have a unique modifier for class types, to indicate statically that an object reference is the only one to the object, and that its release definitely executes deinit.

3 Likes

This is rather important bullet, I agree here. Making deinit isolated implicitly when I declared it is unintuitive and can lead to a trouble.

But the whole picture is weird:
As a developer I expect that deinit is performed with the same rules as other methods. So when the whole class is marked by @SomeGlobalActor, I would expect that deinit is also executed on this actor. But in this case annotating class with @SomeGlobalActor leads changes in behavior and available possibilities inside deinit body. This is also unexpected.
When working with actor, I expect that deinit is isolated by default.
There are also difficulties with class inheritance, where:

class A {
  deinit { // non isolated
  }
}

class B: A {
  isolated deinit { // isolated explicitly
  }
}

class C: B {
  deinit { // isolated or not here?
  }
}

Overall rules are not simple and easy to remember.
We already have lots of unintuitive rules in Swift Concurency, which are not properly described in The Swift Programming Language: Redirect

So I would prefer to leave deinit non isolated by default, whether it's declared or not. But if deinit is declared, it can be explicitly annotated as isolated. If so, as a developer:

  • I already know that actor init is nonislated. So no surprise that deinit is also nonisloated by default. But, if need, I can mark it as isolated (with the cost of binary size), and as it explicitly annotated I also expect behavior changes.
  • In classes it is also non isolated by default. So the rules here as in actor. The difference is that deinit in a class can be isolated only if the class itself or its deinit is annotated by @SomeGlobalActor

This is true for regular functions. Why would it be unexpected for deinit?

Check.

The code with classes needs some global actor annotations to get isolation. So, let me modify your example a bit:

class A {
  deinit { // non isolated - because class is not marked as isolated on the global actor
  }
}

class B: A {
  @SomeGlobalActor  deinit { // isolated explicitly on global actor
  }
}

class C: B {
  deinit { // Still isolated, because base class already introduced isolation
  }
}

When you have an inheritance hierarchy, zero or more base classes can have nonisolated deinit, but once a subclass introduces isolation to the hierarchy, all more derived subclasses from that point will have this isolation and cannot remove or change it.

Why do you even care if implicit deinit is isolated or not? There is no code there, you cannot experience any limitations on accessing properties or calling other functions, you cannot put a breakpoint there. Rule about implicit deinit just allows falling tree to make no sound if there is no-one to hear it.

actor MyActor {
    let myClass: MyClass

    // deinit {}
}

class MyClass {
    let deinitHandler: (Thread) -> Void

    deinit {
        deinitHandler(Thread.current)
    }
}

While we can't hear the deinit of MyActor without explicitly writing, you can still hear MyClass.

If MyClass's deinit executor is fructuating depending on MyActor.deinit exists or not, that may become troublesome.

(Assume MyActor is just MainActor but deinitHandler may be called on non-main thread)