Open to suggestions, seriously. “Serialized async value” doesn't seem great to me.
So, each value-actor stores an underlying value and a queue of value-specific jobs that manipulate the underlying value. When you copy the value, you don't copy the queue or the underlying value; instead, the new value gets a "promise", basically a job added to the old value's queue to copy its underlying value into the new value. Operations enqueued on the new value will be blocked until that promise is fulfilled, essentially as if the promise were a running item in the new value's queue. (Obviously some of this work can happen synchronously in the common case where there's nothing running or pending in the old value's queue.)
If when you say “blocked” you don't mean blocking a thread but just suspending the client task, this sounds about right. I have also mentioned a cruder but equally valid approach, “blocking” (i.e. suspending) the client at the point where the actual copy is made, only allowing it to resume after all the previously-queued items are finished and the copy has been made. But I think that probably leaves some potential parallelism on the floor, so I prefer the approach you described.
I think there are fundamentally two problems with this.
The first is that it's unclear how you would ever get multiple operations enqueued on the actor. If this is a normal value with normal exclusivity, how do we manage to make two asynchronous mutating calls on it at once?
I'm confused; of course you don't ever do two things at once to a value. However, it's easy to get multiple things onto the queue.
doc.gaussianBlurSelection() // gaussian blur is queued, may start
doc.brighten(1.2) // brighten is queued
//...
let tinyBlurredAndBrightened = await /*if you must*/ doc.thumbnail
In this example, at least two mutating operations are notionally queued. In the last line, if they are not complete, the caller is suspended until they have been completed.
An asynchronous call has to happen from an async function, which is going to wait for that call to complete before it can make another call.
Well, maybe we have to change the definition of “complete.” An async call that returns nothing and operates only on a thing with value semantics can be seen by its clients to “complete” immediately.
You can of course have multiple functions making mutating cals on that value concurrently, using some sort of shared storage, but that's an exclusivity violation.
Of course! I wouldn't dream of making multiple mutating calls concurrently on a value. Another way of saying “that's an exclusivity violation” is “that would undermine the whole point of value semantics!”
What you're describing is something much more like a "fire and forget" API, where all the operations on the value-actor are externally synchronous; basically it's a value type with some major underlying trickery about where the computation happens.
I don't know what trickery you're talking about, but it's not “fire and forget.” You can still read from the thing, and that read is serialized after all the already-queued operations.
I can see why that would be an interesting feature, but it's very different from what we call actors in Swift, and I'm not sure how generally useful it is.
Obviously that has yet to be demonstrated, but I think we've seen that the vast majority of mutable state does not need to be shared, and it is a key strength of Swift that programmers can choose to make state non-shared (as opposed to “everything is a reference type”).
We can easily imagine moving (or passing copies of) values to some reference-typed actor to do some fancy computation, but if that actor has no interesting state of its own (other than its queue) we're really just needlessly serializing computations on non-overlapping data. No, in that scenario, the values should each have a distinct queue.
The second is that I think most uses will need to be able to get a synchronous snapshot. For example, you say:
Making a copy of Document is as simple as initializing a variable or appending it to an Array .
But this sort of copy is an asynchronous snapshot; we can't actually read from it synchronously if there's a pending/running operation on the value.
I take “can't read from it synchronously” to mean “can't read without potentially suspending.” I think we can read the whole document value without suspending, as noted above, but reading its parts would indeed potentially suspend. But it's an actor-or-whatever, so that's fine.
The UI presumably wants to be able to synchronously walk the document to decide what to render.
No, I don't expect to be able to observe the interior structure of an actor-or-whatever without potentially suspending.
So I actually need a DocumentSnapshot type that reflects the underlying value of my Document value-semantics actor; and once I have that,
You don't need DocumentSnapshot exactly, but you might think this was the moral equivalent: you probably do want some large-scale read operations on Document that can extract a substantial amount of data so the UI isn't negotiating with the actor-or-whatever over every byte. An application like Photoshop would design the structure of such a document very carefully, probably breaking large images down into sub-actors that can be manipulated in parallel.
it's unclear why I can't just keep Document as a value type, with an ordinary reference-type actor that manages the canonical copy, applying edits and so forth, and which periodically updates the UI with the latest snapshot.
The same reason we don't want to make everyone do array mutation through an enclosing ArrayBox class. Of course you can do that, but now the actor can (and surely will) be shared, which is just as bad as having a reference-typed Document in the first place. Worse, in fact, because now even TSan won't tell us when two threads are operating under the illusion that each has complete ownership of an independent DocumentActor.