ARC deinit time question

Greetings!

I know that release calls are not guaranteed to happen at scope/function end, but is there a guarantee that it will happen before parent function continues execution?

For example:

class A {
  deinit { print("deinit") }
}

func foo() {
  let a = A()
  print(a)
}

func bar() {
  foo()
  print("bar")
}

Is it guaranteed that deinit will be printed before bar considering inlining, reordering and other optimisations?

Thanks!

2 Likes

Yes, deinit will be printed before bar independent of optimization.

Although this isn't formally specified by the language, the optimizer should not reorder print statements in deinitializers with print statements in regular code in either direction. We can say the same about any call to a function outside of Swift that the optimizer can't reason about. The compiler provides this guarantee today, and I don't foresee any need to change it.

Important caveats:

Deinitializers are unordered relative to each other

class A {
  deinit { print("deinit A") }
}
class B {
  deinit { print("deinit B") }
}

func foo() {
  let a = A()
  let b = B()
  print("foo")
}

Both these outputs are valid:

foo
deinit B
deinit A

or

foo
deinit A
deinit B

Eagerly consumed variables

Variables can declare that they are "eagerly consumed" as an optimization. These variables may be destroyed after their last use. Deinintialization still won't be delayed past an external call, so this doesn't apply to your example.

Some types declare this as their default behavior. Notably, all the standard library collections and Strings are eagerly consumed. (This is currently spelled @_eagerMove). They are optimizable as-if their deinitialization doesn't have side effects.

In the near future, we plan to support and consuming parameter and variable declarations, which may be destroyed immediately after their last use.

1 Like

Big thanks for such detailed response!

I wonder does this applies to scopes also?

do {
  let a = A()
  print(a)
}
print("bar")

Is deinit will be before bar in that case?

Yes. I like to refer to the end of a variable's lexical scope as an "anchor point" for deinitialization.


do {
  let a = A()
  // anchor point for a.deinit()
}
print("bar")

Prints

deinit A
bar

{
  let a = A()
  print("bar")
  // anchor point for a.deinit()
}

Prints

bar
deinit A

Remember, deinitialization is generally unordered. So, when in doubt, use withExtendedLifetime. That makes your intention explicit.

  let a = A()
  defer { withExtendedLifetime(a) {} }
  print("bar")

For user-defined classes and ObjC classes, the optimizer is conservative in the presence of things like print statements and loads from weak references. You don't need withExtendedLifetime in those cases. That makes it a lot easier to migrate common code patterns to Swift without stumbling into unexpected optimizer behavior, and makes it feasible debug deinitialization.

Nonetheless, accessing shared mutable state from a deinitializer should be avoided to avoid tricky bugs.

If you're simply accessing a stored property in a deinitializer as opposed to a print statement, then you need withExtendedLifetime to force that access to happen at a specific point:

class A {
  var root: A?
  var child: A?
  deinit { child.root = nil }
}

That deinitializer is fully optimizable:

{
  let a = A(...)
  a.child = c
  let r = c.root // 'r' may or may not be nil
  print("debug")
}
1 Like

Thats interesting. What can we say about behaviour of such "fully optimizable deinitializers" when we are dealing with some code below(after) the end of the lexical scope of the variable?
For example I'll use mutation of shared Bool field, just to indicate if the deinit was executed and keep it being considered "fully optimizable".

public class SharedObject {
  var aDeinited = false
}

public class A {
  let sharedObject: SharedObject

  public init(sharedObject: SharedObject) {
    self.sharedObject = sharedObject
  }

  deinit {
    sharedObject.aDeinited = true
  }
}

Example 1:

func foo() {
  let sharedObject = SharedObject()
  do {
    let a = A(sharedObject: sharedObject)
  }
  print(sharedObject.aDeinited) // guaranteed true or undefined ?
}

Example 2:

@inlinable // assume it *will be* inlined
func bar(sharedObject: SharedObject) {
  let a = A(sharedObject: sharedObject)
}

func buz() {
  let sharedObject = SharedObject()
  bar(sharedObject: sharedObject)
  print(sharedObject.aDeinited) // guaranteed true or undefined ?
}

In both of your examples, the program behavior is unspecified (not undefined). It may print true or false, but, in practice, I'm fairly confident it will print true.

Note that a 'do' block and a function body are both lexical scopes for their local variables. They are on the same footing, and that isn't affected by inlining.

The simplest rule for the compiler is that potential deinitialization is either a synchronization point or it isn't. If releasing a reference might call a deinitializer that does something we construe as an attempt to synchronize, then we won't reorder it with synchronization points in regular code in either direction.

In practice, the optimizer tends not to lengthen the lifetime of a variable anyway, but we haven't found a need to enforce that. Complicating the rules limits future optimization and increases the likelihood of compiler bugs.

As a consequence, if you really need a deinitializer to be ordered, you can always insert effective memory barriers (any async call or call to C will do). This is a handy debugging strategy in situations where you can't find a scope for withExtendedLifetime.

Swift programmers shouldn't need to care about any of this because the central message is that class deinitialization is unordered and should not be used for shared mutable state.

The compiler's rules about ordered deinitialization exist to handle the rare and strongly discouraged practice of using class deinitializers to free system resources. Lexically scoped defer blocks were always the encouraged approach. Move-only types will solve this by providing a way to ensure that resources are freed on all paths. Move-only types will also have a well-defined programming model for deinitialization order that doesn't require any deliberate synchronization.

None of this discussion about deinitialization order applies to the much more troublesome cases of releasing a strong reference to an object in the presence of weak references and unsafe pointers. In those cases, it makes no difference whether the released object has a deinitializer or what the body of the deinitializer contains. The object simply won't be released "early" if it's keeping alive some memory that's accessed via a weak reference or unsafe pointer. It may still be released "late".

3 Likes

Thanks for such detailed explanation!

I think I can tell at least one thing why inits and deinits are tempting to use. They are one of very few ways to express order of invocation of different events at compile time. My favorite example is observing scroll events of UIKit's UIScrollView. In a simplified form the delegate provides methods to observe start of scrolling, scroll ticks and end of scrolling, but the order of invocation of those is controlled by the callsite (UIKit), not the subscription site.
Classes provide compile-time enforced invariants:

  1. No method will be invoked before init
  2. No method will be invoked after deinit
  3. init and deinit will be invoked once

These invariants are helpful to maintain an internal state consistent.
The example of scroll events could be expressed like:

class ScrollDragFlow {
  var latestOffset: CGPoint
  var totalDistance: Double = 0

  init(initialOffset: CGPoint) {
    latestOffset = initialOffset
  }

  func tick(offset: CGPoint) {
    let length = sqrt(
      pow(offset.x - latestOffset.x, 2) + pow(offset.y - latestOffset.y, 2)
    )
    totalDistance += length
    latestOffset = offset
  }

  deinit {
    print("Total drag distance: \(totalDistance)")
  }
}

Luckily there is a better approach to enforce order of a complex flow via async/await now.

1 Like