Isolation Assumptions

In the general case this is pretty much true (and as Holly notes in the issue, the specific situation there should now produce a diagnostic). Region-based isolation (still in revision, not yet accepted) provides a narrow carve-out: there are situations where it can be proven that the local isolation domain cannot actually access any of the state within a non-sendable type. Such a value is eligible to be transferred across isolation domains because there is no danger of concurrent access. If the user attempts to do anything with the value before or after the transfer occurs that would cause the isolation region to 'merge' with the local isolation domain, we'd no longer be able to transfer the value and a diagnostic would be produced.

2 Likes

Is it possible that this proposal is actually a bug report in disguise?

class NonSendableType {
    private var internalState = 0

    func doSomeStuff(on actor: isolated any Actor) {
        Task {
            let value = await getAValue()

            self.internalState += value // non-Sendable self captured here
        }
    }
}

This code currently produces a warning about capturing self. Could it be that this is actually safe?

I would expect the task to be associated with actor, and therefore could be any isolation domain at runtime. So I think the compiler diagnostic is correct - you're essentially passing self to actor's isolation domain.

My assumption is that you can't access self at all inside doSomeStuff; it's should have nothing to do with subtasking.

If fact, no! The 5.9 compiler does not produce warnings about accessing self inside of this function body. This does not seem like a contradiction. In order for this function to be callable, self must already have been isolated to actor. Right?

1 Like

The code is not (necessary) safe.

Everything works fine when we only use one actor to call doSomeStuff, but your code does not express that requirement. It just requires the caller to provide an actor. The code below should fail if you try to execute it:

actor MyActor {}

func getAValue() async -> String { "4" }

class NonSendableType {
    private var internalState: [String] = []

    func doSomeStuff(on actor: isolated any Actor) {
        Task {
            self.internalState.append(await getAValue())
        }
    }
}

Task {
    let nst = NonSendableType()
    let a1 = MyActor()
    let a2 = MyActor()
    
    await withDiscardingTaskGroup { group in
        for x in 0...1000 {
            group.addTask { await nst.doSomeStuff(on: a1) }
        }
        for x in 0...1000 {
            group.addTask { await nst.doSomeStuff(on: a2) }
        }
    }
}

I get no warnings with default settings, but enabling strict concurrency checking in Xcode does give the expected warning that capturing self in this way is unsafe.

2 Likes

Ah ha! You are correct there are no warnings produced for this code with the 5.9 compiler. But I believe the bug mentioned above establishes that this code is actually unsafe and will be correctly detected as such by future compilers.

At issue are the lines with the form:

await nst.doSomeStuff(on: a1)

These require that nst cross isolation boundaries, which cannot happen because the type is not Sendable.

@mattie going back to your initial post asking if we could add

isolated class NonSendableType { }

to the language. If I understand you correctly this is essentially the same as an actor:

actor NonSendableType { }

Again if I understand your question correctly, what your are actually asking for is:

  1. Can we add the ability to have / call synchronous functions on actors.

And perhaps:

  1. Can we have actors subclass other actors like it is possible with a class.

Both of these questions have been discussed before and it is my understand that:

The ability to subclass an actor could be added to Swift in the future, but only if real world projects proved a need for this. Not allowing actor subclass keeps the actor implementation simpler.

It would be possible to add the ability to call synchronous functions on actors, but the swift language group have stated this will never happen.

I think we can all agree, that if you could implement synchronous functions on actors with no downsides, it would be an extremely convenient addition to the language. The downside is that if we added this, it will be possible (and quite easy) to deadlock all or parts of your code.

It has been one of the key design principles of Swift structured concurrency (like actors) that your code is guaranteed not to deadlock.

1 Like

nst never crosses an isolation boundary, it never leaves the Task where it is created and the nst instance is never passed to another function / object.

the actor instances are passed to the nst object, but that is fine, they are sendable.

EDIT: I guess you are referring to everything inside withDiscardingTaskGroup. You are right nst does cross an isolation boundary there...

1 Like

I'm really struggling to understand how this could be. If nst does not cross a boundary, why is the await required? How is this code you wrote different from what I posted in isolated parameters for non-Sendable seems to be missing warnings Ā· Issue #71020 Ā· apple/swift Ā· GitHub ?

This is a direction I had absolutely not considered. I have not followed any of the discussions around the future directions of actors, but I'm fascinated by the possibilities.

I think my original intention wasn't clear enough. Honestly, I'm confused even by then idea of actor NonSendableType. By making it an actor, wouldn't it become Sendable? And, if it does, then this type could be passed around freely to other isolation domains? This is exactly the opposite of what I want to express! I want to tell the compiler that this type must never cross domains (non-Sendable achieves this) but that it also can know it is being isolated to something.

I still think isolated parameters are a very promising option for achieving this goal, which is why I've been focusing on them so much.

Yes, actors are always sendable and could be passed to other isolation domains.

This to me is a contradiction, I am not sure I understand exactly what you are trying to achieve.

Assuming region-based-isolation is accepted into the language it would become legal to parse non-sendable objects across isolation domains in a subset of cases where the compiler can prove it is safe. So I am not sure a NonSendableType is a the best way to express a guarantee that a class does not crosses isolation domains.

What I don't get is this. If we had some way to mark a class as MustNeverCrossIsolationDomains, why do you care about it being isolated to something. If the class can't cross an isolation boundary it will always be accessed from the context where it was created. I assume you are trying to avoid data races or avoiding mutual access to mutable state, but neither of these can happen if the class can't cross isolation domains in the first place.

If we had a MustNeverCrossIsolationDomains feature I think it would come with some severe limitations:

  1. A global variable can't be marked as MustNeverCrossIsolationDomains. A global variable can be accessed from any isolation domain.
  2. If an object / class have an instance variable of type MustNeverCrossIsolationDomains, that outer object must also be marked as MustNeverCrossIsolationDomains, because if the outer object can be accessed from multiple isolation domains so can the inner object even if it is private to the class.

With those limits the use cases I can see is this:

A MustNeverCrossIsolationDomains object can be created as a temporary variable inside a single context / function call, assuming that object is deallocated at the end of that context.

An MustNeverCrossIsolationDomains object can be a private instance variable inside an actor. As an actor is a single isolation domain, so this is fine. As the private object is not sendable the actor can not send the object to other actors or isolation domains.

Again I fail to see exactly what you are trying to achieve here. If the question is "I want to have the protection of being in an isolation domain, but I want to provide synchronous functions to access the domain." The answer is, that would be possible, but such a feature would be vulnerable to potential deadlocks in the code.

2 Likes

I promise that I am trying to understand what you are saying. Can you first help me understand the distinction between this question and what happens when you annotate something with a global actor?

When testing out code examples against -strict-concurrency=complete, I recommend using a Swift 5.10 development snapshot. Compiler versions <= Swift 5.9 have many holes in Sendable and actor isolation checking; Swift 5.10 closes all known holes in the static data-race safety model.

6 Likes

I can try, but you might want to simply read the Global Actor Proposal.

When you annotate a variable / function / type with a global actor, that code gets isolated to that actors isolation domain. This gives the object much the same properties as if it were an Actor, but also the same restrictions.

  1. Only a single thread can execute code inside the (global) actors isolation domain at the same time.

  2. It is only possible to access code annotated with a global actor synchronously if the calling code is annotated with the same global actor (or in another way is in the same isolation domain). Access from a different isolation domain must use async/await etc.

In short annotating code with a global actor makes the code safe to access from code in a different isolation domain, or from code not in an isolation domain.

Having the concept of global actors gives us more freedom to organise code while restricting it to an isolation domain:

  • Cloasures and global variables can be marked with a global actor.

  • Multiple classes can share the same isolation domain (global actor). This can be convenient if the classes often call each other.

Marking a class with a global actor, fundamentally serves the same purpose as placing code inside an actor: To make it safe to access this code from different isolation domains by preventing mutual access to mutable state.

2 Likes

Again, thank you so much for all the feedback here. I want to try to consolidate the possible solutions, along with some comnmentary that I hope may be useful for finding a path forward.

First, to recap, the core problem:

class NonSendableType {
    func doStuff() {
        self.mutableState // this is fine

        Task {
             self.mutableState // this is not
        }
    }
}

The most straightforward option is just to use global actor isolation. @AronL did an excellent job of outlining how it works and why it's useful, and that's what I currently use to solve this problem.

// exhibit A
@MainActor
class NonSendableType {
    func doStuff() {
        self.mutableState // this is fine

        // actor inheritance!
        Task {
             self.mutableState // now things are fine too!
        }
    }
}

However, I really want to contrast this with other options. And I think looking at the behavior of this version compared to other options helps to make the case for change.

Using a global actor achieves the isolation and actor inheritance I'm after, but also ties this type to one particular global actor. I do want the isolation and actor inheritance, but I want only that. Which actor provides these isn't important to this implemenation.

Almost immediately, @FranzBusch zeroed right in on a great option: isolate to an actor via an isolated parameter. Maximum flexibility! Zero changes to the language! He did also suggest making the method async, but I really want to leave that aside. Because even if it is a good suggestion, it changes the problem.

class NonSendableType {
    func doStuff(isolatedTo actor: isolated any Actor) {
        self.mutableState // this is fine...

        // ... but no actor inheritance so...
        Task {
            self.mutableState // this is not?
        }
    }
}

I'm going to put these two options really close together, and even move the global actorness, to really highlight the difference.

class NonSendableType {
    @MainActor
    func globalActor() {
        Task {
            // ok
        }
    }

    func isolatedParameter(_ actor: isolated any Actor) {
        Task {
            // somehow not?
        }
    }
}

As of the most recent snapshot of Swift 5.10, globalActor() is ok but isolatedParameter(_:) is not. Should these two not behave the same? If not, why exactly are they different? In both cases, the compiler knows the specific actor instance being used for isolation, doesn't it?

Now, if they should be the same and they are both safe and they remain safe in the Region-based isolation world, then my initial proposal here is unnecessary.

I think the above statement is true. I believe @FranzBusch's suggestion is fundamentally the right approach here, but the compiler is incorrectly not applying actor inheritance to isolated parameters when it really should.

3 Likes

at my company there is a strict policy against using nightlies, due to disastrous experiences using nightlies in the past. if the 5.9 toolchains ā€œhave many holesā€, why were the fixes not promptly cherry-picked into 5.9 patch releases?

Because the fixes will be in 5.10?

5.10 is a minor release that will arrive on a minor release timetable. the purpose of patch releases is to fix critical issues promptly without creating pressure to ship minors prematurely, which would only exacerbate the systemic issue. (imagine if 5.5 didnā€™t get patch releases, and everyone in 2021 had to wait for 5.6 to come out!)

Are you describing the Swift release process as it exists, or how you wish it to be?

i donā€™t work at Apple, but i am not aware of any plans to accelerate the release of 5.10 for this reason, and iā€™m not sure if such a thing would be wise in the first place.