Am I doing approachable concurrency right? This asymmetry feels weird

Hey folks, I'm working with Swift 6.2's approachable concurrency and I'm really enjoying the changes! I have both @MainActor set as the default isolation and nonisolated(nonsending) as the default for async functions.

What feels weird: When I mark an async function with @concurrent, any async functions I call from it automatically run on the caller's actor (an actor in the background in this case). But sync functions still default to @MainActor and need an explicit nonisolated annotation. I thought approachable concurrency meant both would just use whatever actor context they're called from? Maybe I'm not doing this right?

Here's an example of what I mean:

import HealthKit

struct HealthKitService {
    let store = HKHealthStore()

    // Async function DOES NOT NEED explicit `nonisolated(nonsending)` to run on caller's actor 
    private func isAuthorizationRequestUnnecessary(for type: HKQuantityType) async throws -> Bool {
        return try await self.store.statusForAuthorizationRequest(
            toShare: Set([type]),
            read: Set([type]),
        ) == .unnecessary
    }

    @concurrent
    func fetchStepStatistics() async throws -> [HKStatistics] {
        let stepType = HKQuantityType(.stepCount)

        guard try await self.isAuthorizationRequestUnnecessary(for: stepType) else {
            throw AuthorizationRequestNecessaryError()	
        }

        // ... more code ...
    }

    // Non-async function DOES NEED explicit `nonisolated` to run on caller's actor
    nonisolated
    private func isSharingAuthorized(for type: HKQuantityType) -> Bool {
        return self.store.authorizationStatus(for: type) == .sharingAuthorized
    }

    @concurrent
    func createStepSample(date: Date, value: Double) async throws -> Void {
        let stepType = HKQuantityType(.stepCount)

        guard self.isSharingAuthorized(for: stepType) else {
            throw AuthorizationRequestNecessaryError()
        }

        // ... more code ...
    }
}

When isAuthorizationRequestUnnecessary (async) is called from a @concurrent function, it just works—it runs on the caller's actor automatically. But if I remove nonisolated from isSharingAuthorized (sync) and try to call it from my @concurrent function, I get:

Main actor-isolated instance method 'isSharingAuthorized(for:)' cannot be called from outside of the actor

I get that there's a compiler flag that makes async functions use the caller's actor, but I thought the whole point of approachable concurrency was that functions would default to using whatever actor context they're called from? It just feels a bit asymmetric that async functions get this behavior but sync functions still need the explicit annotation.

Am I missing something here, or is this just how it works?

2 Likes

Is defaulting to @MainActor really approachable? Like, the error is exactly what you’d get if you wrote @MainActor HealthKitService in the first place. If the struct is explicitly actor-isolated, then you explicitly requested instance functions be actor-isolated, contrary to everyone’s mental model of structs but probably for good reasons.

Having every struct marked @MainActor feels like too much to me.

2 Likes

Well, I guess that’s why these are two separate options.

I’ve only briefly played around with these new settings, but had a similar feeling of “weirdness” in my eternal toy project. In my case I’d even say it was the @MainActor default isolation in itself that felt, well, “weird”. Until now I got used to explicitly marking my types in terms of on what isolation they belong, and, if I didn’t intend such an explicit definition, to leave it unspecified, i.e. nonisolated (and thus more “general purpose” in regards to concurrency).

However, I believe this is mostly because, ultimately, I already have “approached” Swift structured concurrency. I learned it (I dare claim) without the new design, a design which is meant to help newcomers to this aspect of the language. If I understand it correctly, @Jerry1144 basically feels similarly about this.

So we’re in a weird “in-between land” now as we’re already used to thinking about what all these things do and design our types with that in mind. Naturally, the “helper” then gets in the way trying to do its job.

In the concrete example I’d say that you’re basically reproducing “baby’s first background task”. The new flags, when set together, are kind of designed for people who would write everything on the main actor at first, i.e. not even use the @concurrent keyword. Once performance issues “guide” them to it, they slowly learn “oh, I was on the main actor the entire time, but now I need to actually start thinking about which parts of my code could also run on another actor”. They then mark these parts explicitly as nonisolated as needed. Of course this also shows a fundamental difference between async and sync functions (namely that until now they were implicitly nonisolated(nonsending) and @MainActor respectively), but this, probably, doesn’t feel weird, it just feels new.

Of course there’s the question in general why “@MainActor as default” excludes async functions and leaves them implicitly nonisolated(nonsending), but I think that’s not really relevant in this context (and was ultimately the right choice for the flag… I’d hate it if every async function were automatically @MainActor by default, too…).

Edit: Well, that last part was a brain fart, thanks for clarifying this below, @robert.ryan!

1 Like

@ryansobol – If you forgive me, I think you may have drawn some incorrect conclusions.

Yep. It is an async function that you await.

No it doesn’t. fetchStepStatistics is not isolated to any actor (because it is @concurrent) and isAuthorizationRequestUnnecessary is isolated to the main actor (because you have “Default actor isolation” of “MainActor”, and therefore HealthKitService is isolated to the main actor).

So, isAuthorizationRequestUnnecessary does not “run on caller's actor”: Rather it runs within the main actor, because HealthKitService is isolated to the main actor.

Yep, and the first suggestion below the warning is “Insert await”.

If you remove nonisolated from isSharingAuthorized, it will now be isolated to the main actor (due to the “Default actor isolation” build setting of “MainActor”). And when you call it from createStepSample (which is not isolated to the main actor because it is @concurrent), the compiler is letting you know that you must await a call to the actor-isolated method, isSharingAuthorized.


With “nonisolated(nonsending) by default”, if a function is nonisolated, whether async or not, will run on the caller’s actor; otherwise it runs on the isolation the type in which it is defined.

And if your default isolation is “MainActor”, then that function lacking any nonisolated or @concurrent qualifier will obviously be isolated to the main actor.

No, for functions of an actor-isolated type, both async and synchronous renditions need nonisolated qualifier if you want them to run without the context hop from the caller’s isolation.

If anything, the opposite is true: With “nonisolated(nonsending) by default” turned off (the old behavior), we had the asymmetry where a nonisolated synchronous function ran on the caller’s thread, but nonisolated async function always jumped off to a generic executor. By turning on “nonisolated(nonsending) by default”, we now get a more consistent behavior, that nonisolated function (async or not) will avoid any context hop, and we use @concurrent when we explicitly want to hop to the generic executor.

2 Likes

For many apps, which don’t need to do anything other than call existing async API, it eliminates a ton of concerns (Sendability, RBI, etc.).

I agree that a discussion about actor isolation of a struct is a bit curious.

But, to my eye, in this example, the question is less “should this struct be actor-isolated” than “should this service be a struct at all”. Do you really want value-semantics for a service object? You generally don’t ever want copies of a service object.