@Sendable attribute in closure

Hi,

I have some confusion around sendable closures

My understanding (could be wrong):

  1. Types need to be sendable to be able to safely accessed across concurrency domains.
  2. For a unstructured task / detached task, the closure needs to be sendable
  3. For a closure to be sendable all the types in the closure (parameter and return types need to be sendable)
  4. @Sendable attribute wouldn't be required (or wouldn't make a difference) for a closure that only contains sendable types.

Questions

  1. Is my understanding correct?
  2. When non-sendable types are used in @Sendable closure, what happens? (It is even allowed to be modified)
  3. 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

No. Specifically this part is wrong:

A closure can be @Sendable even 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.

1 Like

Thanks a lot @lukasa, I have some doubts.

What did you mean by "close" in the following sentence?

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 map closes over x. 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.

5 Likes

Thanks a ton! @lukasa

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

It would be awesome if your explanation was included in the documentation for Sendable Apple Developer Documentation

I have filed a feedback FB10786446

We can add one extra piece of understanding: the closure should not close over (capture by value) any non-Sendable type.

1 Like

But In our case price2 was a sendable type (Int) right?

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:

1 Like

Thanks a lot @lukasa and @xwu for patiently answering my doubts.

I understand better now.

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 all async 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()
}
2 Likes

Thanks @dnadoba

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).

1 Like