Should deinit be called after explicit consume of reference type?

I want to end the lifetime of an actor/class explicitly by using _ = consume object to avoid introducing another level of nesting with a separate scope. However, the deinit of that object is not called after the explicit consume. Instead, it is called at the end of the scope. Is this intended behavior or a compiler bug? With noncopyable structs it works as expected.

class Object {
    deinit { print("deinit object") }
}

struct Noncopyable: ~Copyable {
    deinit { print("deinit noncopyable") }
}

func testDeinitAfterConsume() {
    do {
        let object = Object()
        print("before consume")
        _ = consume object
        print("after consume")
    }
    
    print()
    
    do {
        let noncopyable = Noncopyable()
        print("before consume")
        _ = consume noncopyable
        print("after consume")
    }
}

This prints:

before consume
after consume
deinit object

before consume
deinit noncopyable
after consume
1 Like

Interestingly, running the same function in -O results in the “deinit object” message being printed first, before “before consume”.

If the object binding is a var then “deinit object” is printed where we expect it, both in debug and release.

Seems like a bug.

1 Like

I believe the variability in this behavior is expected, as attempts to normalize it lead to broken code, especially around weak references.

That's generally true, but when you explicitly use consume then we ought to be able to end the consumed value's lifetime where you told us to.

2 Likes

The consume operator ends the variable's lifetime. The following behavior is incorrect and reflects the fact that the operator is not fully implemented.

before consume
after consume
deinit object

I filed The 'consume' operator does not end the lifetime of copyable types · Issue #68128 · apple/swift · GitHub

Note that the lifetime of a copyable value is optimizable. This means that hoisting the deinit is valid as follows:

deinit object
before consume
after consume

We don't want the consume operator to imply a strict lifetimes. If it did, then it would need to have special semantics that differ from other non-explicit consumes. That would be undesirable, because an explicit consume is meant to optimize ownership, not block lifetime optimization. It would be unexpected in many cases:

func foo(_: consuming Object) {}

// this call can be optimized
foo(object) {}

// this call would not be optimized with strict lifetimes
foo(consume object) {}

@Joe_Groff Do we want an empty assignment _ = consume to imply withFixedLifetime in addition to the regular consume operator semantics`? That also seems unexpected and would need an amendment to SE-0390 Noncopyable structs and enums.

1 Like

No, I don't think we want _ = consume to fix the lifetime exactly to the point of the consume. It should still be OK to shorten the lifetime futher.

1 Like

I imagine the common use for an explicit consume-to-death is to ensure either the memory itself is freed or associated state is finalised (e.g. a file is flushed and closed). In which case doing it earlier would be harmless (as long as natural ordering dependencies are still observed, of course, such as use of the value).

However, it does feel odd to me that shared mutable state is apparently not respected by the optimiser (e.g. the prints in the example). Is it unreasonable that the author would expect things to happen in the order they're written in (for strictly serial, imperative code like this)?

What workaround does an author have for enforcing a specific ordering of prints (or mutations more generally)?

That's understandable. In general, class deinitializers run whenever the object loses its last reference without regard to variable scope. Relying on shared mutable state in class deinitialization is somewhat discouraged, and relying on order relative to side effects in regular code is strongly discouraged. It's generally tricky and bug prone even without optimization spoiling your day.

Explicit control over the object lifetime is the official way to rely on class deinitialization order in Swift:

    withExtendedLifetime(object) {
       // Modify shared mutable state without accessing object.
    }

For local variables, including parameters, the compiler (post 5.7) adheres to some conservative lifetime rules so that most "normal-looking" programming patterns just work without explicit lifetime management. In fact, if we take the example literally as written, deinit hoisting will not happen, and we'll never see this output:

deinit object
before consume
after consume

The extra safety rules are:

  1. If an unsafe pointer or weak reference may depend on a the lifetime of a local variable, then the compiler automatically extends any references held by that variable.

  2. If a the regular code calls outside of Swift (including all I/O) or synchronizes across tasks (calls an async function), then class deinitializers will not be reordered across those boundaries. This also implies that programmers can control object lifetimes without withExtendedLifetime just by adding synchronization code. In this example, calling 'print' counts as as a synchronization point, preventing optimization.

This is described in a bit more detail here:

consuming parameters are different than 'let', 'var' and non-'consuming' parameters in this respect because their lifetime can end early at an implicit consume:

  func bar(_ object: consuming Object) {}

  func foo(object: consuming Object) {
    bar(object)
    print("after consume")
  }

Will always give you:

deinit object
after consume

This provides an ideal programming model for those who care about ARC overhead and CoW behavior. Soon, I hope to be able to declare any local variable with some keyword equivalent to consuming on parameters so programmers can opt into this more broadly.

1 Like

Thanks, that's an excellent document. The rules and rational that it lays out make a lot of sense. There's a lot more nuance and complexity to it than I would have guessed.

I concur that in general it's unwise to make timing assumptions in implementing deinit since the author nominally can't control how instances are used. But arguably it's different for the user of the class - they might completely control the use. As such, when consume is used explicitly like that, arguably it's the author telling the compiler "make sure this reference goes away right here", in which case any otherwise normal behaviour regarding automatic lifetime extension should be suppressed (but the compiler could still issue an error diagnostic if it can see that lifetime extension is required for correctness, in which case the author has to fix their code - and more importantly perhaps their understanding of their code).

Maybe I'm a dinosaur? :slightly_smiling_face: I've never liked the wild uncertainty of garbage collection and I grew up in the manual retain-release era, so in my mind reference types are deterministic if you just understand their use (which you often do in any reliable retain-release-using application, because retain-release doesn't magically make lifetime concerns go away, such as peak memory usage).

I recognise that there's also the use-case for _ = consume of just being a way to say "don't let me use this again" (as an alternative to using block scoping to the same effect). Possibly these two functions should be separated into orthogonal directives? I'm not particularly a fan of using consume for that purpose anyway - it seems like it's incidental and a bit hacky.

Is that bad? It seems like this serves as a useful distinction of different desires. The use of an explicit consume in the caller is a way to say "I want to be done with this variable right here irrespective of what the callee does", as opposed to the implicit consumption case which is about the callee's preference instead. The caller and callee can have different preferences here (just not diametrically opposed, e.g. consuming callee and non-consuming caller for a non-copyable type).

Rabbit holing

It seems that automatic lifetime extension is technically a convenience, so that people don't have to use withExtendedLifetime nearly as often? It seems like the compiler could have chosen to just issue an error - "use after free" essentially - in these cases, but instead it just silently makes it work. Which I think is great, but it does mean anyone with the mental model that things deinit / release immediately after last use has an inaccurate understanding.

Now that I reflect on it, prior to reading the aforelinked doc I didn't have a precise notion for how Swift handles lifetimes within a given block. e.g. whether it keeps everything alive until the end of the block or just until the last reference therein. I think my presumption was that it's semantically until the end of the block but the optimiser, as in all cases, is free to actually do it differently if there's no observable difference. Which seems to be what the aforelinked doc confirms is the ideal if not always the reality?

But that just ensures the object remains alive until a well-defined point, it doesn't require that the object go away at the end of that block, right?

Related: Asserting that deinit happened

There's a pattern that's been around for ages, but I've seen it promoted a lot recently, for checking that reference types are deinited when they're supposed to be (per the author's intent). Particularly as part of unit tests. In a nutshell it is to take a weak reference to the object and then assert that it's nil later. It sounds like the weak reference would actually extend the lifetime of the value, ironically, in some cases. That's unintuitive (for that pattern).

Maybe there should be a way to more explicitly deinit a reference type, meaning a way to say "release this now and assert / precondition that it's actually been deallocated as a result"? It seems to be desirable to a lot of people - call it part of "acceptable society". :grin:

Tangent: Delayed deinit optimisations

I am also very interested in the potential for delaying deinit beyond the nominal scope. Potentially way beyond. That might sound odd given my interest in shortening lifetimes, above, but it's for cases like Memory pools (re. binary tree performance) where it'd be awesome if the compiler could transform a bunch of heap allocations into essentially a stack allocation, or an arena more generally, such that allocations are super cheap and deallocation is fantastically cheap (just the whole memory region, not values individually). If anything in this discussion is at odds with permitting that, I'd like to know.

3 Likes

Fair point. There are two opposing points that are typically made here:

  1. even when users try to control references, it's easy for aliases to accidentally sneak in

  2. potential side-effects should be explicit. Hence the try and await keywords

Right. A copyable variable can be "consumed" two ways

  1. explicitly using a consume operator
  2. implicitly by declaring it consuming and passing it to something that needs ownership

In either case, the compiler should not extend the lifetime beyond that consume.

What's debatable is whether the compiler can shorten a lifetime before the point of the consume, or on paths without a consume. Doing so allows us to combine ownership control with normal ARC optimization. On the other hand, ARC optimization is less important in a world where copies must be explicit, and it is natural to assume that _ = consume object has the same effect as withExtendedLifetime(object).

I lean toward allowing copyable lifetimes to be optimized because we can never change our mind after making them strict, and I would not want to add another dimension of performance annotation on top of the ownership controls.

We've tried that. The compiler does not know whether lifetime extension is required in any interesting cases. It's doing lifetime extension conservatively whenever it cannot prove that shortening to the last use is safe.

We get a lot of mileage out of ARC being fully deterministic given fixed inputs, including source code and compiler version. And we're adding ownership control to allow programers to have precise control so they don't need to think about what the optimizer would do. The debate here is mainly about when deinit side effects should be observable in the regular code.

The "don't let me use this again" and "deinit no later than this" use cases seem compatible. The "don't deinit earler than this" use case would also be nice, but it doesn't seem very important for copyable types, there is already a way to do that (withExtendeLifetime), and optimization can be useful for copyable types in situations where we have a normal consume that wasn't written as an explicit lifetime marker.

I would like to encourage programmers to use ownership controls and tell them they'll get strictly better performance when those controls are used right. Here, the consume operator was used perfectly, but it may have disabled optimization.

Remember, you can always wrap a reference in a non-copyable type to get strict lifetimes if that's what you're after. (There's a another lifetime debate that we need to have about whether you also need a user-defined deinit, but that will be a different thread).

You very rarely need it in practice. Only when you're abusing class deinitializers or doing some unsafe low-level programming.

That would be nice, but it's impossible. By design, there's no way to reliably analyze weak references and unsafe pointers.

Lifetime optimization can lead to observable difference whenever shared mutable state exists and there are no other barriers to optimizing the lifetime (no potential unsafe pointers or weak references into the managed object, and no calls outside of pure Swift code).

We only extend a the lifetime of a variable that holds a strong reference within the variable's lexical scope. Without that lifetime extension, weak references are effectively unusable without explicit lifetime control. The case people want to test is when some alias of the local ends up accidentally hanging around, which still works as expected.

Right. We have resisted making a guarantee that the compiler won't extend lifetimes (absent a consume as defined above), but I'm strongly biased toward the rule that deinitialization is never reordered with certain synchronization points. It provides basic debugging sanity and a reasonable level of programmer control.

I don't think this precludes memory pools though. To do that, we would allow the regular deinit path to relinquish its memory without invoking the deallocator.

1 Like

My recollection is that during review of SE-0366 we’d discussed this issue and had concluded that we did want this to fix lifetime? I could be misremembering…

Oh, I think I misunderstood things then - it sounds like we're in complete agreement. I thought you were saying earlier that _ = consume does not ensure deinit / release at that point; that the compiler could defer it to later. I think I see now that you were talking about the other side - whether _ = consume means the compiler has to ensure deinit / release doesn't happen before that point. And as things currently stand, deinit / release can be pulled ahead of the _ = consume but not pushed behind?

That sounds good to me - the concern is usually about making something go away by a specific point, not about whether it can leave early as an optimisation - though there are some potential use cases where that would be undesirable - e.g. a 'Timer' type which counts until its deinit:

{
    let timer = Time("Process request")
    // Do stuff that doesn't itself interact with `timer`.
} // Timer deinits here, and therefore stops.

It sounds like today that wouldn't work reliably; that you'd need to wrap the timer in withExtendedLifetime? And use _ = consume if you don't already exit scope when the timer is supposed to stop?

There are other ways to implement such a timer, e.g. pass a non-escaping closure to a time() method, which seem like they might work as intended… but without some way to essentially insert instruction barriers explicitly, it seems like it's technically impossible to implement such a timer correctly? Even in the following example, presumably the compiler is free to reorder the stop()?

let timer = Timer("Process request")
timer.start()
// Do stuff that doesn't interact with timer and may be completely
// transparent, e.g. just some arithmetic on local variables.
timer.stop()

(assume stop() is @inline(__always) or similar, if necessary)

Perhaps my notion that any of this would work is an errant carry-over from other languages? e.g. C++ where I've seen these patterns most often. But in that case, how does one implement such a timer correctly in Swift?

It happens to work out that way for non-copyable types since they already have strict lifetimes.

I not surprised if someone expected _ = consume to fix lifetimes. But I'm sure no one explicitly came out and said that the consume operator has special semantics when the moved value happens to be bound to an underscore. That's not something that just happens naturally and surely deserves some discussion.