Decorated non-escaping closures?

This results in a really fun error message:

typealias Producer = () -> Int
typealias Consumer = (Producer) -> Void
func intermediate(_ producer: Producer, _ consumer: Consumer) {
    consumer { producer() + 1 }
}

Where's the dragon? Do we need move-only functions?

1 Like

This compiles, BTW:

func intermediate(_ producer: Producer, _ consumer: Consumer) {
    let prod = { producer() + 1 }
    consumer(prod)
}
1 Like

I believe the error message is guarding against situations like

var x = 1
intermediate({ x += $0() }, { return x })

So @tera ’s workaround is not technically safe.

I don't buy it.

Marking producer as @escaping also removes the compiler error but also doesn't make it any more safe.

In fact, I don't see why your example is unsafe. Unexpected or weird maybe, but does it really violate the memory model? It's perfectly fine to capture the same value for mutation in two closures as long as they're isolated to the same context.

Note that none of the above is Sendable or @Sendable by design not omission. Adding it would cause an error at attempting to mutate x from the callback, as expected.

1 Like

The problem is exclusivity. Two nonescaping closures can capture the same value for mutation, but the tradeoff is that those nonescaping closures must not possibly run at the same time. Since producer and consumer come from the same scope, they could potentially access the same mutable captures, and so you can't pass producer or a closure that captures producer as a parameter to consumer, since consumer will exclusively access the same capture scope as producer while it runs, so there would be no safe way for it to invoke producer, not without some new annotation to require the two closures have disjoint captures or something like that.

That's a bug.

1 Like

Aren't you throwing the baby with the bathwater here?

Surely when this happens, the issue is with the caller not with the callee.

This kind of feels weird honestly. A mutating func can call into another mutating func on the same struct and thus temporarily "borrow" its exclusive access for the duration of the call, I don't see this all that different tbh. I guess I'd like a way to temporarily relinquish the exclusive lock for the duration of a call, the same way actors release their lock on await.

1 Like

I don’t buy this. A closure doesn’t take exclusive access of its captures for the entire duration it runs, only for the range it needs access for. That’s no different than re-entrantly using a variable through an object graph, which we don’t (and can’t) protect against. And banning all closure combinations that might have this problem throws out plenty of useful patterns without actually protecting against anything, since you can always avoid this compile-time check with sufficient indirection.

I think something else is going on that’s causing this to get flagged.

5 Likes

I think it's the same scenario as this one:

struct Buffer {
    private var data: UnsafeMutableRawPointer
    private var count: Int
    init(_ len: Int) {
        data = .allocate(byteCount: len, alignment: 1)
        count = len
    }

    mutating func withPointer(cb: (UnsafeMutableRawPointer) -> ()) {
        cb(data)
    }

    mutating func withBuffer(cb: (UnsafeMutableRawBufferPointer) -> ()) {
        withPointer { // <------ overlapping self
            cb(UnsafeMutableRawBufferPointer(start: $0, count: count))
        }
    }
}

For nonescaping closures, we don't know from the outside what the "range it needs access for" is, and have to assume it's the entire duration it runs. It seems to me like we'd need to do dynamic exclusivity checking to get exact "range it needs access for" checking.

Nonescaping closures in their current form don't really allow for any indirection at all. You're right that we might have to revisit this as part of turning nonescaping closures into first-class ~Escapable types. Since we don't want to pay for dynamic exclusivity checking in nonescaping closures, though, it seems like that would require enforcing that closures passed into a function don't have overlapping captures at all, or biting the bullet and doing dynamic exclusivity checking to get exact duration-of-use enforcement like you suggested.

1 Like

There's a performance angle towards "biting the bullet" as well:

A nominally @escaping callback that doesn't actually escape could be inlined away, saving an allocation (similar to how await attempts to save a hop when possible)

I mean, withoutActuallyEscaping exists (and is not “unsafe”), so I think dynamic enforcement is in fact necessary regardless. It still won’t come up very much—it only matters if the same mutable variable is captured in two places at the same time.

You can indeed use withoutActuallyEscaping to get around this restriction, though it can't retroactively add dynamic enforcement to the nonescaping closure, so it does create UB if you have an actual exclusivity violation.

AIUI, the judgment was that it was far less likely that the callee would want to pass one closure argument to another than it would for the caller to want to refer to the same local variables in multiple closures. This is maybe the second time I've seen someone run into the limitation in practice, so while I don't want to discount the fact that there are real use cases for it, it does seem like the right tradeoff overall.

1 Like

This example is almost verbatim from the feature proposal.

See "Restrictions on recursive uses of non-escaping closures"

When a closure is passed as a non-escaping function argument or captured in a closure that is passed as a non-escaping function argument, Swift may assume that any accesses made by the closure will be executed during the call, potentially conflicting with accesses that overlap the call.

And
A function may not call a non-escaping function parameter passing a non-escaping function parameter as an argument.

Non-escaping closures have different semantics for several important reasons, not simply because of exclusivity. The zero abstraction overhead of non-escaping closures is extremely important for many people.

func intermediate(_ producer: Producer, _ consumer: Consumer) {
    let prod = { producer() + 1 }
    consumer(prod)
}

prod above is being allowed to act like a non-escaping closure, but isn't fully diagnosed as one. We should get the same error here.

I filed an issue: Exclusivity restrictions on recursive uses of non-escaping closures are not applied to all non-escaping closures #72174

2 Likes

Consider this:

typealias Readable = (UnsafeMutableRawBufferPointer) async throws -> Int
func reader(read: Readable) async throws {
    let bufferedBeforeUpgrade = try await parseHttp(read) { read in
        while let req = try await read() {
            print(req.path)
        }
        return UnsafeRawBufferPointer(start: nil, count: 0) // not a websocket upgrade
    }
    if bufferedBeforeUpgrade.baseAddress != nil {
        try await parseWebsocket(bufferedBeforeUpgrade, read) { read in
            // ...
        }
    }
}

read() is the async version of the read syscall bound to a berkeley socket. The runtime runs reader() in its own Task and will half close the socket once it returns or throws. In this case it would also cancel its sibling doing the writing, but the other one allows read-only half-close.

While the kernel doesn't mind too much, I explicitly want read to not be sendable because passing it across concurrency domains will cause interleaved reads which are just shy of UB and besides, the goal is to naturally propagate backpressure into the kernel in a composable way, without buffering in userspace. Eventually some synchronization will be required here (between the reader and the writer, or between different sockets) in some but not all cases.

Because a half/full close is issued once the reader returns, read must never escape (the optimization benefits are extra to this goal). To my understanding, there's currently no way to forward this restriction to a specialized handler (for example http, possibly followed by websocket) that exposes a different interface while still propagating backpressure for its lifetime other than using two non-escaping closures, one of which calls into the other.

While it would match this use case (since the closures are defined in different capture contexts), I am against Joe's proposal to add another annotation by argument of complexity alone. There are already way too many annotations in the language to the point that this is starting to feel like a failure to eat your own dog food: Write swift in C++ so you don't have to be bothered by the restrictions imposed onto devs, and every time someone comes up with a valid reason to break the rules, bless it with a new @_withForbulating annotation.

Remaining symbols if you want to play with this:


struct HttpRequest { let path: String /* ... */ }
typealias HttpReadable = () async throws -> HttpRequest?
func parseHttp<T>(_ read: Readable, _ cb: (HttpReadable) async throws -> T) async throws -> T {
    // unworkable: can't use `read` here
    try await cb( { HttpRequest(path: "/") } )
}
func parseWebsocket<T>(_ buffered: UnsafeRawBufferPointer, _ read: Readable, _ cb: (HttpReadable) async throws -> T) async throws -> T {
    // this would have a different signature, it's just a copy/paste to keep the example light
    try await cb( { HttpRequest(path: "/") } )
}
1 Like

To be a bit more constructive, I believe that the following relaxation of NPCR is both useful and easy to implement, without violating NRR:

In the callee, instead of returning a exclusivity-violation error on NPCR violations, mark a edge in a callee-local graph.

In the caller, raise the exclusivity-violation error if there's an edge path between two functions from the same scope.

For this purpose, the scope is defined as:

  1. local if the function is defined within the caller's scope without capturing any other parent scope
  2. parent, if the function is defined within a parent scope (e.g. passed on from the caller's caller) or if it's defined within the caller's scope but captures a parent scope (can't come up with an example, but this is almost certainly possible)

Because non-escaping functions can only be passed down, never up, it is sufficient to forbid local->local calls and parent->parent calls to prevent exclusivity violations.

If scopes can be numbered and this information is readily available for any function, I believe that this can be relaxed further to [callerMinScope, callerMaxScope] ^ [calleeMinScope, calleeMaxScope] = [] but imo, not declared here should go quite a long way here.

1 Like

hi, can you add what NPCR, NRR, mean in your post? I am not familiar with these acronyms but want to follow along

See John's proposal as linked by Andrew 4 replies above.

NRR is a property used to prevent exclusivity violations. NPRC is the rule that is currently followed to guarantee NRR that was known back then to be a conservative over-approximation.

The proposed relaxation avoids NRR by avoiding its initial requirement that the two functions capture the same variable. It carves out this use case from NPCR without having to perform full scope analysis.

NCPR as currently stated rejects any calls between two argument closures A and B because from the callee's point of view, they are both parent (as defined in this thread). The relaxation is to "log and ask the caller" which will reject if the values it passed to those closures are both either local or parent, but will allow the call if one of them is local and the other is parent because it knows that they can't possibly capture the same variable since they don't have overlapping scopes.

Since it's possible for two valid calls to result in a violation (local -> parent -> local), the call graph needs to be augumented with all paths of length 3 but this is sufficient due to there only ever being two possible states. In fact, any path of len > 2 can violate exclusivity and the callee can even raise this locally when augumenting its graph

1 Like