Types need to be sendable to be able to safely accessed across concurrency domains.
For a unstructured task / detached task, the closure needs to be sendable
For a closure to be sendable all the types in the closure (parameter and return types need to be sendable)
@Sendable attribute wouldn't be required (or wouldn't make a difference) for a closure that only contains sendable types.
Questions
Is my understanding correct?
When non-sendable types are used in @Sendable closure, what happens? (It is even allowed to be modified)
Is there more documentation around this? I found Apple Developer Documentation but uses them only with sendable types.
@Sendable attribute:
I tried using @Sendable attribute on a closure containing non-sendable type
Note:
There were no compiler warnings or errors even when Build Setting > Strict Concurrency Checking > Complete was set.
Code
import Foundation
class Car {
var name: String
init(name: String) {
print("car being created")
self.name = name
}
}
func f1(closure: @Sendable @escaping (Car) -> ()) {
Task {
let car = Car(name: "aaa")
print("original car = \(ObjectIdentifier(car)), car.name = \(car.name)")
closure(car)
}
}
f1 { car in
print("f1 car = \(ObjectIdentifier(car)), car.name = \(car.name)")
car.name = "bbb"
print("f1 car = \(ObjectIdentifier(car)), car.name = \(car.name)") //allowed to modify
}
RunLoop.main.run()
Output
car being created
original car = ObjectIdentifier(0x000060000022f020), car.name = aaa
f1 car = ObjectIdentifier(0x000060000022f020), car.name = aaa
f1 car = ObjectIdentifier(0x000060000022f020), car.name = bbb
A closure can be @Sendableeven if its parameter and return types are not sendable. What @sendable implies is only that the closure must not close over non-Sendable types. It's totally fine for this closure to be passed a non-Sendable type as an argument, or to return one.
A closure is conceptually a special kind of function. A regular function is a straightforward computation over its inputs, returning its outputs. A closure is augmented by having what we often call a "closure context", which is some collection of data that the closure can access. This data is derived from the scope in which the closure is declared.
We can use this as an example:
let x = 1
let y = 2
let myArray = [1, 2, 3, 4]
myArray.map { $0 + x }
In this example, the closure is syntactically the region of code that reads { $0 + x }. The type of this closure is (Int) -> Int: that is, it accepts one argument of type Int and returns an argument of type Int.
However, this is not sufficient to describe this closure fully, because this closure contains a reference to a variable x that was not defined within the closure! This variable forms part of what is called the "closure context", and we would say that "the closure passed to mapcloses overx. Importantly, closures do not close over everything that is in scope where they are defined: our closure above does not close over y.
In this instance, then, if map required a @Sendable closure then it would only be allowed to have a closure context that contained Sendable types. The arguments and return values are covered separately, and are not required to be Sendable.
That is a very clear explanation, I had completely gotten it wrong.
Now my understanding
So sendable closure should not close over (capture by reference) any variable (whether the type of the variable is sendable or not (eg. Int))
It is fine to access the constants
class A {
var price1 = 10
func f1() {
var price2 = 20
let price3 = 30
Task {
print(price1) //Capture of 'self' with non-sendable type 'A' in a `@Sendable` closure
print(price2) //Reference to captured var 'price2' in concurrently-executing code; this is an error in Swift 6
print(price3) //Valid since price3 is a constant
}
}
}
Note:
Used the Build Setting > Strict Concurrency Checking > Complete
In your case you are capturing a reference to price2, which is not the same as capturing price2 by value using a capture list: Task { [price2] in print(price2) }. To reiterate in your own words, both of these are true, non-overlapping statements:
As @lukasa has already explained, this is not true in the general case. This once confused me too and I therefore want to point out that there is actually one case where the sendability of your function arguments and return types matter. This is new in Swift 5.7 and part of the Sendability section in SE-338.
This proposal introduces a new rule:
the arguments and results of allasync calls must be Sendable unless:
the caller and callee are both known to be isolated to the same actor, or
the caller and callee are both known to be non-actor-isolated.
On example is when you are in an actor and call out to a global function and pass in a non-sendable argument e.g.:
class Counter {
var counter: Int = 0
func increment() {
counter += 1
}
}
/// Counter is **not** Sendable
@available(*, unavailable)
extension Counter: Sendable {}
actor Foo {
let counter = Counter()
func increment() async throws {
// Non-sendable type 'Counter' exiting actor-isolated context in call to non-isolated global function 'incrementAfterOneSecond(counter:)' cannot cross actor boundary
try await incrementAfterOneSecond(counter: counter)
}
}
@Sendable func incrementAfterOneSecond(counter: Counter) async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
counter.increment()
}
That's a good point, I think the following refers to async functions arguments / return types (not sendable closures).
As quoted above the reason could be because a non-sendable type is being accessed in non-isolated or different actor and that is not safe and is therefore not allowed (warning / error is thrown).