Isolation Assumptions

So then the answer to your question of “why wasn’t this promptly done?” seems obvious: because there has been no reason to assume it would be done at all.

huh? i was asking why patch releases for 5.9 were not made, not why 5.10 was not released.

That’s what I was answering. There’s no reason to assume that 5.9 patches would be made at all, much less “promptly”. Your question therefore comes off as fairly indignant.

It has always been the plan of record to stage in full strict concurrency checking in two phases: 1. basic actor isolation, and 2. full actor isolation. This plan was outlined in the concurrency roadmap long ago, and it has taken several years of development to reach the milestone of closing all known holes in the static data race checking model. It was well understood that closing these holes would require follow on language design proposals, as further communicated in the progress toward Swift 6 post from November from the Language Steering Group. The nature of these changes is not the same as "critical bug fixes", and it was clearly communicated that Swift 5.10 would contain these changes.

6 Likes

i don’t really like rehashing the history of the swift project, as i don’t think that’s productive. but there is some disconnect between what is optimal for Apple (a large corporation which has more flexibility and a deeper capacity to wait out supply chain disruptions) and smaller organizations that also depend on swift and don’t benefit from the same advantages as Apple.

i am pointing out that if the development of swift were not predominantly driven by a single, large corporation, that practices like tagging patch releases would be taken more seriously.

1 Like

When a Task { ... } is used in a context isolated to an actor instance, whether or not the task inherits the actor isolation depends on whether or not the actor value is captured, because the actor value must be captured in order to hop back to the actor after any suspension points. My understanding is the design decision was deliberate, and it was made to avoid implicitly capturing an isolated parameter when you didn't explicitly do so yourself. However, I believe at the time it wasn't understood that the decision had observable semantic implications because local non-Sendable state can still be accessed in a Task { ... } that's isolated to the actor. I completely agree that the current behavior is confusing, and we should change the behavior to always capture the isolated parameter.

7 Likes

i have misunderstood what is meant by “holes in the type checking”, as i was imagining something more akin to the runtime ARC bug that is also currently being tracked. i agree with your assessment that these are not critical bug fixes.

1 Like

I noticed a bug where Task { ... } did not inherit actor isolation when used in a function with an isolated parameter other than self even when the isolated parameter was explicitly captured:

// compiled with 5.10 under -strict-concurrency=complete

class NotSendable {}
actor MyActor{}

func testNonSendableCaptures(ns: NotSendable, a: isolated MyActor) {
  Task {
    _ = a
    _ = ns // warning: capture of 'ns' with non-sendable type 'NotSendable' in a `@Sendable` closure
  }
}

I've fixed this in [Concurrency] More precise modeling of `ActorIsolation` for isolated arguments and captures. by hborla · Pull Request #71143 · apple/swift · GitHub

5 Likes

I've put together a first draft of a real proposal. Thank you everyone for your help along the way!

The proposal differs pretty significantly from what was originally pitched. However, once it became clear that isolated parameters were nearly sufficient for solving the underlying problem, it seemed far more reasonable to just propose a small adjustment to them to get all the way there.

6 Likes

Very nice!

The proposed solution is to make this capture implicit when also capturing self, unless self is an actor type.

I would actually go one step further and propose that the Task initializer always inherits the enclosing isolated parameter (including both self in isolated actor methods and regular isolated parameters declared with the isolated parameter modifier), regardless of the captures in the task closure. This way, the rule is always consistent, and if programmers really want to not isolate that closure, we can add the ability to write nonisolated explicitly on closure expressions to deliberately prevent the inheritance.

8 Likes

Yes I agree with Holly here: it’ll be good for this to work consistently with just an isolated value — this way it’ll work with all kinds of contexts :+1: nice cleanup to Task{}.

3 Likes

I'm really glad you suggested this, because it really does make things easier to understand. I was hesitant to make such a change, but in the end it actually does not have a major impact on the overall design.

Proposal updated!

Edit: Hang on. I missed a critical part about the nonisolated keyword usage for a closure expression. Can you elaborate a little more here?

3 Likes

Sure! Consider this code:

actor MyActor {
  func inherit() {
    Task {
      // do some stuff but do not capture 'self'
    }
  }
}

With the new proposal, that Task will always be isolated to self. Say somebody wants to deliberately prevent that isolation inheritance, but they don't want to use Task.detached because they still want to inherit priority. There's no way to explicitly say that a closure is nonisolated so this code would have to be rewritten to use a local function that is nonisolated. Instead, we could allow nonisolated to be written in a closure signature:

actor MyActor {
  func inherit() {
    Task { nonisolated in
      // this task is nonisolated
    }
  }
}

I also think this can be a future direction of the proposal.

4 Likes

Proposal updated again, now with the suggested closure expression grammar changes needed.

I have posted an expanded and merged pitch here.

2 Likes

Going through the thread, I’ve got one more idea, which is related, but probably deserves a separate proposal.

So far, discussion have reached a stage where original problem is solved by passing isolating actor as a parameter.

But what if that is not possible. What if we want to infer the isolating actor from self. If reference is isolated to a connected region from the moment of allocation, it should be possible.

And it can be expressed using an initializer isolated to an actor parameter.

But we somehow need to pass that isolated actor parameter from initializer to the instance method.

My idea is to allow isolated keyword to be applied to stored read-only properties:

class NonSendableType {
    private var internalState = 0
    private let actor: isolated (any Actor)

    init(actor: isolated (any Actor)) {
        self.actor = actor
    }

    init(another: (any Actor)) {
        self.actor = another // Error: isolated stored property is initialized by a non-isolated value
    }
    
    func doSomeStuff() {
        // self is known to be isolated to self.actor
        // so current method must be isolated to self.actor too
	Task {
	    let value = await otherType.getAValue()

	    // hop back to self.actor
	    self.internalState += value // ok
        }
    }
}
1 Like

I think this is a fascinating direction to pursue! I think anything that offers greater flexibility around isolating non-Sendable types is worth exploring.

I do think a different discussion thread is warranted though, especially this one has now already been retired in favor of a much better-designed proposal.

2 Likes

Yeah, I think this is exactly what @Jumhyn was describing here:

I think it's an idea worth exploring, but we would probably need a way to preserve when you've isolated a non-Sendable stored property of an actor to self in that actor's initializer, so that calls to async functions on that non-Sendable value in isolated actor methods can statically understand that no isolation boundary is crossed.

2 Likes

+1 on the general case of being able to pass isolation to the class.

What I see as a potential cases from design perspective is that in order to allow that class should explicitly provide that ability with implementation. So if its your class, for each such case you need to add this actor dependency and think on how to pass it. If its class you have no control over implementation, it is impossible to address this at all.

So what I invision as a solution is to allow explicitly set isolation on instances of that class, at the current stage it is something like the following:

final class NonSendableType {
    // assertion just for the demonstration purposes
    let assertion: () -> Void

    func doSmth() async {
        assertion()
    }
}

@MainActor
struct Command {
    @isolated(MainActor.shared)
    let instance = NonSendableType { 
        MainActor.assertIsolated() 
    }

    func execute() {
        Task {
            await instance.doSmth()
        }
    }
}

I do not like isolation being expressed as part of a property, because it seems wrong to express isolation in that way, it's more like just this property being isolated... but hasn't came up with better idea so far.

But the end goal is to allow isolation to be - ideally, assumed, but at least to be explicitly specified - at the usage point.

We definitely could allow methods and properties to be isolated to a specific immutable stored property. I'm concerned that it might prove to be a deceptively large feature, though.

1 Like