Why must Task's return value be Sendable even if Task and caller are isolated on same actor?

class Book {
    var title: String = "ABC"
}

actor BookShelf {
    var book = Book()
    func getBook() -> Book  {
        return book
    }
    func test() {
        Task { // Warning: Type 'Book' does not conform to the 'Sendable' protocol
            let b = getBook()
            return b
        }
    }
}

With the -warn-concurrency and strict concurrency checking enabled, this emits the warning

Type 'Book' does not conform to the 'Sendable' protocol

I know that Task's are mostly (always?) required to return Sendable Values. But my question is:
Why is this necessary in a case like this, i.e. when Task and the recipient of the return value are isolated on the same actor?

Is it simply a limitation of the static analysis as of right now, in which no distinction between same-actor-tasks and others is made? Or is there a way this could actually cause a data race?

If the return value tried to escape the actor-isolation, I assume it would be caught at that time, since only Sendables can cross actor boundaries. Might there be a possibility of another Actor gaining a handle on the same Task without that ringing the alarm bells of cross-actor-passing?

The task might run on the same actor, yes, but you're still sharing state (the Book class) outside of actor boundaries. I think this is a valid scenario for a warning, as you could indeed pass the task as a variable to somewhere else, and use the result outside of the actor there. You could then access the information from many threads at the same time, potentially causing a crash.

I think it's even a valid warning if you don't consider that, and purely consider the fact that a class like this does not guarantee being free of race conditions.

Sendable is a tool for enforcing the high-level rule that some values must not be used from multiple contexts concurrently. Since contexts that are isolated to the same actor cannot run concurrently, there should be no problem with sharing actor-isolated values between them. We could, for example, have a general rule that non-Sendable values can be captured in a Sendable closure (generally forbidden) if the closure and outer function are both isolated to the same actor.

In principle, this same logic applies to task results. A Task that produces a non-Sendable value from an actor-isolated function must be producing a value that is just isolated to that actor, and so other functions that are isolated to the same actor ought to be able to pull the value out of that Task. But that would require the type/isolation system to track the isolation of the Task value itself, which is not something it does — Tasks are always Sendable. And allowing non-Sendable Tasks would require some very special-case reasoning about actor isolation, and I'm not convinced it wouldn't create major holes in general.

However, if you're creating a Task and then awaiting it later in the same function, you can probably just use async let, which should bbe able to side-step these problems because of the known structural relationship between the two tasks and because the child task is not exposed in a first-class way as a Task.

3 Likes

Trying the same with async let seems to sidestep the Task-related warning but gives an actor-crossing warning instead, because async-let-Tasks don't inherit the callers actors, iirc.

class Book {
    var title: String = "ABC"
}

actor BookShelf {
    var book = Book()
    func getBook() -> Book {
        return book
    }
    func test() async {
        async let b = getBook() // Warning: Non-sendable type 'Book' returned by implicitly asynchronous call to actor-isolated instance method 'getBook()' cannot cross actor boundary
    }
}

Ah, that's unfortunate. We should be able to figure out a way to express that.

3 Likes