What's new about lifetime in Swift 5.10?

Hi, just upgraded to Swift 5.10 a day ago (on Linux) and encountered this issue when compiled with optimization flags on. Basically, in some cases, the lifetime of function parameters are no longer hold until the end, see this commit:

Given the Precise Lifetime Semantics (Objective-C Automatic Reference Counting (ARC) — Clang 19.0.0git documentation), I am under the impression that for a call like a.someMethod(b), the lifetime of b will be held until the end of the someMethod call. But it seems in Swift 5.10 it is no longer the case.

Question: is this become some inlining happened? Is it become there is never a semantics guarantee the behavior I thought it would be? Is it something strange about init in particular? What are the simple rules I can derive to make sure I don't break anything in the future when interop with C?

1 Like

I'm not sure it is directly connected, but there was a long talk about lexical lifetime some years ago: A roadmap for improving Swift performance predictability: ARC improvements and ownership control
May be this changes were partly or tangentially made during ownership improvements as there is active work on ~Copyable types.

2 Likes

Yeah, looks like if this is implemented: A roadmap for improving Swift performance predictability: ARC improvements and ownership control would match the behavior I see (i.e. only happens for init and set method so far when I run the unit test suite).

1 Like

The ARC rules cited above state:

By default, local variables of automatic storage duration do not have precise lifetime semantics. Such objects are simply strong references which hold values of retainable object pointer type, and these values are still fully subject to the optimizations on values under local control.

The lifetimes in question are method arguments, which are local variables. So according to the ObjC ARC rules, which are more or less inherited by Swift, these variable lifetimes may be optimized.

Now, the Swift compiler is actually much more friendly, and only fully optimizes the lifetimes of copy-on-write "value types", Array, Set, Dictionary, and String. The rationale is that (1) such "value types" don't generally have well-defined deinitialization, regardless of the element type, and (2) it is critical for performance to optimize the lifetime of copy-on-write data structures.

Here's more background from an earlier post:

You notice this with initializers and setters because these methods take ownership their arguments by default, and are therefore responsible for destroying them (whenever the implementation of the method pleases).

The simple rule is to follow the formal specification fo ARC, and use withExtendedLifetime to explicitly indicate the end of a values lifetime whenever you unsafely hold a pointer to its storage.

For example, if you expect x to be destroy after a <side effect> that does not reference x, then do this:

{
  var x = ...
  withExtendedLifetime(x) {
    use(x)
    <side effect>
  }
}

The somewhat more complicated Swift compiler rules are:

You can skip withExtendedLifetime if x is not a predefined "value type" and the "side effect" above is any of the following:

  • a use of a weak or unowned reference to an object kept alive by x
  • a use of an unsafe pointer into an object kept alive by x
  • an "externally visible" side effect, such as I/O, closing a file handle, or thread synchronization. (This only applies if deinitialization of x also has an externally visible side effect.)

These more complicated rules exist to handle patterns that appear with some regularity in Swift code.

Rather than breaking existing code by improving lifetime optimizations in these cases, we will allow programers to opt-into lifetime optimization in Swift when using explicit ownership modifiers like borrowing, and consuming. These modifiers are supported on parameter types, but not yet on other local variables, and the implementation is a work-in-progress.

3 Likes

So this is ownership change related and an alternative simple fix is to explicitly marking them as borrowing parameters?

BTW, for withExtendedLifetime, I cannot follow the idiomatic way of doing it because the following snippet would be not well-defined:

public convenience init(_ x: X) {
  withExtendedLifetime(x) {
    // The side-effect happens within the self.init call.
    let rawPointer = x.rawPointer
    self.init(rawPointer)
  }
}

Thanks for the reply!

Your original fix looks correct to me:

This is just another way of expressing the same thing, so both look correct:

public convenience init(_ x: X) {
  withExtendedLifetime(x) {
    // The side-effect happens within the self.init call.
    let rawPointer = x.rawPointer
    self.init(rawPointer)
  }
}

You can change the ownership to borrowing, but could still run into problems after inlining because array lifetimes are canonicalized to their last use.

Thanks! Would the original fix risk that in the future the empty body block optimized away hence the whole withExtendedLifetime optimized away?

withExtandedLifetime forces its argument to be alive regardless of whether its block is empty. So your fix is safe. It’s an awkward API that way but does work and it’s the best we have.

2 Likes