Why do not actor-isolated properties support 'await' setter?

Hi everyone, I'd like to understand if there are any reasons not to support following syntax in Swift:

class A {
    @MainActor
    var foo: Int

    func bar() async {
        await foo = 3 // Main actor-isolated property 'foo' can not
                      // be mutated from a non-isolated context
    }

    func baz() async {
         await MainActor.run {
             foo = 3
         }
    }
}

Seems like MainActor.run(resultType:, body:) is excessive in such contexts.

4 Likes

Actors model implies that actors communicate with each other via messages – methods in Swift's case. The state is protected by the actor and can be mutated only be the actor. That's like basic building blocks of the whole model. Allowing state modification outside of an actor context breaks this foundation. I would also consider MainActor.run as a last resort in a small amount of cases/during migration, but not as a default tool to solve errors. Additionally, such explicit isolation makes it easy to reason about behaviour and analyse code in terms of isolation, logically structuring the app in that way too.

I just don't see much difference between:

  • calling actor-isolated method with async keyword
  • getting a value of actor-isolated variable with async keyword
  • setting a value to actor-isolated variable

I started this thread, so maybe someone can point me to the such difference

1 Like

The difference is what that syntax communicates: await x = 1 tells nothing about where this state isolated, how it plays with other parts of the actor state, etc. It also allows to modify state in a much less obvious ways, so

await someActor.x = 1
await someActor.y = 2

Isn’t the same as

await someActor.update(x: 1, y: 2)

And that can lead to unexpected results.

At the same time, message is a clear communication point between isolation domains. As it was stated in one of the threads, thinking about actors as regular classes isn’t a good approach — they look like one, but there is a significant difference in programming paradigms.

4 Likes

To elaborate on that, one of the best mental model shifts you can do when working with actors is to stop designing "cute" and "clean" interface like

actor ImageLoader {
    func startLoadingImage(url: URL) async throws
    func addTagToImage(_ tag: String)
    func saveImageToDisk() throws
}

with the intent to "compose" these calls from the outside:

try await myActor.startLoadingImage(url: url)
await myActor.addTagToImage("cute")
await myActor.addTagToImage("kitty")
await myActor.saveImageToDisk()

— these four calls can be interleaved in many possible combinations; you're more likely to corrupt your data this way than not.

It really helps instead to turn a complete 180 when it comes to API aesthetics: the "unwieldier" a signature looks, the more likely it is that the function exhibits correct transactionality:

func loadImage(at: URL, addingTags: [String], saveToDisk: Bool) async throws

This is a very artificial example, but it aims to show that the interface of an actor should only offer indivisible, complete entry points to the whole "batch" of operations that needs to be performed. If you need variations, you should either parametrize the function or offer a separate function, so that your callers never have to "compose" logic out of smaller steps.

10 Likes

Interesting point, but I didn't quite understand how is it related to the topic

What are your compiler settings? The 6.0 language mode doesn't allow the MainActor.run capture either:

func baz() async {
    await MainActor.run {
        foo = 3 // Capture of 'self' with non-sendable type 'A' 
                // in a `@Sendable` closure; this is 
                // an error in the Swift 6 language mode
    }
}

Please correct me where I’m wrong.

I think what’s being asked is something like this — We can easily add a function like:

@MainActor
func setFooTo3() {
    foo = 3
}

and then call it in a revised bar like so:

func bar() async {
    //await foo = 3  // Will fail to compile
    await setFooTo3()  // Have to `await` since this needs to hop actors
}

so, why can we not just do await foo = 3, since that would seem to express that we’re potentially suspending and hopping to the main actor that protects foo?

And I think the answer being given is: “that syntax could be allowed, but it’s been judged that it’d encourage a bad pattern, namely accessing little bits of state across multiple suspension points.”

3 Likes

No, despite whatever discussion happened in this thread, no, that's not the reason why. We can read the reason why in the original proposal.

Effectful settable properties

Defining the interactions between async and/or throwing writable properties and features such as:

  1. inout
  2. _modify
  3. property observers, i.e., didSet, willSet
  4. property wrappers
  5. writable subscripts

is a large project that requires a significant implementation effort. This proposal is primarily motivated by allowing the use of Swift concurrency features in computed properties and subscripts. The proposed design for effectful read-only properties is small and straightforward to implement, while still providing a notable benefit to real-world programs.

In short, they would be complex, and so were deferred from the getter proposal. Hopefully once Swift's effects story is more complete we can revisit effectful setters.

7 Likes

I think actors proposal is more relative to the topic? As it has its own note on support cross-actor sets:

Cross-actor references to an actor property are permitted as an asynchronous call so long as they are read-only accesses:

func checkBalance(account: BankAccount) async { 
    print(await account.balance) // okay await 
    account.balance = 1000.0 // error: cross-> actor property mutations are not permitted
}

Rationale: it is possible to support cross-actor property sets. However, cross-actor inout operations cannot be reasonably supported because there would be an implicit suspension point between the "get" and the "set" that could introduce what would effectively be race conditions. Moreover, setting properties asynchronously may make it easier to break invariants unintentionally if, e.g., two properties need to be updated at once to maintain an invariant.

2 Likes

That’s just one narrow example of the general problem. I don’t really find either issue that big of a deal given Swift already lets you hide arbitrary suspension points behind a single await. I especially disagree with the notion that async sets shouldn’t exist in order to encourage “correct” design. It actually does nothing of the sort, it just forces people to write setX methods which have the same problem. And actors already provide this footgun even without setters, as you can hide arbitrary suspensions within single methods and, even worse, be reentrant while doing it!

7 Likes

Yet such cases easier to spot compared to property setters. Allowing setters gives an ability to change actor's state from any place, while idea of actors to protect this state. And I think this should be discouraged as much as possible. Isolating state changes on actor allow more thoughtful control on state modifications, and avoid more suspension points. If you need some shared state that is freely modifiable by any party, mutex suits better for the job, I don't see the benefit of using actor in such case. It also leads to problematic mix-up of isolations inside a single type, which also something that only complicates flow and code.

3 Likes

Yep, I was asking exactly this, in different words though :)

Nevertheless, the set of actions that are considered to be in single transaction will be decided by the programmer on the basis of his intentions.

Having a state, consisting of one variable can be a case. So I'll have to go through the ceremony of changing it in a special method

That take is way out of a context of initial message — single sentence here isn’t capturing the whole idea. Not that a single state update is wrong in general, but that fragmented state updates are. In the same way this quote makes my message fragmented, and as result — hard to follow.

In the end, allowing setters will make actors to act as just synchronization primitive for read/write, which is mutexes what for. But actor isn’t just a sophisticated mutex.

In example of global actor isolation, that’s wrong claim IMO: you don’t have state that consists of just one variable, you have this state spread across various abstractions. It would be highly unlikely that you introduce global actor for just one variable.

If you have an actor with only one property, and you want to mutate it outside of it, then you probably don’t need an actor here, or this property should become a part of another isolation.

I brought this same concern up here, and I'm not necessarily convinced by the arguments against.

@vns , if a variable cannot be safely mutated outside of the actor, it should be marked with a private set, just like you would with any other non-actor.

Regarding the "transaction" argument, I don't see a convincing reason that my transaction cannot entirely consist of setting a variable. Every time I have tried to use an actor, I have ran into this same problem, and every time have had to create a "setter" function like I'm writing in Java.

Additionally, adding an async set property (just like async get) would allow actors to conform to protocols of the following type:

protocol SessionService {
    var user: User? { async get; async set }
}

At the moment, it isn't possible for an actor to conform to this without some modification of the protocol.

2 Likes

As unexpected coincidence, I've been going few days ago through old threads on concurrency discussion, and there was a nice way to think about this: actor isolation is orthogonal to access control. So you don't fix isolation with access levels, and you don't fix access levels with isolation. This may seem as contradictory to disallowing async setters on actors, but if you think about it – it makes a lot of sense. So actors don't allow such mutations not because this something you would be able to address with access control, but to enforce state isolation.

Transaction argument, while is important (that's number one post in thread you linked!), isn't the main player here IMO. If we go back to original proposal and review thread, as well as actors model itself, the core idea of actors is to be only holders and protectors of the state. So making async setters now violate this isolation, making isolation a bit blurry concept in general. Again, actor shouldn't be just used as some asynchronous locks, they are much more than that.

Finally, let's consider example from the proposal:

extension BankAccount {
  enum BankError: Error {
    case insufficientFunds
  }
  
  func transfer(amount: Double, to other: BankAccount) throws {
    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")

    balance = balance - amount
    other.balance = other.balance + amount  // error: actor-isolated property 'balance' can only be referenced on 'self'
  }
}

Now, let's imagine we allow async setters on actors. How it will look like from usage point? Something like

await other.balance = await other.balance + amount

? That's clearly now what we want.

What if we could eliminate second await to get

await other.balance = other.balance + amount

Is it one transaction to actor? Or will it first read balance (1st suspension point), then update it (2nd suspension point)? That's unclear from the code, and source of potential errors.

Actor require a bit of shift into using them, so you don't write Java-like setters at all. While they look in Swift like classes from OOP, they aren't them at all.

Isn't one key use case for actors precisely to allow mutating a piece of shared state from "any place" in a safe manner? The state of the actor is still protected in a world with async setters: you still can't get memory corruption issues due to race conditions.

Yes, actors can do so much more. But isn't this a use case that actors should also be able to do?

If the issue is that a property shouldn't be modified unless it's done by code internal to the actor, it does look like the issue is one of access levels, not isolation. The property is isolated as it's part of an actor: no one can modify it while other actor code is executing. Async setters don't change that. If the issue is that the code in the actor doesn't expect that property to change between suspension points using a mechanism external to the actor (like a async setter), the issue does sound like an access level problem.

That said, I understand that it's better if the actor only exposes a limited API to users, to avoid encouraging the kind of interleaving @nkbelov mentioned. And async setters would increase the surface available and open for misuse, by automatically making every non-private property modifiable from the outside independently.
That's an obvious downside, but I'm not convinced that it's worth not being able to write things like await fooActor.foo = 3 when you need it.

I'm having a hard time picturing why async setters "violate isolation" but manually writing the corresponding setter doesn't. The only difference seems to be the ergonomics of it, and that shouldn't make-or-break isolation.

There is a significant difference between "this state should be mutated by anyone concurrently" and "this state can only be mutated by this party, others ask to do so". In the first case, that's a one property that you need safely mutate from anywhere, and this can be done using locks – perfect tool for the job. While actors provide isolation: the state is guaranteed to live in this isolation, accessed from there, mutated, etc. Actor here define "autonomous unit" that performs some work, and other parts send messages to it.

The latter is a completely different way to structure the program: instead of having multiple parties that operate on a single state, you have one entity that handles everything related to this state. That is making much easier to reason about the code, since now you have units that operate on some state, and only they can change it, so now you design how this units communicate with each other to perform work.

They able to do so, just with additional friction — because they aren't designed for this in the first place.

So the problem here is that if you need it, you are either using wrong tool for the job, or using tool not in the way it is supposed to be used.

Side note on compiler fighting

In languages like Swift (or Rust), where a lot of checks, that previously was a concern of a developer, moved to a compiler, it is often experience of "fighting a compiler" to make something, that seems obvious and simple, work. That's the case with Rust borrow-checker, now that's the case with Swift Concurrency. While at first this is really unpleasant, and sometimes can annoy experienced developers as well, I'd argue that in majority of cases the necessity to fight a compiler goes from the wrong use of a features it provides. It simply tells you that the design/way to implement things you have invisioned isn't correct in its world.

We can argue about pros and cons of that, to me that's an obvious plus, somebody might want more freedom and not be told by the compiler what to do, but that's the state of the things. Actors model has certain properties it has to posses to work in intended way, so that compiler enforces them.

I have illustrated the issue with async setters that can be easily introduced here, as well as uncertainty they bring with previous post. How do you imagine async setter to work with the last example? I don't know tbh.

As for writing setter, the first thing here for me is that if you need to write a setter, you are more likely using actors in a wrong way (I mean, if that's really just a setter, not some logical operation that happen to look like one). But even if we put this aside, explicit setters make you think if you need to update several properties, then probably use batch setter? Then they also state clearly what mutations on the actor happen, so you keep this isolated picture of a state and its mutations, compared to async setters can be invoked anywhere in the app, so that you don't know what happens to your isolated state. And the more friction you have to get through to make it happen, the more likely you won't do that at all.

I don't think locks are the best tool for the job. You get zero compiler help when making the properties of a class thread-safe with locks. Forgot to acquire the lock before accessing or modifying a property? The compiler won't help you. In contrast, when you use an actor for the same purpose the compiler will guarantee that you don't access those properties without first switching to the correct isolation context.

But that doesn't change with an async setter. Before setting a property, you hop to the actor, modify the property (while the caller awaits), and then hop back to the original context. You still have "one entity that handles everything related to this state". It may be better to do a few long hops instead of lots of small hops (not disagreeing with that one bit), but it feels like the granularity of those hops should be up to the programmer to decide, not the compiler.

Honestly it feels like all these arguments could be made for async getters as well. To use a similar example as yours, imagine we were writing a BankActor. You can write:

Models
struct Account {
    let balance: Double
}

actor BankActor {
    var mainAccount: Account = Account(balance: 53)
    var savingsAccount: Account = Account(balance: 1000)
}
let totalBalance = await bank.mainAccount.balance + bank.savingsAccount.balance

And this code has almost the exact same problem as your example with async setters. There are two suspension points, so in-between those two suspension points, the actor may have changed state in a way that makes totalBalance have an unexpected value (for example, if some balance is transferred from mainAccount to savingsAccount between the access of mainAccount.balance and the access to savingsAccount.balance).

The correct approach here would be to avoid composing totalBalance from outside the actor, with many tiny separate hops to the actor to fetch individual properties, and instead have a single method in the actor to perform the operation:

extension BankActor {
    var totalBalance: Double {
        return mainAccount.balance + savingsAccount.balance
    }
}

Yet we allow async getters to exist for actors! No need to write getMainAccount() and getSavingsAccount(), even though doing so may have led you to think about writing a "batch getter", which would be the correct solution here.

(Also, to circle back to my point about access control: if you were trying to prevent the former code from being written (where balances can be accessed separately), the way to do it would be... to mark the properties as private).

2 Likes