Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active May 22, 2023 19:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save atrick/cc03c4d07fb0a7bee92c223ae5e5695b to your computer and use it in GitHub Desktop.
Save atrick/cc03c4d07fb0a7bee92c223ae5e5695b to your computer and use it in GitHub Desktop.
Swift variable lifetimes

Optimization rules governing the lifetime of Swift variables

Introduction

The combination of automatic reference counting (ARC) and synchronous deinitializers presents a substantial challenge for an optimizing compiler unique to Swift. The Swift 5.7 compiler follows new rules for when optimization is allowed to end the lifetime of variables. The result is more predictable, user-friendly, and performant ARC behavior.

To enforce the new rules, the compiler adopted a new internal representation that tracks the lexical scope of each variable. This involved updating existing optimizations and implementing several new optimizations. Now, all areas of the compiler follow the same, consistent, well-defined rules, making it possible to reason about its behavior for the first time.

The most common programming patterns that depend on extended variable lifetimes are now safe without requiring programmers to explicitly use withExtendedLifetime(). This protects programmers from difficult to diagnose lifetime bugs that only appear at runtime in optimized builds. It also allows the introduction of more powerful optimization without breaking existing source.

To understand what this means for programmers, jump to the "Proposed programming model for object deinitialization" section.

Proposal scope

This is a compiler proposal. All of the program behavior rules proposed here are within what's currently allowed by the language. Swift affords considerable flexibility around implementing variable lifetime. In practice, though, we need more predictability from the optimizer.

To establish context, I'll start by discussing the programming model. I expect this discussion to be the basis of future language documention. But we don't need to finalize language design here. In fact, implementing the proposed rules does not make it any more difficult to add or remove requirements to the language than it already is. They do, however, make it possible to know what to expect from the compiler.

Proposal goals

Accept society. Uphold widespread programmer expectations when it comes to common idioms and legacy APIs, regardless of our ideals. Ensure that programmers don't need to think like a compiler to write correct Swift code. Programmers who are not deliberately reasoning about the rules of variables lifetimes should not be unpleasantly surprised. More importantly, future maintainers of the code won't be surprised by a compiler update breaking their application.

Facilitate ARC. Minimize both the programming burden and performance overhead. Support techniques for breaking reference cycles with minimal cognitive load. Ensure that optimizations aren't critically hamstrung by ARC support.

Encourage evolution. Establish a path to full programmer control over lifetime management. Allow a subset of programmers to benefit from predictably optimized "value types" by using modest extensions to the language.

Formalize compiler behavior. Historically, each piece of the compiler has followed whatever rules, or lack of rules were convenient in that context. Disagreement between those parts of the compiler creates unpredictability and thwarts progress on optimization.

Canonicalize SIL. This is the path to compiler predictability. It also simplifies optimization and catches logic that doesn't follow the rules.

Catch latent bugs. Subtle lifetime and deinitializer compiler bugs have gone unreported. Well-defined lifetime rules that are explicitly represented in SIL allow strong verification.

Proposed programming model

Class object deinitialization is generally unordered

Swift programmers should first understand that the execution of deinitializers is largely unordered with respect to other code. An object is deinitialized when it is no longer needed, and optimization may affect when deinitializers run. This is a basic tenet of automatic memory management. Most programmers won't need to think any harder than this about deinitialization.

Even though deinitializers are generally unordered, their execution may be observed by regular code either through lifetime dependencies or through the side effects of custom deinitializers.

Swift programmers create lifetime dependencies by using weak and unowned references and unsafe pointers. Lifetime dependencies are typically associated with a lexical scope. It is up to the programmer to either avoid escaping the dependent value outside that scope, or to gracefully handle a nil weak reference. To uphold this simple lexically scoped model of dependent lifetimes, the optimizer recognizes lexical scopes that encompass lifetime-dependent values. It avoids deinitializing objects within that scope whenever it may invalidate lifetime dependencies.

Swift programmers may also write custom deinitializers with arbitrary side effects, however much the practice is discouraged. Even though deinitializers are generally unordered, they still run synchronously with regular code. This gives Swift a vastly simpler and safer programmer model for deinitialization than is found in typical garbage-collected languages. As long as an object hasn't been shared across threads or actors, there is no need for programmers to write synchronization code to guard against race conditions. Swift flips the usual programming model around. Synchronization is only required if a deinitializer's execution needs to be ordered with respect to side effects in regular code. To support typical use cases and avoid confounding behavior when optimization us enabled, Swift broadly recognizes "externally visible side effects" as synchronization points. These are side effects that can be safely observed outside of the current thread, including all forms of I/O, inter-process communication, and thread synchronization. In practice, this simply reduces to any externally defined function whose side effects aren't visible to Swift.

In the following sections we discuss situations where deinitialization order may be relevant and how programmers can reason about the expected order of execution.

Lexical variable scope can affect object lifetime

Although deinitialization is unordered, programmers can rely on a some properties of a variable's scope. This isn't something Swift programmers generally need to be concerned with, but it is relevant to a few important programming patterns.

Given the following lexical variable scope:

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

If the "side effect" above is any of the following, the compiler will automatically extend the lifetime of the object referenced by x over that side effect:

  • 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.

The compiler conservatively assumes that calling a function outside of the current Swift module may access weak or unowned references, access unsafe pointers, and produce external side effects.

Any lifetime assumption not covered by the above rules would require an explicit withExtendedLifetime scope to force deinitialization to occur after certain side effects:

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

Normal programming patterns never require withExtendedLifetime.

Deinitialization barriers

The three conditions described above are collectively refered to as deinitialization barriers. Deinitialization is "anchored" to the end of a variable's lexical scope, but may execute earlier. This is substantially different than calling a function or executing a defer block at the end of the scope. Only a designated "deinitialization barrier" orders deinitialization relative to the anchor point.

The first two deinitialization barriers are simple lifetime requirements:

  1. access to weak or unowned references

  2. access to unsafe pointers

These barriers are independent of whether the object has a custom deinitializer.

Synchronization points

The third deinitialization barrier involves "externally visible side effects". To be precise, these are referred to as synchronization points. This name reflects the customary programmer expectation that these sort of side effects have sequential consistency. This includes system calls, which are truly externally visible, but also includes traditional thread synchronization, which the programmer may use to order other "internal side effects", like access to a stored property.

In this example, the compiler must create a copy of x to pass to Array.append, which takes ownership of its argument:

func appendAndSynchronize(array: inout [AnyObject]) {
  let x = SomeClass()
  array.append(x)
  maySynchronize()
  // x.deinit() implicitly depends on maySynchronize()
}

Unlike lifetime dependencies, synchronization points depend on the implementation of the deinitializer. A dependency is implied from a synchronization point in regular code to side effects in the body of a deinitializer. Two deinitializers with side effects are not ordered with respect to each other unless there is an intervening synchronization point in regular code.

Synchronization points are currently reduced to three constructs:

  1. async method calls and other native Swift concurrency constructs

  2. A call to an "externally defined" function

  3. An access to a global variable

Here, "externally defined" refers to an entity whose implementation is opaque to the compiler, either because it is provided by different language or by a library-evolution-enabled Swift module.

Once Swift has native support for atomics and thread synchronization, those builtins will naturally be considered synchronization points.

In the future, programmers will be able to annotate C functions to circumvent the conservative assumption about external code. A "nosynchronization" annotation would be useful, and annotating "pure" functions would be even more powerful when applicable.

Absent synchronization points, no other side effects are ordered with respect to deinitializers:

  • Deinitializer access to stored properties and globals happen in any order.

  • Traps happen in any order. This is true of traps in regular code as well as deinitializers.

Note that synchronization points are not specific to deinitialization order. They are also the only way to order traps.

Value types are eagerly moved

The rules for deinitialization order only apply to "reference types", including classes themselves and also structs, tuples, and enums that contain class references. A type that contains no class references, and therefore doesn't require deinitialization, is refered to as "trivial". Trivial lifetimes are irrelevant because there is no way to observe their destruction. Some non-trivial types should, however, should be thought of as behaving like values even though they contain references--particularly copy-on-write types. To that end, certain types can be designated as "value types". See the "language evolution" section for how value types might be surfaced in the language.

Value types support a programming style that encourages safety and performance by eschewing shared mutable state. The performance benefits rely on precise control of ownership. Programmers expect to be able to move values between scopes without copying their contents. To this end, a value's ownership must be relinquished immediately after its last use. Optimization that eliminates copy-on-write values as soon as they aren't needed can be enormously effective. Code that follows this style also does not expect deinitializers to refer to shared mutable state. In this paradigm, a variable is nothing more than the name of a value, and values are not expected to keep shared objects alive during lexical scopes.

In short, deinitialization barriers do not apply to value types. They have "eager-move" lifetimes as shown here:

{
  var value = ValueType()
  use(value)
  <side effect>
}

The compiler may immediately deinitialize value after its use without proving anything about side effects in the subsequent code or in the the value's deinitializers. This is important when the implementation of either the code after the last use or the deinitializer itself is polymorphic or crosses module boundaries.

Some practical trade-offs need to be made to support programming for reference types vs. eagerly-moved types. As much as possible, we want to create a clear distinction between each style. Shades of grey lead to programmer confusion. Code written in either style should work as expected without excessive programmer intervention.

In fully polymorphic code, Swift must conservatively assume references. It would be unprincipled for the compiler to make optimistic assumptions about generic types that might prove false after type specialization. Typically, generic code can be specialized so that optimization can recover value semantics. For performance critical code paths, the generic library author can statically guarantee precise value ownership by using lifetime annotations, as explained in the "language evolution" section.

Swift's implementation does make one important concession for generic value types. All standard nominal copy-on-write types, String, Array, Set, and Dictionary, have eager-move lifetimes. For the generic collection types, this is true independent of their element type. Swift's standard library types were designed to encourage value semantics. Using these types in a generic context is common, they have an outsize role in performance, and requiring pervasive ownership annotation would be excessive. It is certainly possible for a programmers to use a generic copy-on-write collection to attempt to keep strong references alive until the end of a lexical scope, but this is not a programming model that we need to support in practice.

The message for Swift programmers is simple: standard library collections should not be used to manage the lifetime of their elements beyond what is needed for access to the collection. In the extremely rare case that a programmer wants to force a set of references to be lexically scoped, withExtendedLifetime is the right approach:

let array = getFiles()
withExtendedLifetime (array) {
  let handles = array.map { $0.getHandle() }
  handles.forEach { read($0) }
}

In the future, Swift will support move-only structs with deinitializers, effectively making them non-trivial types. Even though these deinitializers will be observable, they are designed for programming with value types. As such, these types might default to eager-move behavior. If so, then synchronizing struct deinitialization would require explicit withExtendedLifetime scopes. This would be most troublesome for method invocation in which 'self' is not expected to be destroyed:

struct FileWrapper {
  let handle: Handle

   func access() -> Data {
     handle.read() // self may be destroy after evaluating 'handle', but before calling 'read'.
   }

   deinit {
     handle.close()
   }
 }

Programmer responsibilities

Swift code may never directly bitcast an integer to a reference.

class Storage { ... }

This results in undefined behavior:

let addressBits: UInt = ...
let storage = unsafeBitCast(addressBits, to: Storage.self)

The Unmanaged API must be used to convert a bit pattern to a reference:

let addressBits: UInt = ...
let storageAddress =
   UnsafeRawPointer(bitPattern:addressBits)._unsafelyUnwrappedUnchecked
 let unmanagedRef = Unmanaged<__StringStorage>.fromOpaque(storageAddress)
 return unmanagedRef.takeUnretainedValue()

Motivation

Programmer expectations

Without rules for deinitialization barriers, compiler optimization would break common programming patterns in pernicious ways. In debug builds, variables are visible until the end of their lexical scope. While execution is stopped in a debugger, programmers rely on being able to inspect values defined in the current scope. This creates an implicit baseline for variable lifetimes. As compiler optimization naturally improves, it has the effect of shortening the lifetime of values more and more from release to release. This means that production code that has been in maintenance mode for a long time could suddenly exhibit confusing runtime behavior in release builds. The incorrect behavior will not appear during testing, and lifetime bugs are especially difficult to reproduce and diagnose.

Solving this problem involves tradeoffs dictated by support for automatic reference counting (ARC). The compiler conservatively introduces reference counting at the points where variables are used or captured. Reducing that overhead requires compiler optimization that, as a side effect, shortens lifetimes. This allows ownership to be "forwarded" directly from code that produces an owned value into code that consumes an owned value:

let object = makeObject()
array.append(object)

In debug builds, the programmer expects to attach a debugger inside the call to append and inspect that object that was passed into the call. But in optimized release builds, the compiler should not need to emit any retain of object.

ARC also demands that programmers break reference cycles in their code. As a result, programmers often create lifetime dependencies without any deliberate intention to control lifetimes. Normal Swift programming patterns should not require programmers to understand subtle lifetime rules. Here, the programmer has created a weak reference to the local variable object:

struct CaptureWeakRef {
  weak var reference: AnyObject?
}
func chainedWeakRef() {
  let object = makeObject()
  CaptureWeakRef(reference: object).reference! // will force-unwrap crash?
}

Without deinitialization barriers, the code will crash when the weak reference is unwrapped. But that behavior hardly jumps out from the source code.

Programmers should not need think like the compiler at every turn in order to guard against optimization, particularly because experience with other languages is no guide. Other garbage collected language do have weak references, but they are only used for caching, not breaking cycles, so lifetime dependencies aren't an issue in practice. Programmers may also carry over expectations from languages with fully synchronous destructors. But those semantics don't support optimization required for ARC. C++ performance, for example, would be unacceptable if every pointer/reference required shared_ptr and every type had an externally defined virtual destructor.

None of these observations are based on presumption. The pre-release Swift 5.6 compiler enabled powerful ARC optimizations. These optimizations predictably followed the language rules as written prior to this proposal. While migrating Swift applications to this compiler we discovered that some lifetime assumptions are natural and unavoidable. We found several programming patterns where the programmer could not reasonably be expected to use withExtendedLifetime as a defensive tactic. And after adding those annotations, the resulting code was less readable and maintainable. Finding these lifetime "bugs" was deeply problematic. We considered various sanitizers, but all approaches had severe usability limitations. We also found many common APIs that rely on implicit lifetime dependencies. We considered a complex system of annotation that could be used to make such APIs safe, but there is no practical way to migrate all existing API. There was no clear path forward without refining the ARC rules.

Class optimization in the presence of ARC

[TBD] Releases are prevalent. The compiler can't know whether a particular release may trigger deinitialization. If every release was viewed like a function call with arbitrary side effects, the mere presence of ARC would defeat many classic compiler optimizations.

Deinitialization for resource management

Class deinitializers should generally be limited to freeing data dependent on that object--regular program logic is better expressed through other means. For example, resources that should be released predictably relative to other side effects should be managed via closure scopes instead.

var handle = acquireHandle()
defer { releaseHandle(handle) }
accessHandle(handle)

Nonetheless, it may be convenient to manage resource cleanup via deinitialization when that cleanup is independent of other side effects.

class ResourceWrapper {
 
    private var handle: Handle
 
    public init() { self.handle = acquireHandle() }
 
    public func access() { accessHandle(self.handle) }
 
    deinit {
      releaseHandle(self.handle)
    }
}

With the proposed lifetime rules, programmers can rely on this basic, natural expectation: All side effects within ResourceWrapper methods happen before the deinitializer's side effects. Therefore, the initialization, along with any calls to access, will complete before the handle is released. (This behavior naturally falls out of the fact that class methods define a lexical scope in which self is in implicit variable.)

The burden of the programmer is simply to ensure that all access to the resource handle be done within ResourceWrapper. If handle were exposed as a public property, then this pattern would be invalid.

Broken Patterns

Weak Delegate

Bidirectional references

Deinit resource + run loop

Unit test context

Examples: real-word scenarios

With these rules, all of the real-world cases that we encountered in which optimization broke expected program behavior now behave as the programmer intended without their intervention.

The examples below were used to demonstrate the need for withExtendedLifetime in common scenarios. They can all now be written safely without withExtendedLifetime:

class Delegate {}

class Container {

    weak var delegate: Delegate?

    func callDelegate() {
        _ = delegate! // Crashes with object lifetime optimization
    }
}

func useTransientDelegate(container: Container) {
    let delegate = Delegate()
    container.delegate = delegate
    // delegate is destroyed here with object lifetime optimization
    container.callDelegate()
}

And for example, dependent pointers:

class DataWrapper {

    var pointer: UnsafeMutableRawBufferPointer

    init(count: Int) {
        pointer = UnsafeMutableRawBufferPointer.allocate(byteCount: count, alignment: MemoryLayout<Int>.alignment)
    }

    var bytes: UnsafeMutableRawBufferPointer { return pointer }

    deinit {
        pointer.deallocate()
    }
}

func testDataWrapper(input: [UInt8]) {
    let data = DataWrapper(count: input.count)
    // 'data' is released after accessing 'bytes' but before calling 'copyBytes'
    data.bytes.copyBytes(from: input)
}

And for example, deinitializers that have arbitrary side effects, like system calls:

// Counter-example: Don't actually use a class as a file handle abstraction;
// instead use 'defer' to release resources.
class FileHandleWrapper {

    var handle: Int32? = nil

    func open(path: String, flags: Int32) {
        let fd = Darwin.open(path, flags)
        if fd >= 0 {
            handle = fd
        }
    }

    func close() {
        if let fd = handle {
            Darwin.close(fd)
            handle = nil
        }
    }

    deinit {
        if let fd = handle {
            Darwin.close(fd)
        }
    }
}

func testFileHandle(path: String, buffer: UnsafeRawBufferPointer) -> Bool {
    let file = FileHandleWrapper()
    file.open(path: path, flags: Darwin.O_WRONLY)
    // Retrieving 'fd' is the last use of 'file'
    guard let fd = file.handle else { return false }
    // 'fd' has now been closed. The subsequent write will fail.
    write(fd, buffer.baseAddress!, buffer.count)
    return true
}

Language evolution

[TBD]

Lifetime optimization

SIL and compiler internals

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment