Which thread does deinit run on, and what does isolated deinit change for actors and main-actor classes?
Predict first
Where do the three deinit bodies run?
import Foundation
func onMain() -> Bool { pthread_main_np() != 0 }
@MainActor final class Plain { deinit { print("plain deinit on main thread:", onMain()) } }
@MainActor final class Iso { isolated deinit { print("isolated deinit on main thread:", onMain()) } }
actor Counter { var v = 7; isolated deinit { print("actor deinit reads its own state, v =", v) } }
@main struct App {
static func main() async {
await Task.detached {
let a = await MainActor.run { Plain() }
let b = await MainActor.run { Iso() }
_ = (a, b)
}.value
try? await Task.sleep(nanoseconds: 200_000_000)
_ = Counter()
try? await Task.sleep(nanoseconds: 200_000_000)
}
}
- plain deinit on main thread: false / isolated deinit on main thread: true / actor deinit reads its own state, v = 7
- All three on the main thread
- plain deinit on main thread: true / isolated deinit on main thread: true
Mental model. Plain deinit is cleaning the office whenever the last person happens to leave, wherever they are. Isolated deinit says the cleaning must be done by the office’s own janitor, so someone may have to wait for them.
A plain deinit runs on the thread that released the last reference, whatever the class’s isolation. In the check a @MainActor class whose last reference is dropped inside a detached task has its deinit run off the main thread (the first line prints false), because a nonisolated deinit cannot assume the actor. That is why a main-actor view model could not touch its own main-actor state in deinit without a workaround (and Swift 6 would not let it for non-Sendable state). Isolated deinit (SE-0371, available from Swift 6.2) lets you mark it isolated deinit: the runtime then runs the body on the actor’s executor, so for the main-actor class it prints true and for an actor it can read and tear down its own state (v = 7).
Two details to remember. If the last release happens on a different executor than the actor’s, the deinit body is scheduled there, so it may run slightly later than the release and the object’s memory stays alive until then; if the release already happens on the right executor it runs inline. And the extra hop is why you should keep an isolated deinit tiny. It also needs a deployment target with the Swift 6.2 runtime, so check availability before adopting it in a library that supports older OS versions. The old workarounds still exist: a nonisolated deinit that only touches Sendable or nonisolated(unsafe) state, or a Task { @MainActor in … } that captures copied values, never self (Q143 shows why).
A plain deinit runs where the last release happens; isolated deinit runs on the actor’s executor.
Common traps
- Starting a
Task { self … }fromdeinitto reach isolated state. It captures a dying self and traps (see the first question of this section). - Assuming a
@MainActorannotation putsdeiniton the main thread. Onlyisolated deinitdoes.
They might follow up with
- Do structs have this problem? Structs have no deinit unless noncopyable, and then the same thread rule applies.
- How did people cope before? By cancelling and invalidating in a nonisolated deinit and doing UI teardown in
viewDidDisappearor on a lifecycle callback.
Related questions
- 3.8.1 What state is an object in while its deinit runs, and what happens if you let self escape from it?
- 3.3.6 Can a Task cause a retain cycle, and how do you avoid it?
- 27.1.3 A closure uses [weak self] and the owner is released while the work is running. What is the difference…
← Does deinit run when the app exits, and which objects never get one? How do noncopyable types behave in practice: deinit on a struct… →