A need for a once-callable closure type

Writing Swift 6, I've hit this problem a few times now:

func callOnce(_ closure: () -> Void) {
    closure()
}

public struct NotSendable {}

func send(_ value: sending NotSendable) {
    // not relevant
}

func example() {
    let s = NotSendable()
    callOnce {
        send(s)
//      |- error: sending 's' risks causing data races
//      |         `- note: task-isolated 's' is passed as a 'sending' parameter;
//                   Uses in callee may race with later task-isolated uses
    }
}

At first glance it looks wrong, because there clearly aren't any later task-isolated uses. But a quick change to the code shows what the compiler's problem is:

//      v--- lying, now!
func callOnce(_ closure: () -> Void) {
    closure()
    closure()
}

Now there is a later task-isolated use. And it's quite reasonable that the compiler can't "look inside" callOnce to know how it's going to use the closure that was passed.

Rust solves this problem with the FnOnce trait, a closure type that can be called at most once. (It achieves that by being noncopyable, and the call operator being consuming).

So I thought, we should now have the tools to write that, however verbosely:

protocol CallableAsVoidFunction: ~Copyable {
    consuming func callAsFunction()
}

func callOnce<F: CallableAsVoidFunction & ~Copyable>(_ closure: consuming F) {
    closure.callAsFunction()
}

public struct NotSendable {}

func send(_ value: sending NotSendable) {
    // not relevant
}

// this is a hand-rolled `FnOnce () -> ()`
struct Closure: ~Copyable, CallableAsVoidFunction {
    private var s: NotSendable
    init(s: sending NotSendable) {
        self.s = s
    }
    consuming func callAsFunction() {
        let s = (consume self).s
        send(s)
    }
}

func example() {
    let s = NotSendable()
    callOnce(Closure(s: s))
}

However, once again, the call to send generates the same warning — I guess it can't look at the whole interface of Closure to know that s can't be aliased already when callAsFunction() is called.

Does anyone have any insights or workarounds for this? Do we just need an SE?

7 Likes

Sorry, I don’t have the computer with me. You can mark “sending” for the closure. I think you didn’t mark it for the closure.

Thanks for the reply. I wasn't 100% sure what this meant, but adding sending to the parameter of callOnce doesn't change the error given in either code example. Please let me know if I've misinterpreted you!

func callOnce(_ closure: sending () -> Void) {
closure()
}

Yep, that's what I tried. It gives the same error in both cases, I believe for the same reasons.

I think the issue that compiler cannot reason about this part in terms of concurrency: it still sees s being part of self and therefore not in disconnected region.

sorry, as i don't have a machine with me. But I guess it could be also a bug. because I tried different docker images, they had different behaviour when I was trying sending keyword.

That's what I thought at first, but then I thought nothing prevents s from being aliased outside the struct, was a more likely explanation?

Could be both, though!

1 Like

Yes, I guess that’s the part of the reason why compiler cannot allow your version in the end — even though it is consuming, s isn’t guaranteed to be uniquely used there anyway. That’s why it is probably would need language support for similar to Rust behavior.

1 Like

hey, I just tried. here is the answer:

func callOnce(_ closure: sending () -> Void) {
    closure()
}

public class NotSendable {
    var str = ""
}

func send(_ value: NotSendable) {
}

func example() {
    let s = NotSendable()
    callOnce {
        send(s)
    }
}

you already transfer to callOnce. don't have to transfer again.

The point of send is to model some real API that truly is sending the value, though. Removing sending from that function is no longer the same problem at all.

(The point of callOnce is also to model some real API that I don't have much control over, but often more than with send)

1 Like

I don't know what you are talking about.

import os

public struct NotSendable {}

func example() {
    let s = NotSendable()
    let lock = OSAllocatedUnfairLock(initialState: ())
    lock.withLockUnchecked {
        _ = Task.detached {
//               `- error: task-isolated value of type '() async -> ()'
//                  passed as a strongly transferred parameter; 
//                  later accesses could race
            print(s)
        }
    }
}
  • I can't change Task.detached to not be sending
  • I can't change withLockUnchecked (but I can reimplement OSAllocatedUnfairLock, which gives some flexibility)

This isn't the only situation I've come across this problem in, though.