SwiftServerConf - Feedback on "Leveraging structured concurrency in your applications"

Hello,

EDIT: I make mistakes in this post, and fix them in the following answer.

I've just watched the excellent Leveraging structured concurrency in your applications talk by @FranzBusch at SwiftServerConf, and my interest was raised by the withResource pattern described in the talk:

public struct Resource {
    public func interact() async { }
    internal func close() async { }
}

public func withResource<ReturnType>(
    isolation: isolated (any Actor)? = #isolation,
    _ body: (inout sending Resource) async -> ReturnType
) async -> ReturnType {
    var resource = Resource ()
    let result = await body(&resource)
    await Task {
        await resource.close ()
    }.value
    return result
}

I'm immensely interested in such temporary and wrapped access to a resource, because I maintain GRDB which uses this pattern for accessing an SQLite database:

let players = try await reader.read { db in
    try Player.fetchAll(db)
}

GRDB has a few differences with the pattern described in the talk: the body function is synchronous, and it is non-isolated. But that's not the topic of this message.

The topic of this message is the ability to build and return non-Sendable types from withResource-style methods:

let nonSendable = withResource { resource in
    // Return a NonSendable instance built from resource
}

Support for non-Sendable types is very desirable, because it helps people deal with their existing non-Sendable types. GRDB can't deal with them so far, and this had me write a long documentation chapter about this caveat.

Full of hope, I made experiments with Franz's code.

Since I want to deal with non-Sendable type, I first modified the original code so that the returned value is "sent" back to the caller:

// Now both withResource and body have a "sending" result
public func withResource<ReturnType>(
    isolation: isolated (any Actor)? = #isolation,
    _ body: (inout sending Resource) async -> sending ReturnType
) async -> sending ReturnType {
    var resource = Resource ()
    let result = await body(&resource)
    await Task {
        await resource.close ()
    }.value
    return result
}

This still compiles fine, as expected (Swift 6 language mode).

I then made an exhaustive list of the possible ways to build a Sendable value out of a Resource. Sendable because I want to put all chances on my side in order to build a non-Sendable value, so I first start by identifying the patterns that work in the simple Sendable case. Exhaustive because region-based isolation has various rules for grouping values, and the exhaustivity should help exploring a maximum of those rules.

// A Sendable, non-isolated type
struct Sendable {
    // Resource initializers
    init(resource: Resource) { }
    init(inoutResource: inout Resource) { }
    init(sendingResource: sending Resource) { }
    // Won't compile: Sending 'inoutSendingResource' risks causing data races
    // init(inoutSendingResource: inout sending Resource) { }
    
    // Factory methods
    static func make(resource: Resource) -> Sendable { fatalError() }
    static func make(inoutResource: inout Resource) -> Sendable { fatalError() }
    static func make(sendingResource: sending Resource) -> Sendable { fatalError() }
    static func make(inoutSendingResource: inout sending Resource) -> Sendable { fatalError() }
}

extension Resource {
    func makeSendable() -> Sendable {
        Sendable()
    }
    
    mutating func mutatingMakeSendable() -> Sendable {
        Sendable()
    }
}

And I tested all of them. Three techniques won't compile. Fine, let's discard them. There are plenty others to live with.

@Suite("Sendable tests") struct SendableTests {
    // ❌ Sending 'resource' risks causing data races
    func testInitSendingResource() async {
        let value = await withResource { resource in
            Sendable(sendingResource: resource)
        }
        print(value)
    }

    // ❌ Sending 'resource' risks causing data races
    func testFactorySendingResource() async {
        let value = await withResource { resource in
            Sendable.make(sendingResource: resource)
        }
        print(value)
    }
    
    // ❌ Sending 'resource' risks causing data races
    func testFactoryInoutSendingResource() async {
        let value = await withResource { resource in
            Sendable.make(inoutSendingResource: &resource)
        }
        print(value)
    }
}

I then applied all techniques that work with a Sendable type to a non-Sendable type.

And they all fail to compile:

// A non-Sendable, non-isolated type
final class NotSendable {
    init(resource: Resource) { }
    init(inoutResource: inout Resource) { }
    static func make(resource: Resource) -> NotSendable { fatalError() }
    static func make(inoutResource: inout Resource) -> NotSendable { fatalError() }
}

extension Resource {
    func makeNotSendable() -> NotSendable {
        NotSendable()
    }
    
    mutating func mutatingMakeNotSendable() -> NotSendable {
        NotSendable()
    }
}

@Suite("NotSendable tests") struct NotSendableTests {
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testInitResource() async {
        let value = await withResource { resource in
            NotSendable(resource: resource)
        }
        print(value)
    }
    
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testInitInoutResource() async {
        let value = await withResource { resource in
            NotSendable(inoutResource: &resource)
        }
        print(value)
    }
    
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testFactoryResource() async {
        let value = await withResource { resource in
            NotSendable.make(resource: resource)
        }
        print(value)
    }
    
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testFactoryInoutResource() async {
        let value = await withResource { resource in
            NotSendable.make(inoutResource: &resource)
        }
        print(value)
    }
    
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testResourceMakeNotSendable() async {
        let value = await withResource { resource in
            resource.makeNotSendable()
        }
        print(value)
    }
    
    // ❌ 'inout sending' parameter 'resource' cannot be task-isolated at end of function
    func testResourceMutatingMakeNotSendable() async {
        let value = await withResource { resource in
            resource.mutatingMakeNotSendable()
        }
        print(value)
    }
}

@FranzBusch, I'm obviously not here to criticize the excellent pattern you have demonstrated in the talk. I was wondering if you have started thinking about building non-Sendable values out of a resource. This does not sound like an unreasonable use case to me.

1 Like

I then made an exhaustive list of the possible ways to build a Sendable value out of a Resource.

Exhaustive? No :wink:

I forgot:

extension Resource {
    func makeSendingNotSendable() -> sending NotSendable {
        NotSendable()
    }
}

This one works fine:

// ✅
func testResourceMakeSendingNotSendable() async {
    let value = await withResource { resource in
        resource.makeSendingNotSendable()
    }
    print(value)
}

OK. Sorry for disturbing you, @FranzBusch, and thanks again for the inspiring talk :+1:

Back to the GRDB drawing board.

Sorry for the delayed reply but I was on vacation for a few days.

You are correct that currently it is required to mark the ReturnType as sending for both the body closure and the with-method itself. However, from what I can tell this is a compiler bug. The reason behind this is that currently the compiler assumes that any non-Sendable/non-sending closure is isolated to the same context as where it was formed. Since our withResource method is inheriting the isolation it means that the isolation of our body closure and the withResource method is the same. That's what allows us to pass the non-Sendable Resource to the body closure without having to send it in the first place. However, the compiler currently doesn't do the same analysis for the return type. @hborla proposal for changing the default isolation of nonisolated async methods also contains a section about function conversion that covers this.

Just so that I understand what you want to achieve here. You want to provide scoped access to a non-Sendable resource and you want to allows users to extract parts of that non-Sendable resource that are safe to send off and even outlive the initial resource itself, correct?

Thanks for your reply, @FranzBusch. I see what you mean with the unnecessary "sending".

Just so that I understand what you want to achieve here. You want to provide scoped access to a non-Sendable resource and you want to allows users to extract parts of that non-Sendable resource that are safe to send off and even outlive the initial resource itself, correct?

Yes, exactly.

The excellent technique I learned in your video is the fact that when the resource is inout, the value built from the resource can be sent off:

// NS is not Sendable

// When rez is not inout: error
// Returning a task-isolated 'NS' value as a 'sending' result risks causing data races
let ns = try await withResource { rez in try NS.fetch(rez) }

// When rez is inout: no error
let ns = try await withResource { rez in try NS.fetch(rez) }