Isolated synchronous deinit

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 Concurrency — The Swift Programming Language (Swift 5.7)

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)

Is this more strict than it has to be? (I haven’t reviewed the rules for initializers here, and as long as it’s all consistent, it’s certainly fine, but this is for my own edification.) Can there not be hops between executors as an object is deinitialized, with subclass C doing its thing on one executor, then hopping over to the executor for subclass B’s deinitializer, etc.?