Over in the async/await proposal review, Doug posted a toolchain and some examples using async/await. I have some comments and observations, but they have nothing to do with that proposal review, so I broke them out here.
In the Actor Counters example, several questions:
-
It looks like the Counter.scratchBuffer is both leaked and used uninitialized, and isn't actually useful. It isn't clear what it is about, should it just be dropped?
-
Similarly in the worker part of the code, it has its own scratchBuffer. It isn't used, and is deallocated, but why does it exist? should it be dropped from the example?
On the concurrent merge sort, I find the async let
syntax to spawn a subtask to be too subtle:
func mergeSort<T: Comparable>(_ array: [T]) async -> [T] {
guard array.count > 1 else { return array }
let middleIndex = array.count / 2
async let leftArray = await mergeSort(Array(array[0..<middleIndex]))
async let rightArray = await mergeSort(Array(array[middleIndex..<array.count]))
return merge(await leftArray, await rightArray)
}
This is a powerful example, but there is a lot going on in the leftArray/rightArray lines, and it isn't clear to me how this will compose with other things. In the case of the actor proposal, actor message sends are always cleanly scoped and explainable as part of the message to actors: someActor.foo(x)
the transfer of x to the actor is part of the message invocation that is immediately obvious.
In the example above, tasks are created, stuff is passed, a future is formed, and it is all implicit. Let me rewrite a bit of the example above, it is equivalent to:
let tmp = Array(array[middleIndex..<array.count])
async let rightArray = await mergeSort(tmp)
return merge(await leftArray, await rightArray)
From what I can tell, the bottom two lines are doing something like this (where spawnTask
is probably Task.runDetached
?):
let rightArrayFuture = spawnTask { await mergeSort(tmp) }
return merge(await leftArrayFuture.get(), await rightArrayFuture.get())
why is this sugar necessary?
I'm asking for two reasons:
- Reducing magic to make the model more understandable is important.
- The magic makes it more difficult to diagnose errors.
Given that this is forming a subtask, transfers between it and the current task will need to be checked for compatibility with ActorSendable
to ensure memory safety. How are these errors going to be explained to the user?
Furthermore, I thought that one of the design points of the concurrency story was that there would be multiple underlying implementation models for the actor runtime that are possible. Without making the spawning of a task an explicit library call, how is this replaced? What happens when we want new forms of spawning?
That example is also a bit suboptimal because it is doing tremendous amounts of copying of the array data. I understand that the original example was doing that to, but I think another good example would be to look at the equivalent code when doing an in-place version of this with an UnsafeBufferPointer
into an Array. Where do things like @UnsafeTransfer
go in this task spawning design?
-Chris