Deinit and MainActor

I've annotated some of my classes that should be run only on the UI thread with @MainActor. However, I'm running into a compiler error when I try to clean up resources in deinit.

Here's a stripped-down example of the error, running in Xcode 13.0 beta 2 (13A5155e):

Is it possible to clean up resources in the deinit of a @MainActor-annotated class?

I did notice that if I inline the cleanup, then it compiles, e.g.:

  deinit {
    self.inputStream?.close()
    self.outputStream?.close()
}

But it's definitely cleaner to have one named closeAll method that I can use anywhere in the class, including deinit.

1 Like

In a class annotated with a global actor, deinit isn’t isolated to an actor. It can’t be because the last reference to the actor could go out of scope on any thread/task. This is noted in the Global Actors proposal.

It’s possible to use async/Task.detached to switch to the main actor, but this is unsafe.

I'm wondering whether the code invoking deinit could do something like this (imaginary pseudocode):

guard refCnt > 0 else {
  if isSync {
    deinit()
    destroy()
    return
  } else {
    Task.detached {
      await deinit()
      destroy()
    }
    return
  }
}

Keep in mind that deinit can't extend the lifetime of self; the object will be deallocated once the deinit completes. So if you need to trigger async work to run on a different actor from a deinit, you'll need to copy that data out of the object to pass to the new task.

3 Likes

I decided to abandon the idea of doing last-resort cleanup in deinit. Instead, I'll just count on clients of my class close any resources they don't need.

I thought I could then use deinit to assert that this cleanup had been done, but that leads to the same compiler error:

I've hit this problem when playing with actors. A possible solution would be to allow declaring deinit async. I haven't though it out exhaustively, but intuitively, here's how I think it would work:
if a type has a deinit async implemented, then when the reference count reaches zero an implicit call to something like object.deinit() is replaced by async { await object.deinit() }. I thought about the possibility of await-ing on the object if it went away in an async scope, but that would be inconsistent with the behavior from inside a sync scope. Regarding the "deterministic lifetime" principle: If a type defines deinit async, then by definition, the author realizes that there actual deinitialization happens outside of the normal control flow.

1 Like

IIRC, some UIKit classes (UIViewController at least) have some hackery that dispatches dealloc to main thread if last release happened from the background queue.

That wouldn't actually work because you can't keep an object alive in dealloc (at least with ARC). Once dealloc returns the object's memory is going to be reclaimed and possibly reused. If you tried to dispatch to another thread and then use self you would almost certainly crash.

You could potentially dispatch to another thread and do other cleanup things as long as you don't reference self and retain anything else you need to use, but it's generally not a good idea, and I don't believe UIViewController does any such thing.

The general guidance for things that require asynchronous cleanup is that they should have explicit lifetime management, i.e., an explicit stop or cleanup method that the client has to call. If the object is deallocated before that method is called then that's a programmer error and may crash (some APIs deliberately crash in this situation to make the behavior more predictable).

Relying on dealloc or deinit to to do cleanup that has to be asynchronous or has to be on a particular thread is just not a safe pattern.

1 Like

I guess the question I still can't resolve is: why does this compile when I inline the contents of closeAll into deinit, but raise an error if I abstract my cleanup calls into a method and then call it in deinit?

1 Like

Object is kept alive not in dealloc, but rather in release. UIViewController overrides release method. Overridden implementation is implemented in non-ARC code, and its logic can be described by the following preudocode:

- (void)release {
    atomic decrement retain count
    if it was the last reference {
        if it is the main thread {
            [self dealloc];
        } else {
           // No retain, no resurrection
           // self is passed as non-retained context
           dispatch_barrier_async_f(dispatch_get_main_queue(), self, _objc_deallocOnMainThreadHelper);
        }
    }
}
void _objc_deallocOnMainThreadHelper(void *context) {
    [(id)context dealloc];
}

Implementation ensures that after atomic decrement reading weak references returns nil, even if dealloc was not called yet.

As far as I can tell that is no longer the case and hasn't been for a while. Regardless, you can't do that with ARC. In Objective-C you're allowed to not use ARC if you want to hack around things, but in Swift that's not an option so there's no getting around this.

I wrote my previous message staring into assembly of the UIKitCore from iOS 14.5 Simulator. Didn’t check on device, TBH, but I think it is reasonable to expect the same behavior on device as well.

I’m not sure what you mean here. I’m trying to make a point that technically it is possible to make deinit isolated on the actor. But that would be a feature of the Swift runtime, implemented in C++ (so, no ARC), supported by the compiler feature.

I’m not suggesting that @moreindirection should try to override the release method, that is indeed impossible, I’m questioning if deinit need to be non-isolated.

1 Like

Oh, I do see some usages of that pattern in UIKit (very well hidden), but not in UIViewController. Maybe it's somehow even more buried there. Oh well.

I'm still not convinced that's comparable to what's being proposed here. The code surrounding the usage of _objc_deallocOnMainThreadHelper uses manual refcounting logic such that it knows (synchronously) whether we're actually going to deallocate the object or not before dispatching dealloc to the main queue. By the time dealloc is called on the main queue we've already committed to deallocating it, and the normal ARC semantics apply (i.e., you can't resurrect the object at that point, and you can't do any further asynchronous cleanup from within dealloc).

If you allowed dealloc (in ObjC) or deinit (in Swift) to itself be asynchronous then that would require the ability to hold that object in memory for some indeterminate amount of time as it hops potentially from one thread to another. That would require further refcounting to track when it's really safe to deallocate that object. The semantics of these languages don't allow for that kind of resurrection. I've seen that in garbage-collected languages via their finalizers, but even those run before the object is officially committed to being collected, and the finalizer can't be asynchronous (it has to commit synchronously to either resurrecting or not).

I was talking about synchronous deinit, but isolated in the actor. This is sufficient to solve the original problem of this thread.

Asynchronous deinit is a different topic, but it is also possible. Resurrection is a problem, but it is completely orthogonal to asyncronousity.

It is a problem for synchronous deinit as well. The following code compiles without errors, but crashes in runtime:

var objects: [AnyObject] = []

class Inner {
    var name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        print("Inner.deinit(): name=\(name)")
    }
}

class Test: CustomStringConvertible {
    var inner: Inner?

    init() {
        inner = Inner(name: "\(ObjectIdentifier(self))")
    }

    var description: String {
        return "Test(\(inner?.name ?? "nil"))"
    }

    deinit {
        print("Test.deinit()")
        objects.append(self)
    }
}

func foo() {
    let t = Test()
    print(t)
}

func bar() {
    foo()
    print("Now in bar()")
    for x in objects {
        print(x)
    }
    objects.removeAll()
}

bar()

Some options to solve this problem, both for sync and async deinit:

  • No nothing, crash only if resurrected object is accessed (observed behaviour)
  • Run deinit with refcount=1 and assert that it is still 1 after deinit
  • Implement escape analysis for values other than closures and catch this in compile time.

Note the for the last solution, closeAll() from the original problem would need to be marked somehow to indicate that self is non-escaping. Or vice versa - assume that self is non-escaping in methods by default, and annotate escaping methods explicitly.

1 Like

Oooh, I see. So as long as deinit starts on the right actor context and completes synchronously then it should be safe, and that is analogous to the example with UIKit that you mentioned. You're right, I don't see any obvious flaws in that approach. That said, I'm definitely not an expert on the actor proposals.

Sorry for the derail. I saw the talk about async stuff above and thought that was what was being proposed.

I still would be interested to hear from the core team on the subject of isolated deinit. After quick searching I don't see this topic being raised before (or did I miss it?). @Douglas_Gregor, @Joe_Groff, wdyt?

Would it be possible to make deinit isolated for actors and classes isolated in global actors? I guess implementation would involve adding a slot for the function getting a scheduler for the deinit to class metadata, but if wrapped into a flag new runtime still would be binary compatible with existing binaries, right?

3 Likes

As @benlings notes, the last reference to an actor can go away anywhere---in any task---so you are not guaranteed to be on the actor. You might be in synchronous code, so you cannot "hop" over to the actor. For global-actor-qualified classes, that's the end of the story: there is no way to get to the global actor without escaping self, which you aren't permitted to do.

For actor instances, you do know that you have a unique reference to the instance. For the native actors, we could call this isolated because we know that each actor instance is independently synchronized. However, this falls apart immediately when an actor has a custom executor for the same reasons that global actors don't work: the custom executor might be shared with other actors, and you cannot synchronize appropriately with them. Similar issues issues exist for initializers, which have a similar property of having a unique self (unless they escape it).

Doug

3 Likes

Would it be possible to wrap deinit into a top-level task when last release happens from synchronous code?

In asynchronous code, it may be possible to run deinit as a child task, but this would turn every release into an implicit suspension point. So, instead even for asynchronous code release should remain a synchronous operation which launches deinit in a top-level task. "Detached" task and "top-level" task are the same thing, right?

Could you elaborate on this? Where would references to self remain after deinit is executed on the executor? Is it possible to create from C++ code of the runtime a job that stores self without retaining it?

This is effectively escaping self. Remember, deinit is the teardown after we've already done the last release. It's reference count hit zero and must not increase again, and we can't block on this other task.

If the right semantics for your actor is to schedule a task to run later to complete teardown, capture the stored property values you need into that new task and let the deinit finish synchronously. But the actor instance itself has to go away.

Doug

5 Likes

Escaping from what boundary?

Calling deinit on the scheduler de-facto makes release asynchronous. And self does not escape this asynchronous release(). But as I argued above, release should not await on deinit, so deinit must be detached and de-jure release remains synchronous. IMO, escaping self from synchronous release is not a problem, as a long as it does not escape from logical asynchronous release. Do I miss something?

I don't see how reference counting is related to blocking. If we can create a job that captures self as unowned(unsafe), it should not matter if we block, await or detach. But as I argued before, it needs to be detaching. Can job capture self as unowned(unsafe) or is there something in the concurrency design that prevents this?