While reading closure isolation control proposal, I notice some examples are written in this style: a non-isolated class with actor-isolated methods. For example:
class NonSendableType {
@MainActor
func globalActor() {
Task {
// accessing self okay
}
}
func isolatedParameter(_ actor: isolated any Actor) {
Task {
// not okay to access self
}
}
}
I believe I must have written similar code before. But now that I have read more about swift concurrency, I doubt if that's a good style. Take globalActor as an example, it's isolated in MainActor, which means it expects a self parameter that is MainActor isolated. That in turn means the self passed to it is either created in MainActor (so the value inherits the isolation) or it's created in a disconnected domain of another isolation domain and then sent to MainActor. My concern is none of these is intuitative. Actually when I first saw the code, I felt confused (if the value was non-isolated, how could globalActor, a MainActor method, access it?). I thought for a moment and then figured it out. So in my opnion the code's readability isn't good.
I wonder if that's valid concern, or is it just me? (I don't use concurrency often and always find it's a bit tricky to understand how non-isolated value play with other pieces of swift concurrency).
What's more, even if we put the code readability issue aside, I still wonder if it's typical (or unavoidable) for a non-isolated value to have actor-isolated methods in real world application design? I doubt it because many types in stdlib is non-isolated and as far as I know none of them have actor-isolated methods. In my opinon, a non-isolated value with only non-isolated methods is a far more simple model than the above approach.
I wonder how the folks in the forum think about it? Thanks.
PS: I remember someone seemed to mention a similar point (it's not a good design for a non-isolated value to have actor isolated method) in this long thread, though I forgot the exact post.
I think if you're writing fresh code with no external constraints that looks like this you should probably rethink your design, but I've had it be the most straightforward way to retrofit existing code that did weird things quite a few times.
I would consider this to be anti-pattern, yes. I'd also argue that it always was, even before Swift Concurrency, just less prominent and maybe a bit harder to address. Code that should be running in different isolation within the same type is hard to follow and hold model of how it works, it also most certainly mixes up too much responsibilities.
Global actors IMHO aren't designed to make this isolation of one property or function on a daily basis (eventually in one-two places consciously placed they might make sense, though), but rather to design isolation across types: with actors defining the isolation, this reduces the need to put large complex logic that in OO-style would benefit from several types, into one actor, but rather mark with global actor group of types (classes, structs, enums, etc.) so they become part of this larger actor.
It might be typical for code-bases that were existed long before Swift Concurrency. I have been attempting to migrate such a project lately to Swift 6, and while most of the code I was able to address, with one submodule I gave up: in GCD design it looks fine, a bit over-engineered, but fine; when you throw concurrency checks there you realise how messy isolations are as one type could've had 4 to 5 different isolations, and its just impossible to get all them working in alignment within one type, it just doesn't make sense or you can't simply define to what isolation some property should belong. Of course, it also uncovers some concurrency issues with this code that were hidden. But after a few days of struggle, it seems that rewrite half of that isolation mess is much simpler, I still figuring out how to address this in a much less intrusive way.
Thanks for all replies. I later realized that it's more complicated for non-isolated asynchronous functions. They hop to global executor by default (see SE-0338), but user can customize the behavior with isolated parameter (see SE-0420). While isolated parameter is a general mechanism, I think its main purpose is to make a non-isolated asynchronous function stick to the isolation of the context.
So I think, for a non-isolated value, a simple design might be:
Its synchronous methods should be non-isolated and hence inherit the isolation of the context.
Its asynchronous methods should have isolated parameter with #isolation default value and hence inherit the isolation of the context, unless for performance reason it uses the default behavior of SE-0338.
Sometimes I think saying a type is non-isolated is a bit meaningless because it gives very few information. For example:
a non-isolated type's instance can be actor-isolated or non-isolated, depending on where it's declared.
the same is true for a non-isolated type's instance methods.
Also, I think Swift docs lack enough explanation on non-isolated. For example, SE-0420 says the following:
Non-isolated synchronous functions dynamically inherit the isolation of their caller.
IMHO this should be mentioned as early as in SE-0306. People has written code based on this behavior all the time by assumption.
Another example. SE-0420 allows user to customize non-isolated asynchronous method's isolation by using isoated parameter. The doc uses terms like "dynamically isolated" to describe the behavior. For synchronous function, however, the doc uses term like "dynamically inherit". While both are "dynamical", they are different: asynchronous dynamical isolation behavior is based on user passed paraemter, while synchronous dynamical isolation behavior is hardcoded in the compiler. They are different mechanisms and there isn't a simple and unified way to understand them in general.
That's a tempting way to go, and I've tried this approach instead of isolating explicitly. First of all, this is not a simple design in any way: your code gets more complicated, compared to either keeping it completely non-isolated, or explicitly isolating to an actor. Second is that you probably don't need on a large scale in your app this kind of dynamic isolation, in some places it is useful – with more generalized code most of the time I'd say, but as go-to solution it just makes everything more complicated.
Instance isolation != type isolation. While instance itself might be isolated on the actor, it's non-isolated async methods aren't, because type itself doesn't change its isolation. The only case is for synchronous methods as they called in the same isolation, but that's core distinction between async and sync functions in the language to begin with.
I think this kinda obvious from the async/await proposal: async functions can abandon their thread, sync cannot, therefore they will be called in the same isolation. If anything to "blame" is undefined sticky behaviour of async functions before SE-0338 has landed, this one created some confusion around execution assumptions. But still, for sync functions nothing has changed since the beginning.
Yet I agree that we lack better explanations for non-isolated types in general.
Ability to make a function to be actor-isolated has been since SE-0313, SE-0420 just added a bit tooling to do that easier.
You are right that they are different mechanisms, isolated parameter makes function isolated (you can try mark it nonisolated explicitly and see compiler error), and synchronous functions inherit isolation by their nature while remains non-isolated.
You probably misunderstood what I said. I didn't mean not to use actor. What I meant was to avoid defining a non-isolated value's methods (including both synchronous and asynchrous ones) to be explicitly isolated (e.g. isolated to MainActor).
My point is knowing a type or its method is non-isolated doesn't directly help a beginner to understand the code, and very often it causes confusions. Take non-isolated method as an example, it's non-isolated due to it static characteristic (that is, where it's declared); however, when one tries to understand its usage in code, the key is its dynamic characteristic and the name "non-isolated" is more misleading than being helpful.
That said, I do think non-isolated is probably the best name (it reflects its nature). What I want to point out is that it has complicated behavior and is worth better explanation in Swift docs.
I agree that's a good way to understand it, but that understanding requires only "synchronous", not "non-isolated". Let's me put it this way, how many people realized the behavior explicilty and had a good theory to explain it in the past? I didn't.
That's not true. Isolated parameter introduced in SE-0313 only applied to synchronous methods. SE-0420 enhanced it to apply to asynchronous method too.
I did understand this. And I don’t think this is a good strategy. Non-isolated code can make sense in a lot of cases, and just avoiding it making everything isolated removes a set of options.
It requires both currently, as synchronous method can be actor-isolated as well.
I think I'm the one that actually wrote the code in the initial question. But, the reason I did it was to concisely demontrate a problem. That happens a lot in proposals. The goal wasn't to show useful and/or recommneded code, but to capture a specific problem without requiring a lot of mental parsing.
Now, as for the original question. You are right that as soon as you isolate one method of a non-Sendable type to a global actor, you are kind of applying that isolation to the whole type. And in general I do think this is a pattern to avoid. However, real projects can sometimes be messy, and it can be useful to selectively control which parts of your code are statically isolated as a temporary measure.