Avoiding Sendable Warning for Unused Async Result

My team just ran into a scenario regarding Sendable checking that felt pretty surprising, so I wanted to confirm whether my expectations were well-founded.

We have a function isolated to a dedicated actor:

@globalActor
final class SomeDedicatedActor {
    actor Actor {}
    static let shared = Actor()
}

@SomeDedicatedActor
func f() async -> [Any] {
    // ...
}

We typically call f() from other functions isolated to @SomeDedicatedActor, but we have one call that we want to make from a different actor. We make this call purely for f's side effects, and don't care about the return result at all. I would expect that the following construction would be valid, but with Targeted/Complete Sendable checking, we get a warning:

@MainActor
func g() async {
    // ⚠️ Non-sendable type '[Any]' returned by implicitly asynchronous call
    // to global actor 'SomeDedicatedActor'-isolated function cannot cross
    // actor boundary
    _ = await f()
}

This warning makes complete sense if we were expecting to use the result here — [Any] isn't, and can't be, Sendable; but we're not, and don't.

Is it reasonable to assume that Swift would elide transferring the result across actor boundaries if it won't be used?

  • If so, then is this just a spurious warning, or is this construction actually unsafe right now (because the value does forcibly cross actor boundaries)?
  • If not, is there a reason the result must be transferred across boundaries?

Happy to file an issue for this, but I wanted to make sure my mental model is seeing this reasonably, and there isn't anything I'm missing.


Context

f() is a code-genned function that wraps an interface for a different language. Theoretically, we could either:

  1. Write a wrapper function that throws away the result type internally:

    @SomeDedicatedActor
    func f2() async {
        _ = await f()
    }
    

    Or,

  2. Add an annotation to the code generator to generate the equivalent of f2() for us

Both cases feel like overkill for what we need to do, and coming up with a reasonable name for f2() is annoying in practice.

The Workaround

For now, the construction we're using looks like

@MainActor
func g() async {
    await Task { @SomeDedicatedActor
        _ = await f()
    }.value
}

This is explicit, and correct to the semantics (to the best of my knowledge), but really sticks out in practice, and feels unnecessary.

3 Likes

I don't think anything is elided tbh but I may be wrong -- the value is actually returned from the actor...

This is definitely worth a ticket to improve diagnostics, in my mind at least.

As for the workaround: beware since that causes subtle differences in ordering; The task starts on the global pool, and only then hops to the target actor -- so there's an extra hop here which may be good to be aware of.

Yeah, a cursory reading of the SILGen seemed to confirm, but my SIL knowledge is very limited, so this was low-confidence signal.

Yeah, it feels unfortunate — though at least in our case, it's unlikely to cause any noticeable difference. The code itself is (almost) as simple as the example code above, and we don't have any ordering concerns.

Filed Sendable warning produced for unused non-Sendable result value · Issue #65463 · apple/swift · GitHub

I did not try, but... what about discarding the result from @SomeDedicatedActor?

@SomeDedicatedActor
func f_ignoringResult() async {
    _ = await f()
}

@MainActor
func g() async {
    await f_ignoringResult() // no result, no pain?
}

This workaround (if it works) does not need any extra task, extra hop, or extra ordering concern.

Yeah, this definitely works, and in general, would be a more direct workaround — though in my specific case, would be a little more frustrating to use. (See the "Context" disclosure in my original post, in case you missed it)

1 Like

:+1: I missed it, sorry. Maybe some other users will be happy to find this alternative technique under their plain sight ;-)

1 Like

If the value is non-Sendable, it could have deinit effects that are only allowed to happen in its original actor. So ignoring the result really would have to “avoid returning” the result from the actor, in that it would have to destroy the value before leaving the actor’s isolation context. That seems fairly subtle to me, but maybe it’s consistent with writing an isolated method that returns a non-Sendable type at all, since you’ll never be able to use the result from outside the isolation.

3 Likes

I had this same thought as I filed the issue above (and should have called it out in this thread) — I can see now why this might not be done today, because deinit may need to happen on the original actor; but it would be extremely convenient if it were possible for the compiler to do this automatically when the result was explicitly discarded. I can't, off the top of my head, think of a reason why that would necessarily be problematic (since it's the only safe way to destroy the value anyway).

I think if you got an error like “non-Sendable result of isolated method blah must be discarded” then it becomes explicit enough to make me happy. :-) Implementation-wise, I figure the task that gets onto the actor from such a call site will include the destruction of the value and have a return type of Void at the SIL level, basically synthesizing the manual helper function shown above.

1 Like