Surprising actor deinitialization

Hello,
I have been using Xcode 16 for a few weeks now and noticed a behavior regarding actor deinitialization that surprises me. I have read several proposals regarding this topic without finding an explanation so I would like to ask if it's an intended behavior, or maybe something I have missed.

In our app, we use a few actors that are stored as constant static properties and thus live as long as the app is not killed. Compiling with Xcode 16, we have noticed that capturing an actor as unowned in somes closure such as a Task initialization is an error as the actor might be deinitialized before the Task completes. Reading more attentively the documentation, I have realized it was unneeded to capture the actor as unowned in most cases even though the Task instance is owned by the actor (self).

That said, we noticed that capturing the actor as unowned lead to a crash in other cases, although the actor captured as unowned is still stored as a constant static property.
To exemplify this, here are two versions of similar blocks of code for a command-line tool. In the first block, the reference type is a final class.

// MARK: - Static

enum Storage {
    static let shared = SharedClass()
}

// MARK: - SharedClass

final class SharedClass {

    // MARK: Properties

    var task: Task<Void, Error>?
    var value = 1

    // MARK: Init

    deinit {
        print ("DEINIT")
    }

    // MARK: Test

    func test() {
        task = Task { [unowned self] in
            print ("Task execution")
            print (value)
        }
    }
}

// MARK: - Run

Storage.shared.test()
try await Task.sleep(for: .seconds(1))
print("Bye-Bye")

There are some warnings regarding concurrency with the complete concurrency setting on. But everything behaves as I imagine it should. Here is what this code prints.

Task execution
1
Bye-Bye

Now, let's replace the final class by an actor.

// MARK: - Static

enum Storage {
    static let shared = SharedActor()
}

// MARK: - SharedActor

actor SharedActor {

    // MARK: Properties

    var task: Task<Void, Error>?
    var value = 1

    // MARK: Init

    deinit {
        print ("DEINIT")
    }

    // MARK: Test

    func test() {
        task = Task { [unowned self] in
            print ("Task execution")
            print (value)
        }
    }
}

// MARK: - Run

await Storage.shared.test()
try await Task.sleep(for: .seconds(1))
print("Bye-Bye")

Here's what is printed in the console.

Task execution
1
DEINIT
Bye-Bye

I don't understand why the deinit block is called. From my understanding, all reference types should behave similarly here, and storing them as static constants should prevent deinitialization as long as the app (or command line-tool) live.

Those are examples with a Task, and as I pointed above, it's not necessary to capture self as unowned as stated in the documentation. But we are noticing similar behaviors with other closure initializations such as AVAudioSourceNode where the owning actor is deinitialized although it lives with the application. Even though in many cases I realized there was no strong reference cycle as I wrongly assumed, I would like to know why the deinitalization behavior I mention is happening.

If anyone could shed some light to this matter, I would appreciate!

If you set a breakpoint on your deinitialiser, you’ll see it’s called with a backtrace like this:

(lldb) bt
* thread #2, queue = 'com.apple.root.default-qos.cooperative', stop reason = breakpoint 1.1
  * frame #0: 0x0000000100002d6c MyTool`SharedActor.deinit() at main.swift:19:16
    frame #1: 0x0000000100002e38 MyTool`SharedActor.__deallocating_deinit() at main.swift:0
    frame #2: 0x00000001958b61a0 libswiftCore.dylib`_swift_release_dealloc + 56
    frame #3: 0x00000001958b6cf8 libswiftCore.dylib`bool swift::RefCounts<swift::RefCountBitsT<(swift::RefCountInlinedness)1>>::doDecrementSlow<(swift::PerformDeinit)1>(swift::RefCountBitsT<(swift::RefCountInlinedness)1>, unsigned int) + 136
    frame #4: 0x000000024d0ad514 libswift_Concurrency.dylib`(anonymous namespace)::ProcessOutOfLineJob::process(swift::Job*) + 1284
    frame #5: 0x000000024d0ab2cc libswift_Concurrency.dylib`swift::runJobInEstablishedExecutorContext(swift::Job*) + 416
    frame #6: 0x000000024d0ac470 libswift_Concurrency.dylib`swift_job_runImpl(swift::Job*, swift::ExecutorRef) + 72
    frame #7: 0x00000001004d2ac0 libdispatch.dylib`_dispatch_root_queue_drain + 404
    frame #8: 0x00000001004d36d0 libdispatch.dylib`_dispatch_worker_thread2 + 188
    frame #9: 0x0000000100743d04 libsystem_pthread.dylib`_pthread_wqthread + 228

That suggests that the release is being called from the job execution machinery. Which is a bit of a worry.

To test this further I cloned your test() call a few times:

await Storage.shared.test()
await Storage.shared.test()
await Storage.shared.test()
await Storage.shared.test()

Now it just crashes:

Fatal error: Attempted to read an unowned reference but object 0x600003220000 was already deallocated

I don’t know what’s causing this but, given that your code doesn’t use anything labelled as unsafe, I’m pretty sure that this is a bug. I encourage you to file it as such.

Please post a link to your bug, just for the record.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Following @eskimo recommandation, here's the link to the bug report.

1 Like

unowned is unsafe, isn’t it?

Naked unowned is perfectly safe: it will deterministically crash if you attempt to access the referenced object after it's been deallocated. There is an aptly-named unowned(unsafe) version for the unsafe 'trust me I'll keep the object alive' behavior.

4 Likes