Skip to content

Instantly share code, notes, and snippets.

@lorentey
Last active November 8, 2019 04:22
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 lorentey/71981897bb8637cb060255837730e5d8 to your computer and use it in GitHub Desktop.
Save lorentey/71981897bb8637cb060255837730e5d8 to your computer and use it in GitHub Desktop.
[pitch] Exposing the memory locations of class instance variables

Exposing the Memory Location of Class Instance Variables

Introduction

We propose to enable Swift code to retrieve the memory location of any directly addressable stored property in a class instance as an UnsafeMutablePointer value.

The initial implementation of the MemoryLayout API introduced in this document is available at the following URL: apple/swift#28144

An up-to-date copy of this document (with a revision history) is available at https://gist.github.com/lorentey/71981897bb8637cb060255837730e5d8.

Motivation

For Swift to be successful as a systems programming language, it needs to allow efficient use of the synchronization facilities provided by the underlying computer architecture and operating system, such as primitive atomic types or higher-level synchronization tools like pthread_mutex_t or os_unfair_lock. Such constructs typically require us to provide a stable memory location for the values they operate on.

Swift provides a number of language and runtime constructs that are guaranteed to have a stable memory location. (Incidentally, concurrent access to shared mutable state is only possible through memory locations such as these.) For example:

  • Dynamic variables manually created by Swift code (such as through allocate/initialize methods on unsafe pointer types, or ManagedBuffer APIs) inherently have a stable memory location.

  • Class instances get allocated a stable memory location during initialization; the location gets deallocated when the instance is deinitialized. Individual instance variables (ivars) get specific locations within this storage, using Swift's class layout algorithms.

  • Global variables and static variables always get associated with a stable memory location to implement their storage. This storage may be lazily initialized on first access, but once that's done, it remains valid for the entire duration of the Swift program.

  • Variables captured by an (escaping) closure get moved to a stable memory location as part of the capture. The location remains stable until the closure value is destroyed.

  • The stdlib provides APIs (such as withUnsafePointer(to:_:)) to pin specific values to some known memory location for the duration of a closure call. This sometimes reuses the existing storage location for the value (e.g., if these APIs are called on a directly accessible global variable), but this isn't guaranteed -- if the existing storage happens to not be directly available, these APIs silently fall back to creating a temporary location, typically on the stack.

However, Swift does not currently provide ways to reliably retrieve the address of the memory location backing these variables -- with the exception of dynamic variables, where all access is done through an explicit unsafe pointer whose value is (obviously) known to the code that performs the access.

Therefore, in current Swift, constructs that need to be backed by a known memory location can only be stored in dynamically allocated memory. For example, here is a simple "thread-safe" integer counter that uses the Darwin-provided os_unfair_lock construct to synchronize access:

(We can wrap POSIX Thread mutexes in a similar manner; we chose os_unfair_lock for this demonstration to minimize the need for error handling.)

class Counter {
    private var _lock: UnsafeMutablePointer<os_unfair_lock_s>
    private var _value: Int = 0
    
    init() {
        _lock = .allocate(capacity: 1)
        _lock.initialize(to: os_unfair_lock_s())
    }
    
    deinit {
        _lock.deinitialize(count: 1)
        _lock.deallocate()
    }
    
    private func synchronized<R>(_ body: () throws -> R) rethrows -> R {
        os_unfair_lock_lock(_lock)
        defer { os_unfair_lock_unlock(_lock) }
        return try body()
    }
    
    func increment() {
        synchronized { _value += 1}
    }
    
    func load() -> Int {
        synchronized { _value }
    }
}

Having to manually allocate/deallocate memory for such constructs is cumbersome, error-prone and inefficient. We should rather allow Swift code to use inline instance storage for this purpose.

To enable interoperability with C, the Swift Standard Library already provides APIs to retrieve the memory location of a class instance as an untyped UnsafeRawPointer value:

class Foo {
    var value = 42
}

let foo = Foo()
let unmanaged = Unmanaged.passRetained(foo)
let address = unmanaged.toOpaque()
print("foo is located at \(address)") // ⟹ foo is located at 0x0000000100500340
unmanaged.release()

However, there is currently no way to reliably retrieve the memory location for individual stored variables within this storage.

SE-0210 introduced a MemoryLayout.offset(of:) method that can be used to determine the layout offset of a stored variable inside a struct value. While this method doesn't work for classes, we can use it to guide the design of new API that does.

Proposed Solution

We propose to add an API that returns an UnsafeMutablePointer to the storage behind a directly addressable, mutable stored property within a class instance:

extension MemoryLayout where T: AnyObject {
  static func unsafeAddress<Value>(
    of key: ReferenceWritableKeyPath<T, Value>,
    in root: T
  ) -> UnsafeMutablePointer<Value>?
}

If the given key refers to a stored property within the in-memory representation of root, and the property is directly addressable (in the sense of SE-0210), then the return value is a direct pointer to the memory location implementing its storage.

Accessing the pointee property on the returned pointer is equivalent to the same access of the instance property itself (which is in turn equivalent to access through the corresponding key path):

class Foo {
    var value = 42
}

let foo = Foo()

// The following groups of statements all perform the same accesses
// on `foo`'s instance variable:

print(foo.value) // read
foo.value = 23   // assignment
foo.value += 1   // modification

print(foo[keyPath: \.value]) // read
foo[keyPath: \.value] = 23   // assignment
foo[keyPath: \.value] += 1   // modification

withExtendedLifetime(foo) {
  let p = MemoryLayout.unsafeAddress(of: \.value, in: foo)!
  print(p.pointee)  // read
  p.pointee = 23    // assignment
  p.pointee += 1    // modification
}

Note the use of withExtendedLifetime to make sure foo is kept alive while we're accessing its storage. To rule out use-after-free errors, it is crucial to prevent the surrounding object from being deallocated while we're working with the returned pointer. (This is why this new API needs to be explicitly tainted with the unsafe prefix.)

Note also that the Law of Exclusivity still applies to accesses through the pointer returned from unsafeAddress(of:in:). Accesses to the same instance variable (no matter how they're implemented) aren't allowed to overlap unless all overlapping accesses are reads. (This includes concurrent access from different threads of execution as well as overlapping access within the same thread -- see SE-0176 for details. The compiler and runtime environment may not always be able to diagnose conflicting access through a direct pointer; however, it is still an error to perform such access.)

We can use this new API to simplify the implementation of the previous Counter class:

final class Counter {
    private var _lock = os_unfair_lock_s()
    private var _value: Int = 0
    
    init() {}
    
    private func synchronized<R>(_ body: () throws -> R) rethrows -> R {
        let lock = MemoryLayout<Counter>.unsafeAddress(of: \._lock, in: self)!
        os_unfair_lock_lock(lock)
        defer { os_unfair_lock_unlock(lock) }
        return withExtendedLifetime(self) { try body() }
    }
    
    func increment() {
        synchronized { value += 1}
    }
    
    func load() -> Int {
        synchronized { value }
    }
}

(Note that the functions os_unfair_lock_lock/os_unfair_lock_unlock cannot currently be implemented in Swift, because we haven't formally adopted a memory model yet. Concurrent mutating access to _lock within Swift code will run afoul of the Law of Exclusivity. Carving out a memory model that assigns well-defined semantics for certain kinds of concurrent access is a separate task, deferred for future proposals. For now, we can state that Swift's memory model must be compatible with that of C/C++, because Swift code is already heavily relying on the ability to use synchronization primitives implemented in these languages.)

Efficiency Requirements

To make practical use of this new API, we need to ensure that the unsafeAddress(of:in:) invocation is guaranteed to compile down to a direct call to the builtin primitive that retrieves the address of the corresponding ivar whenever the supplied key is a constant-evaluable key path expression. (Ideally this should work even in unoptimized -Onone builds.) Creating a full key path object and iterating through its components at runtime on each and every access would be prohibitively expensive for the high-performance synchronization constructs that we expect to be the primary use case for this new API.

This optimization isn't currently implemented in our prototype PR, but we expect it will be done as part of the process of integrating the new API into an actual Swift release.

For now, to enable performance testing, we recommend caching the ivar pointers in (e.g.) lazy stored properties:

final class Counter {
  private var _lockStorage = os_unfair_lock_s()
  private var _value: Int = 0

  private lazy var _lock: UnsafeMutablePointer<os_unfair_lock_s> =
    MemoryLayout<Counter>.unsafeAddress(of: \._lockStorage, in: self)!

  private func synchronized<R>(_ body: () throws -> R) rethrows -> R {
    os_unfair_lock_lock(_lock)
    defer { os_unfair_lock_unlock(_lock) }
    return try withExtendedLifetime(self) { try body() }
  }

  func increment() {
    synchronized { _value += 1}
  }

  func load() -> Int {
    synchronized { _value }
  }
}

This wastes some memory, but has performance comparable to the eventual implementation.

Detailed Design

extension MemoryLayout where T: AnyObject {
  /// Return an unsafe mutable pointer to the memory location of
  /// the stored property referred to by `key` within a class instance.
  ///
  /// The memory location is available only if the given key refers to directly
  /// addressable storage within the in-memory representation of `T`, which must
  /// be a class type.
  ///
  /// A class instance property has directly addressable storage when it is a
  /// stored property for which no additional work is required to extract or set
  /// the value. Properties are not directly accessible if they are potentially
  /// overridable, trigger any `didSet` or `willSet` accessors, perform any
  /// representation changes such as bridging or closure reabstraction, or mask
  /// the value out of overlapping storage as for packed bitfields.
  ///
  /// For example, in the `ProductCategory` class defined here, only
  /// `\.updateCounter`, `\.identifier`, and `\.identifier.name` refer to
  /// properties with inline, directly addressable storage:
  ///
  ///     final class ProductCategory {
  ///         struct Identifier {
  ///             var name: String              // addressable
  ///         }
  ///
  ///         var identifier: Identifier        // addressable
  ///         var updateCounter: Int            // addressable
  ///         var products: [Product] {         // not addressable: didSet handler
  ///             didSet { updateCounter += 1 }
  ///         }
  ///         var productCount: Int {           // not addressable: computed property
  ///             return products.count
  ///         }
  ///         var parent: ProductCategory?  // addressable
  ///     }
  ///
  /// When the return value of this method is non-`nil`, then accessing the
  /// value by key path or via the returned pointer are equivalent. For example:
  ///
  ///     let category: ProductCategory = ...
  ///     category[keyPath: \.identifier.name] = "Cereal"
  ///
  ///     withExtendedLifetime(category) {
  ///       let p = MemoryLayout.unsafeAddress(of: \.identifier.name, in: category)!
  ///       p.pointee = "Cereal"
  ///     }
  ///
  /// `unsafeAddress(of:in:)` returns nil if the supplied key path has directly
  /// accessible storage but it's outside of the instance storage of the
  /// specified `root` object. For example, this can happen with key paths that
  /// have components with reference semantics, such as the `parent` field
  /// above:
  ///
  ///     MemoryLayout.unsafeAddress(of: \.parent, in: category) // non-nil
  ///     MemoryLayout.unsafeAddress(of: \.parent.name, in: category) // nil
  ///
  /// - Warning: The returned pointer is only valid until the root object gets
  ///   deallocated. It is the responsibility of the caller to ensure that the
  ///   object stays alive while it is using the pointer. (The
  ///   `withExtendedLifetime` call above is one example of how this can be
  ///   done.)
  ///
  ///   Additionally, the Law of Exclusivity still applies: the caller must
  ///   ensure that any access of the instance variable through the returned
  ///   pointer will not overlap with any other access to the same variable,
  ///   unless both accesses are reads.
  @available(/* to be determined */)
  public static func unsafeAddress<Value>(
    of key: ReferenceWritableKeyPath<T, Value>,
    in root: T
  ) -> UnsafeMutablePointer<Value>?
}

Source Compatibility

This is an additive change to the Standard Library, with minimal source compatibility implications.

Effect on ABI Stability

Key path objects already encode the information necessary to implement the new API. However, this information isn't exposed through the ABI of the stdlib as currently defined. This implies that the new runtime functionality defined here needs to rely on newly exported entry points, so it won't be back-deployable to any previous stdlib release.

Effect on API Resilience

As in SE-0210, clients of an API could potentially use this functionality to dynamically observe whether a public property is implemented as a stored property from outside of the module. If a client assumes that a property will always be stored by force-unwrapping the optional result of unsafeAddress(of:in:), that could lead to compatibility problems if the library author changes the property to computed in a future library version. Client code using direct ivar pointers should be careful not to rely on the stored-ness of properties in types they don't control.

Alternatives Considered

While we could have added the new API as an extension of ReferenceWritableKeyPath, the addition of the root parameter makes that option even less obvious than it was for offset(of:). We agree with SE-0210 that MemoryLayout is the natural place for this sort of API.

Generalizing offset(of:)

We considered extending the existing offset(of:) method to allow it to return an offset within class instances. However, the means of converting an offset to an actual pointer differ between struct and class values, and using the same API for both is likely to lead to confusion.

let foo = Foo()
let offset = MemoryLayout<Foo>.offset(of: \.value)!

// if Foo is a struct:
withUnsafeBytes(of: foo) { buffer in 
    let raw = buffer.baseAddress! + offset
    let p = raw.assumingMemoryBound(to: Int.self)
    print(p.pointee) 
}

// if Foo is a class:
withExtendedLifetime(foo) { 
    let raw = Unmanaged.passUnretained(foo).toOpaque() + offset
    let p = raw.assumingMemoryBound(to: Int.self)
    print(p.pointee)
}

We also do not like the idea of requiring developers to perform fragile raw pointer arithmetic just to access ivar pointers. The proposed API abstracts away these details.

Closure-Based API

Elsewhere in the Standard Library, we prefer to expose unsafe "inner" pointers through APIs that take a closure. For example, SE-0237 added the following customization point to Sequence:

protocol Sequence {
  public mutating func withContiguousMutableStorageIfAvailable<R>(
    _ body: (inout UnsafeMutableBufferPointer<Element>) throws -> R
  ) rethrows -> R?
}

At first glance, this looks very similar to the ivar case. In both cases, we're exposing a pointer that has limited lifetime, and arranging the client code into a closure helps avoiding lifetime issues.

It would definitely be possible to define a similar API here, too:

extension MemoryLayout where T: AnyObject {
  public static func withUnsafeMutablePointer<Value, Result>(
    to key: ReferenceWritableKeyPath<T, Value>,
    in root: T,
    _ body: (UnsafeMutablePointer<Value>) throws -> Result
  ) rethrows -> Result?
}

However, the ivar usecase is something of a special case that makes this approach suboptimal.

In the Sequence.withContiguousMutableStorageIfAvailable case, it is strictly illegal to save the pointer for later access outside the closure -- Sequence implementations are allowed to e.g. create a temporary memory location for the duration of the closure, then immediately destroy it when the closure returns. There is no way to (reliably) hold on to the storage buffer.

In contrast, the memory location (if any) of a class ivar is guaranteed to remain valid as long as there is at least one strong reference to the surrounding object. There is no need to artifically restrict the use of the ivar pointer outside the duration of a closure -- indeed, we believe that the guaranteed ability to "escape" the pointer will be crucially important when we start building abstractions on top of this API.

In the primary usecase we foresee, the pointer would get packaged up with a strong reference to its object into a standalone construct that can fully guarantee the validity of the pointer. Given that this usecase is deliberately escaping the pointer, it seems counter-productive to force access through a closure-based API that discourages such things.

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