What does @available(*, unavailable) Sendable actually do?

this type is obviously not Sendable:

public final
class NonSendable
{
    var x:Int = 0

    init()
    {
    }
}

but i can still pass instances of it to async functions from inside an actor-isolated method, if the instance is a local variable and not shared by the actor.

func doSomethingAsyncWithNonSendable(_ instance:NonSendable) async
{
}

actor A
{
    init()
    {
    }

    func test() async
    {
        let instance:NonSendable = .init()
        await doSomethingAsyncWithNonSendable(instance)
    }
}

but if i am more explicit about the non-sendability of NonSendable:

@available(*, unavailable)
extension NonSendable:Sendable
{
}

all of a sudden, this produces a compiler warning:

    func test() async
    {
        let instance:NonSendable = .init()
        await doSomethingAsyncWithNonSendable(instance)
    }
warning: non-sendable type 'NonSendable' exiting actor-isolated context 
in call to non-isolated global function 'doSomethingAsyncWithNonSendable' 
cannot cross actor boundary
        await doSomethingAsyncWithNonSendable(instance)
              ^

is this a bug? or does @available(*, unavailable) actually mean something different from saying the type is merely not Sendable?

2 Likes

See SE-0337. Because most modules have not yet been annotated for sendability, the current default sendability checking mode only produces warnings for things which have been explicitly annotated.

-strict-concurrency=complete switches to the Swift 6 mode where anything not marked as Sendable is considered non-Sendable.

1 Like

The extension marks the type as “explicitly non-Sendable”, so the compiler is more aggressive about telling you about it not being Sendable. Basically, it distinguishes between “we’ve looked at this type and it’s definitely not Sendable” and “we haven’t looked at this type so we don’t know if it’s Sendable”. In a 100% Swift 6 world, that difference won’t really matter anymore, but in a world full of Swift 5 code, it still does.

(Hmm, @nonSendable would make a good peer macro…)

4 Likes

okay, then assuming the compiler is right, then what is the problem with:


actor A
{
    init()
    {
    }

    func test() async
    {
        let instance:NonSendable = .init()
        await doSomethingAsyncWithNonSendable(instance)
    }
}

then? where is the race condition? the instance isn't shared by the actor, it is a local variable.

You don’t know (with only local reasoning) whether constructing the NonInstance has escaped it somewhere, such as by setting up a repeating timer.

1 Like

why is it that i can do this within a nonisolated actor method, and call it from an isolated method, but i cannot inline the nonisolated method into the isolated method?

1 Like

In settling upon a syntax for noncopyable types, it was contemplated that it should be generalizable to ~Sendable on the primary type declaration to suppress a presumption to the contrary. If we can do that, we shouldn’t need a macro to generate an unavailable extension in order to accomplish this.

2 Likes

one of the benefits of @available(*, unavailable) is i can add an explanation to the message field that will be printed by the compiler:

@available(*, unavailable, message: "sessions have reference semantics")
extension Mongo.Session:Sendable
{
}

Good point. However, is this message displayed at the point when a concurrency warning is displayed? Even if it were displayed, is it actionable information? If a type isn’t Sendable, the reason for that isn’t something that an end user can do anything about.

(Incidentally, types with reference semantics can nonetheless be Sendable, so that can’t be actually the full story.)

when i get a “Sendable” warning, my first thought is usually not “there is something wrong with this code”, my first thought is usually “whoever wrote this library (which is sometimes me) forgot to annotate this type with Sendable”. so it saves me time if i am told the lack of Sendable is intentional and not just a missing annotation.

but that’s all moot because (although i swear i’ve seen the message get printed before) i could not get it to show up on 5.8 when compiling a quick experiment that misuses the type. but maybe it should?

a MongoDB session is stateful, on both the client and server sides. even if it were made threadsafe on the client side, it would still be incorrect usage.

i thought about this some more and i think i finally understand why we can’t inline non-isolated stuff into isolated contexts, because when we declare a reference in an isolated context, it has the right to escape its original concurrency domain via assignment to shared actor state.

extension MyActor
{
    func isolated()
    {
        let session:Mongo.Session = .init()

        self.sessions.append(session)
    }
}

and the right to escape to the shared actor state is mutually exclusive with the right to escape to some external async task, because when the async call suspends, the actor doesn’t wait for it to return, it starts executing something else in its queue, and now session exists in two different concurrency timelines.

but if we declare a reference in a non-isolated context, it doesn’t have the right to escape to self, so it is fine to pass it to another async function.

extension MyActor
{
    nonisolated
    func notIsolated()
    {
        let session:Mongo.Session = .init()

        self.sessions.append(session)
//      ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ not allowed
    }
}

i wonder if what we need is some kind of nonisolated do, like:

extension MyActor
{
    func isolated() async throws
    {
        nonisolated do
        {
            let session:Mongo.Session = .init()

            try await session.doSomething()
        }
    }
}