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:
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?
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!
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.
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.
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)
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.