[Pitch] Awaitable Assignment

Hello all!

I would like to pitch changing how assignment statements which cross isolation domains work. As motivation, here's some code that compiles today:

@concurrent
func produceValue() async -> Int {
  0
}

class AsyncTest {
  var storage: Int = 5

  subscript(index: Int) -> Int {
    get { 0 }
    set(newValue) { }
  }

  func performAssignment() async {
    await self.storage = produceValue()
    await self[0] = produceValue()
  }
}

And here is some code that does not:

@MainActor
class IsolatedTest {
  var storage: Int = 5

  subscript(index: Int) -> Int {
    get { 0 }
    set(newValue) { }
  }

  nonisolated func performAssignment() async {
    // ERROR: Main actor-isolated property 'storage' can not be mutated from a nonisolated context
    await self.storage = produceValue()

    // ERROR: Main actor-isolated subscript 'subscript(_:)' can not be mutated from a nonisolated context
    await self[0] = produceValue()
  }
}

This is an ergonomic issue. It is a common area of confusion. It is consistently difficult to explain to people getting started with the concurrency system. It's also something that frequently pushes people towards MainActor.run. And while I think that construct does have its place, I don't think this is a great example of it.

I'd like to lift this restriction and permit this leading await in these cases. I do want to point out that this is not about changing the nature of the properties themselves or touching on asynchronous setters.

I've begun writing a proposal for this, but it is still a very early draft:

I'd love to hear your feedback.

8 Likes

That would be a massive improvement in ergonomics. Always resorting to require setter functions, which this would eliminate iiuc, is a lot of boilerplate.

Can anyone shed some light on why this has been disallowed up to now? Surely it was discussed at the time

Also, to clarify, you’re also proposing to allow this for actors, right?

We didn’t want to do this because popping over to a different actor to mutate a single thing has always seemed like something very likely to cause high-level data races, where people write code that pops over separately to do specific loads and stores rather than applying a coherent set of more “transactional” changes. I’m open to the idea that that’s too paternalistic, and I’m aware that the same transactionality argument can apply to loads alone. Nonetheless, I suspect that a lot of the people who feel held back by not having this are just going to find that it makes it a lot easier to do the wrong thing.

8 Likes

I've never understood this concern, given how easily the rest of Swift's concurrency features lend themselves to similar high level data races (technically). Now, personally, I don't think those are currently much of a problem. But I can't say I see the greater risk in await a = b; await c = d vs. let a = await x.b; let c = await x.d. Or something like await a(b(c(d()))).(Which part of that statement actually suspends? How many hidden async getters or set methods are called, and in what order? How many high level data races is it hiding?) And really, how is await setA(b); await setC(d) any better at preventing high level races? Now, Swift could offer a way to atomically load or store multiple properties asynchronously (transactions), but then nothing really prevents people from doing await a.transaction { $0.b = c }; await a.transaction { $0.c = d }, and we're right back where we started (what if people don't use this right?). So unless Swift has a way to prevent all of these issues at a language level, simply not implementing the obvious counterpart to a feature that already exists in the name of protection seems unnecessary.

3 Likes

Thanks so much for the clarification and discussion! I’ve expanded the proposal document to include a section on transactionality. I’ll reproduce it here just for convenience.


An important consideration here is accidentally exposing intermediate states.
Properties often directly hold isolated state
and assignment makes introducing non-transactional mutations easier.

Consider this code:

actor Stateful {
  var a: Int = 1
  var b: Int = 2
}

await stateful.a = stateful.b

The proposed change make logical races even easier,
because it makes suspensions points less obvious in this situation.
However, there are three things worth noting here.

First, diagnostic produced does explain what cannot be done,
but it does not help the programmer to build a mental model around why.

Actor-isolated property 'a' can not be mutated from a nonisolated context

The property cannot be mutated directly, but a trivial wrapper function can?
Why is this?
A programmer encountering this would have to do considerable research to learn
this is actually about a potentially problematic pattern and not just a syntactic limitation.
Worse, it could foster confusion around the concept of isolation,
because it strongly implies that mutation specifically is special in some way.

Second, this is the API that the author of the Stateful type has decided to publish.
Understanding the intention here, what operations may or may not make sense,
and how much transactionality is appropriate cannot be known.
The visible interface could be a simple property,
but the underlying implementation could be quite complicated.
A number of high-profile state observation libraries use properties as an interface,
where direct assignment as a means of publishing changes makes sense.
Further, it's hard to know what an reasonable solution should be.
Is a transactional method desirable?
These is an API design concern.

But, perhaps most importantly,
understanding the implications of suspensions points on transactional state mutation is an essential skill.
This is a phenomenon that a Swift programmer will be exposed to,
one that requires they develop the ability to recognize.
Building APIs that encourage logical races isn't a good thing.
But, disallowing this one particular construct does not further develop recognition,
thought it might indirectly encourage some limited mitigations.

Ultimately, thinking in terms of synchronous transactions while writing asynchronous code is unavoidable.
Encouraging awareness of the problem is essential,
but in order to achieve that goal, the programmer must understand the problem.
Disallowing await for this specific situation may help to build this understanding,
but it does so in a very oblique way that also comes with signifiant downsides.

2 Likes