How do closures work (memory management)

Hello,

I have been playing today with the Swift runtime in order to create some tools for myself (GitHub - mikolasstuchlik/Rentgen: Swift runtime diagnostic toolkit) and to improve my understanding of the language.

In doing so, I have found out, that I don't know how the (escaping) closures work! My high level understanding is, that escaping closures will create capture list, that will retain and copy all things the closure need. The capture list itself is on the heap.

Therefore, I would presume, that the capture list is an instance of HeapObject but it looks like it is not the case.

How does the reference counting of closures/capture lists work (and are they even reference counted)?

Closures are such an important part of the language. I have to deal with them almost every time I open the debugger. Yet, I know almost nothing about them!

Could you share with me some resources (or link to code) that would improve my understanding?

Thanks :slight_smile:
-stuchlej

4 Likes

Closures are actually conceptually a bit more like this:

struct Closure {
    var functionPointer: UnsafeRawPointer
    var closureContext: AnyObject?
}

That is, they are two pointers wide, where the first pointer is a function pointer pointing to the code that implements the closure, and the second pointer optionally points to a reference counted object containing the closed-over state.

So, yes, the capture list is heap allocated, and it is reference counted. However, I don’t think it is an actual Swift object per se, it behaves somewhat more unusually than that. I’m afraid I can’t help more than this.

12 Likes

Hello,

thanks for the reply! :slight_smile:

I have the general idea how the “Closure” variable may look like. I have based much of my research on The Swift Runtime: Heap Objects // -dealloc and responses and threads mentioned in Hacking Swift runtime.

I have hooked a function into void * _swift_retain(void *) and I use functions like swift_OpaqueSumary (swift/ReflectionMirror.cpp at 2c920c932a55f9ebecc4bb12764d03a0be704569 · apple/swift · GitHub) (and others) to inspect each retained heap object.

I can observe only null values (which are all instances of a class, for example __StringStorage) and Heap local variable. But I was unable to confirm nor deny, that the Heap local variable values are in fact closure contexts.

Therefore I am currently working with three possibilities:
a) The closure context is in fact Heap local variable and I need to find a proof
b) The closure context is not the Heap local variable and since no other retain is performed, Swift does not perform _swift_retain in order to reference count closure context
c) Somehow I have failed to create an escaping closure and closures were optimized as a part of guaranteed optimizations (I know exists, but I have no understanding of).

1 Like

A little update.

I have created a structure which helps me to expose contents of a Closure.

struct AnyNonThrowing<Argument, Return> {
    struct RawClosureRebound {
        let function: UnsafeRawPointer
        let capture: UnsafeRawPointer
    }

    let closure: (Argument)->Return

    func reboundToRaw(_ validityScope: (RawClosureRebound)->()) {
        withUnsafePointer(to: self) { ptr -> Void in
            ptr.withMemoryRebound(to: RawClosureRebound.self, capacity: 1) { rawPtr -> Void in
                validityScope(rawPtr.pointee)
            }
        }
    }
}

I have looked at the LLDB and Xcode Memory Graph. The LLDB shows, that the capture property is Builtin.NativeObject. I have looked at the closure context of the same closure in Xcode and found out, that there is a Swift closure context capture with address corresponding to the capture variable.

I have not observed, however, a _swift_retain call for heap object corresponding to the capture address.

One interesting thing is, that sometimes the Xcode Memory Graph is able to determine that an instance is retained as a strong [capture] reference and sometimes it shows that an instance is referenced by a conservative reference from a default malloc zone.

2 Likes

Hi @stuchlej – tools for runtime diagnostics for debugging purposes are a really interesting area to explore. There is some existing work in the area by @Mike_Ash here: swift/tools/swift-inspect at main · apple/swift · GitHub.

Depending on what your goals are, it may make sense to collaborate there once you get something useful working. The benefit of doing this is your tool would build with the swift compiler, potentially using some of the compiler source. So if you are relying on some ABI-unstable internals, the tool would change with the compiler. You could also add tests that flag if you break some assumptions made and need to update the tool to match.

7 Likes

Thank you for telling me about the Mike Ash’s work! I’m going to look at it.

My current goal is to improve my understanding of various concepts of the Swift language - currently closures. I would appreciate any help in that regard, since searching for materials (pieces of documentation or relevant parts of source code) have been more difficult to me, than on other topics.

The runtime diagnostic tool I am working on is really an idea that came to me at the sunday morning. At this moment, I am exploring what could be done. In a recent discussion, I have described the current aim of the tool as providing me the ability to put breakpoints to places that are hard to reach by normal means. I would like to expand the capabilites of the tool beyond this (humble) goal as I expend my understanding of the language.

2 Likes

I would like to bring up a little update.
As always, I would appreciate if anyone points me in the right direction.

The reason why I post this messages and why I do not consider this being spam.

I am aware, that this thread could be perceived as using The Swift Forums as my personal notebook.
I would like to assure reader, that I in fact have a personal notebook :slight_smile:
I created this thread (and make this posts) since I think, that closures are one of the most important concepts in Swift. Issues related to closures (and closures related debugging) is more and more common.
At the same time, I find it difficult to debug closures related incidents and use tools like "Xcode Memory Graph" (especially compared to classes). At the same time, compared to classes, there is not a lot of content (either in documentation or blog posts) about how to debug closures and how the closured work. Most of the content reserves to a simple abstraction, that closure context is living on heap and is reference counted.
Therefore I have to dig deeper to understand this problem. And since my knowledge of inner workings of the Swift compiler (and LLVM) and various IL and optimalizations is insufficient, I share my current knowledge and thoughts.

Since the last post
I have created little swift file. In that file, I have created few closures with one or two strong class captures and zero or one mutable value capture.

I have observed, that closures with less than two strong class captures do call retain on captured instance before each call. Unlike closure with more strong class captures which calls retain/release when context is initialized/destroyed.
At this point I assumed, that there are differences (possibly optimiziations that apply) based on the number and nature of closure capture list arguments.

I have tried to search for more information in the SIL. I have used --sil-print-all on the file. I have observed that all closures were initialized using SIL "command" %r = partial_apply ... and the %r was then subject to strong_retain %r and strong_release %r on each assignment. I assume that the %r represents the function pointer and instance of a closure context.

If this is the case, I assume, that there should be some piece of compiler code (possibly amongst multiple SIL optimization passes) that is responsible for the difference of Closure Context behaviour based on the context content and I should be able to observe there, how the context is allocated and reference counted (if there is a context at all). I would really appreciate to know where to find more about this.

Further, I wonder, whether I would be able to intercept context allocation by hooking a custom piece of code to swift_allocObject in the same manner as with swift_retain. (Note: Since posting this, I was able to observe in assembly, that such calls are present around the breakpoint when assigning certain closures.) And If I am correct, that the context is not allocated in a certain scenarios (for example, when context captures strongly only 1 class instance), that my initial observation How do closures work (memory management) is incorrect and contexts are in fact HeapObejcts and I have based on my observations on a wrong piece of code.

Enjoy your weekend!
-stuchlej

2 Likes

This seems plausible to me. Specifically, if the closure stores a pointer to the closure context, and there are cases where the only captured state is a single class reference (or, more broadly, MemoryLayout<ClosedOverState>.size <= 8) then the closure context can be stored inline in the pointer value.

This is probably not quite right (at least one uninhabited pointer representation has to be used to indicate this is happening, and nil is already taken), but it would explain the behaviour here. Closing over a single class requires less than 8 bytes of state to store, and there's already an 8 byte pointer value, so just stuff it in there. This would avoid the need to allocate, and would be a nice performance win for small closure contexts.

2 Likes

There is no additional metadata needed to indicate that a class object is captured, because there are no requirements on the closure context other than that it is a valid argument to swift_retain, and that it can be passed as the context argument to the function pointer.

2 Likes

So this implies that PoD structs cannot be stuffed into that pointer space, even if they'd otherwise fit? That is, if I close over an Int32 I have to allocate a box for it?

2 Likes

The compiler does not currently attempt to optimize such cases, but on 64-bit platforms, it can in the future. 64-bit swift_retain will ignore any argument that is representationally a negative number, so up to 63 bits of trivial data can be encoded that way.

Also note that for non-escaping closures, because there is no need for reference counting, the context argument is completely arbitrary, so 64 bits of data can be encoded in the context.

3 Likes

Thanks for that clarification @Joe_Groff, that’s a useful note for attempting to optimize closure allocations.

I will note regarding nonescaping closures that the Swift compiler seems to have a very high hit-rate with stack allocating those contexts so they tend not to allocate anyway, even if they do end up with worse locality than storing the context inline.

1 Like