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

That's easily addressable by proper structuring accesses to the property. Plus we have Mutex now.

Imagine actor in current version, without async setters as a box (mailbox? heh). Inside that box lies the state. You can take a look — like maybe this box is transparent – on what's inside that box. But we can't change what's inside by ourselves — instead we put a "request" to make a modification, that will be processed by the box.

Async setters change this perception, as we can now take something out of this box by ourselves, play with it in any way, and put it back. Box only now can guard that we only one who play with the item, but in general it becomes kinda meaningless, as overseeing can be done by anything, so why to use the box?

Actors propose different outlook on the state in the app. Instead of scattered across various types and domains, it is now isolated, and only in this isolation it gets changed.

Don't you think that this only proves point that async setters are bad? Exactly because you already can have such behaivour with getters. Just with the difference that getters in overall will bring a bit less harm potentially, while still can give undesired behaviour.

And you further prove several points as well with this example

by isolating action to the actor's computed property. You've just improved code by isolating on actor, so why we should promote bad practices introducing async setters?

We still have unresolved question how async setters will behave here

await other.balance = await other.balance + amount

or here

await other.balance = other.balance + amount

(note that both these cases cannot be mitigated by some computed property, because amount is external to actor state)

or even with your example of computed property

actor ATM {
    // ...
    var withdrawAmount: Int?
}

let atm = ATM()
await atm.withdrawAmount = bank.totalBalance

In which way any of these cases would be resolved? How clear it would be that there are several suspension points involved?

Compared to (not perfect) isolating this action:

extension BankActor {
    func withdrawAll(from atm: ATM) {
        await atm.request(amount: totalBalance)
    }
}

(note that this probably also a bit wrong from design point, but isolation makes me think about how better communicate the action, not just use async setter and don't think about it)

That's still orthogonal to isolation. Yes, you might be better hiding mainAccount and savingsAccount, but not necessarily hiding them makes sense, while isolating does.

But this is not a good metaphor. We're not taking anything outside this "box", we're still putting a request to make modifications to the contents of the box, that will still be processed by the box. The syntax for this request may be different (with async setters), but it's still a request, performed inside the box. If the property had an effectful setter (like didSet), that code would also run inside the box.

It proves that both async getters and async setters can be misused (which I think we both agree on), not that they are intrinsically bad. If the behavior was deemed acceptable for getters, why not for setters? I'm not convinced that the potential for creating this kind of harmful effects of is much higher in setters vs getters.

The point I'm trying to make is that it's not the compiler's job to police the granularity of the writes to an actor's properties, just like it doesn't police the granularity of the reads to an actor's properties. And if it were (which I strongly disagree it is) then it's already failing spectacularly at that job: you can bypass it with a 3-line setter.

Not all small writes to an actor property are wrong, just like not all big "unwieldy" methods that modify several actor properties at once are correct. The compiler can't possibly know which ones are correct without domain knowledge that only the programmer has.

Because not all async setters are bad practices. Again, this same argument can be made to remove async getters from actors, as you can also misuse them in subtle and confusing ways.

Well, this is definitely something worth discussing, but my intuition is that it should do the same thing a manual setter does. What does this do?

await other.setBalance(await other.balance + amount)

Or this:

await other.setBalance(other.balance + amount)

It must do something, because you can already write that code today.

In any case, if reading and then modifying an actor-protected property in the same line is deemed to confusing (due to the implicit second suspension point between the read and the write), it's within the realm of possibility to have the compiler force splitting the two suspension points into separate lines, so it's clear that the property can change in the middle:

await other.balance = other.balance + amount
//                  ` error: suspension point between actor read and actor write requires explicit await

With a fix-it to:

let balance = await other.balance
await other.balance = balance + amount

Where it's obvious that balance can change between the two suspension points.

2 Likes

Let's use regular OOP for the sake of an example (even though actors are not the same as classes at all)

final class BankAccount {
    var amount: Int

    init(amount: Int) {
        self.amount = amount
    }
}

let myAccount = BankAccount(amount: 1_000_000)
myAccount -= 10_000

That seems legitimate with classes, arguable — but let's put aside encapsulation details here. Is this modification still processed within BankAccount? I'd say no, because modification happens somewhere outside of the class. There is a little control with didSet, and that's all.

Now, back to actors, with async setters you don't put request for modification with async setter, you ask to put new modified state. Not that the box changed its state, you put new state into it outside. You first processed, then ask box to apply this change internally.

That's may seem like just a matter of words choice, yet this is a significant implication on how you structure code: actors should isolate their state, allowing modifications of this state outside of an actor breaks this model.

We are talking about two different perspectives: I say that you need to think and design actors in the way that you don't need setters, not seek for bypassing; in the way where async setters simply doesn't needed.

Compiler doesn't police you from granularity of the writes, it just makes you think twice. If you really need granular update, writing 3 lines occasionally isn't a problem, while the entire point of allowing based on the fact of their often use.

Let's assume async setter would transform into legit code with explicit setter as we can write manually today, that's reasonable choice of behaviour. How safe are this modification of a state? What is in between of getting balance and setting it, it gets modified? We end up with the wrong transaction! And that's exactly the issue: by permitting such modifications in easy way (write = is much simpler than define method and call it), we also would make it much easier to write wrong code.

I’d actually claim that actors are the most true to the actual intent and principles behind OOP, and the fact that you can grab innards of a class and do a += 1 on it is the doing model “breaking“.

Turns out actors outright ban such loose treatment of the model — encapsulating is enforced and what previously sloppy accessing another objects inner state you could have gotten away with, now you don’t.

This goes all the way back To Allan Kay terming the term “object” and OOP and SmallTalk, and a pretty interesting response he wrote in 1998.

I'm sorry that I long ago coined the term "objects" for this topic because it gets many people to focus on the lesser idea.

The big idea is "messaging" -- that is what the kernal of Smalltalk/Squeak
is all about (and it's something that was never quite completed in our
Xerox PARC phase).

But anyway, in practical terms looking at all this from the perspective of “what it looks like to be doing” and “what it’s actually doing” is helpful imho. both the await other.balance = other.balance + something.balance as well as chained writes which are quite scary IMHO such as: someActor.anotherActor.something = bla are IMHO very difficult to understand what kind of transactionallity we’ll be getting here. And the answer is going to be “almost no guarantees, this code is very likely broken”.

Because mutations often are more than a trivial +1, but include coordination between various domains and a number of operations that should perhaps be performed atomically etc… async setters are a footgun that we’d rather avoid IMHO.

It’s the same reasons why not every actor has a func run(whatever: (isolated Self) -> T) async -> T on it, because actors (and OOP!), are a way to organize your code/logic, and not just bob.run { tons of code here } solve everything without actually centralizing logic. If all your actors have is boring “setX” methods… maybe they’re not actually containing any logic that’s important to isolate the state/logic with — to me that’s a smell to act on in the code which ends up having to write this, and not something to resolve by “just” allowing the property setters on actors.

At the other hand, I am curious if perhaps this is somehow showing up here specifically because “so much of stuff is on main actor” AND “a lot of existing libraries require setting properties”. I would definitely welcome more real world examples of the problem showing up. The 3-10 line snippets we have so far in the thread IMHO (personal opinion) are not really justifying exposing the huge footgun to avoid a few lines of code that should often make you step back and rethink if you’re doing what you should be doing.

6 Likes

I recall one particular case in which I ended up needing to write lots of setters by hand.

I'm writing a small application that renders the 3D structure of proteins using Metal. This had some interesting requirements:

  • During a draw() call, the state of the renderer must not change (for example: can't change the rotation, or the number of objects to draw mid-call).
  • Since the app can show multiple (independent) windows, it must be possible to run different draw() calls in parallel if they belong to different windows.
  • Rendering must not happen in the main thread.
  • The user is able to change some properties of the renderer's state (rotation, color...) from the UI. Ideally, these properties would take effect on ~the next frame, but it's fine for them to take much longer.

So I created a Renderer actor to back each window. The properties protected by this Renderer actor conform the shared mutable state of the renderer.

  • draw() is a synchronous method of this renderer, so while a draw call is in progress, nothing can modify the state of the renderer.
  • Different actors can run draw() calls for different windows in parallel.
  • All this happens outside the main thread.
  • When a user makes some changes in the UI, it calls a method on the relevant instance of this actor. Here's where setters started to appear.

Something like this:

actor Renderer {
    private var modelBuffers: [MTLBuffer]
    // ...
    func draw(in layer: CAMetalLayer) {
        // Somewhat long operation, state must not change here!
        // ...
    }
}

This actor is designed with lots of "big" methods that change many properties of the actor at once. For example, when the user loads a new model, there's a (synchronous) method that populates all the Metal buffers:

actor Renderer {
    private var modelBuffers: [MTLBuffer]
    private var frameData: FrameData
    private var needsRedraw: Bool
    // ...
    func populateBuffers(_ models: [Model], config: VisualizationConfig) async {
        // Some stuff is computed asynchronously first...
        let newModelBuffer = await createModelBuffers(
            models: models
            config: config
        )
        // Once everything is ready, other properties are updated all
        // at once.
        self.modelBuffers = newModelBuffer
        createColorBufferIfNeeded(models: models)
        self.frameData.atomCount = config.atomCount
        self.needsRedraw = true
    }
}

This has worked great in practice :grin: The state of the renderer is always consistent, thanks to compiler checks. However, some properties, while being part of the shared mutable state, are safe to modify by themselves without needing to make changes to any other actor-protected properties. So I ended up with (lots) of setters like these:

actor Renderer {
    // ...
    func setAutoRotating(to autorotating: Bool) {
        self.scene.autorotating = autorotating
    }
    // ...
    func setColorFill(_ color: CGColor) {
        self.scene.colorFill = color
    }
    // ...
    func setSunDirection(_ sunDirection: SunDirection) {
        self.scene.sunDirection = sunDirection
    }
}

It's still important for these properties to not change during a draw call. For example, it would be bad if the shadow map generation render pass saw a different sun direction than the main rendering pass. And yet, as long as you don't change this property in the middle of a draw, it's actually fine for the property to be changed from anywhere.


In fact, I must say that this limitation related to setters confused me for a while, and led me to wrong design decisions before I realized I could just write a few small setters. The errors I got were something like:

Actor-isolated property 'colorFill' can not be mutated from the main actor

So I just assumed that this meant that it was fundamentally wrong to mutate this actor's properties from another actor, while all I actually needed was... a 3 line setter.

6 Likes

Could you represent all of the view-controllable state (except the model choice) as a struct stored on an @MainActor view model, then synchronously grab a copy of that state before each render pass? That way, the UI will immediately see any changes the user makes reflected back in the controls, and value semantics mean it is impossible for that configuration to change out from under the renderer. For things like the 3D model, you’d still need an actor to manage fetching the updated model choice from disk, but that could be a separate actor from the renderer which could just grab a struct containing the current Metal buffers at the start of each render pass.

Oh, the UI already has its own copy of the values, stored on a few @MainActor view models, so all UI controls see changes reflected immediately, even if the visualization takes a few ms longer to update. So when the UI is updated -> @MainActor View Model is updated -> a task to mutate the renderer is spawned. It's even possible to keep track of those tasks to disable the specific control while the action takes place.

There are a few reasons I went with shared state with reference semantics instead of copying all state from the @MainActor View Models at the start of every frame:

  • The combined state is not lightweight enough to copy all of it up to 120 times per second.
  • It allows rendering to happen only when a value has changed, using minimal energy if there are no changes.
  • It completely avoids requiring a switch to the main thread to render new frames. So even if the main thread is blocked for a few milliseconds, no frames are dropped.
2 Likes

Thanks for the writeup @Andropov ! I'll have to re-read deeper again soon.

Meanwhile I wanted to share a PR which may be of interest to this thread. It's not changing any semantics, but the thread got me thinking that perhaps we can offer some more notes, here's an idea to suggest the method route when hitting the property setter restriction: [Concurrency] Suggest adding a method, when mutating actor property cross isolation by ktoso · Pull Request #75922 · swiftlang/swift · GitHub I'll also look into offering "long form" educational notes, which explain the "why" somewhat deeper.

4 Likes

i understand this argument but i am unconvinced of how useful it is in practice. a lot of actors are born simply because there is a piece of state (which may be as trivial as a monotonically increasing timestamp) that is shared between concurrency domains, and an actor is needed to resolve compiler errors. these named setters add boilerplate to the project and don’t always provide a lot of value in understanding the code.

i’ll also say that i’ve run into more than a few instances where i’ve had the opposite problem where the file containing the actor definition simply got way too long because there was so much code that “had” to be written as methods on the actor due to the “run on” pattern being discouraged.

1 Like

I see this viewpoint onto concurrency a lot, and it's the root cause of the frustrations, not the actual limitations.

These warnings, errors and limitations are not here to annoy you, and you don't put them into place to "make the compiler happy".

They're there to make you stop and think about your problem facing with concurrency, and resolve it in an appropriate way. They're there to "make the future you happy, who doesn't have to debug irreproducible races and weird states".

Whenever people bring up "just a counter" or "just a variable" examples, I quite honestly say don't use an actor for those (and that's coming from someone entire career with actors pretty much). It's fine to use a lock or atomics, they're there for a reason. Not everything has to be an actor, a lot of things are, but not everything - in Swift at least (and not in some hypothetical actors-only language). I see people getting frustrated because they assume everything must be actors now, but that's not true. After all, we introduced Mutex and low-level atomics, just now, and there may be more things to follow.

You do have to think about concurrency (you always should have but you might have gotten away with it), and sloppy programming won't fly anymore, but that's it, other synchronization mechanisms are viable as well -- especially for "just contains a bunch of values" things. It would not make sense at all to make a sophisticated concurrent datastructure an actor, there's a reason they're highly specialized for high performance using advanced locking or lock free programming techniques.

Yes, we should be pushing the envelope of the ergonomics story and making it easier, but the setters are a touchy subject as they would almost definitely result in breaking assumptions about atomicity and result in more mysterious hard to debug bugs rather than in those situations having to rethink things.

I really don't want to be dismissive here, but out of all the ergonomic improvements I'm personally just not sure we get the most gain, and least pain from the specific async setters topic. More specific real world examples would definitely help so we can think them through together. @Andropov's writeup was very interesting for example.

10 Likes

In regards to all the comments recommending the use of a mutex, do we really want to use them in a Swift Concurrency context? A mutex or lock blocks the thread while it waits; blocking threads in the cooperative thread pool is something we're explicitly supposed to avoid.

An actor can do much more than a mutex, yes, but AFAIK there's no real reason not to use it as a mutex if that's what you need, and it's a lot safer on the cooperative thread pool due to its re-entrancy.

Yes we really want to use them in a Swift Concurrency context. What you're saying is a very common misconception, blocking the thread is fine (for short period of times) if the code doesn't wait on future work (semaphores wait on future work and are not ok). There's plenty of locks in use today, including in Swift Concurrency itself, it's fine.

Use the right tool for the job, that is what @ktoso is saying I believe. If a lock is the right tool, then absolutely use it.

3 Likes

Sometimes you just really need that your locking is synchronous and doesn't break up your transaction with a suspension point.

1 Like

This sounds great, thank you!

This would be :sparkles:awesome​:sparkles:. It's hard to condense the subtle difficulties of concurrency in one-line errors/warnings.

I think locks have been demonized in Apple platforms for so long now that few developers choose them, even when they are the right tool for the job. There's been not one, but two concurrency frameworks (Grand Central Dispacth and now Swift Concurrency) launched in recent memory that put a heavy emphasis on how much better than locks they were. So, understandably, many people resist using locks now, which probably contributes to some of the frustration with Swift Concurrency.

I've read everywhere that semaphores are not okay, but isn't the problem generally that you must not suspend while holding a synchronization primitive? For example, isn't this code with semaphores correct?

actor LimitTo3ConcurrentJobs {
    private let semaphore = DispatchSemaphore(3)

    func performJob() {
        semaphore.wait()
        doLongSynchronousWork()
        semaphore.signal()
    }
}

Even though we use a semaphore here, its usage still creates a well-defined synchronous critical region between each wait() and signal(). Some threads could be blocked in performJob(), but there must be at least one thread alive and running in the concurrency pool that is doing forward progress. The only way this would deadlock is if it suspends before signaling the semaphore, as the cooperative thread pool may end up filled with threads that are all stuck blocked with no forward progress.

True, and in those cases it's clear that an actor won't work and a lock must be used. But there are cases where you could use an actor as a mutex, because you don't need the mutation to happen synchronously and a suspension point doesn't break the transactionality of the code.

Possibly yes but this suffers from other problems such as priority inversion because a semaphore isn't limited to this pattern and the OS can't know what threads are going to signal the semaphore. I'm also unsure long synchronous work, especially constrained by a count semaphore, is a good idea to perform on the limited cooperated thread pool as this seems like an easy way to starve the pool. All in all, I'd probably look into another way to achieve something like this.

Well, that and the fact that with GCD, blocking the thread on a dispatch queue often caused the system to say, "oh, one of my threads is blocked, let's spin up a new one", and then if you had a lot of stuff going on, you'd end up with threads in the four digits.

Can't speak for GCD, but with Swift Concurrency, I'm quite definitely not getting the impression that either locks are discouraged or that SC is claiming to offer a zero-cost replacement. I still heavily use locks when building SC-compatible primitives, and if actors iterate on anything, it's primarily GCD queues, as they most obviously resemble the actors' mailbox.

The issue is perhaps that the mental model for concurrency is already heavy and not everybody would willingly dedicate time to learn all of the intricacies (although I personally would argue that in the modern times it's almost as essential as if statemtents), so people tend to rely on the promise that this one particular technique is going to solve all the problems — no, it won't, but this is how I would otherwise get the impression that actors are suggested to replace "older" primitives.

Unfortunately I'm afraid the actor type might be playing a detrimental role and we might have been better served if Swift Concurrency offered isolation domains instead, which you could use to assign many pieces of you program to them (more or less what global actors are). It could have help shift the discourse from "you now need to make things actors" to "assign your existing types to isolation domains" (i.e. developers won't be making new isolation domains for everything but instead be encouraged to reuse them for more things).

2 Likes

Oh, I may have worded that poorly. I'm not suggesting that the Swift project itself is pushing for Swift Concurrency as a one-size-fits-all alternative to locks (quite the contrary). However, in the wider community of developers for Apple platforms, it does feel like a lot of people are suggesting exactly that.

It may be my personal experience only, though I can't help but notice that many of the most prominent blogs about Swift all have articles following a template similar to "Here's this code with locks. And now here's the same code using actors instead. Actors are much better!" with no mention to when or why locks would still be the best option.

1 Like

Sadly this is also something that you will not find on the documentation pages for Concurrency, Actor or Mutex at developer.apple.com. This would make a big difference if developers could read proper guidance from the documentation, right now all the bits are scattered on twitter, mastodon, wwdc videos and here, which makes it hard to find. And as Andropov said, it is lost in a sea of prominent blog posts sometimes telling you the opposite. The same thing happened with the libdispatch which pushed me to eventually write a collection of all the dos and donts I could find (from various posts made by the Apple engineers working on the libdispatch) because Apple would not do so itself.

4 Likes