Swift Actors: A practical example, part 2
In my previous post Swift Actors: A practical example, part 1 I covered how I got a Swift.org toolchain from trunk, enabled support for the currently experimental support for structured concurrency, and created a thread-safe cache in few lines of Swift code.
If you missed part 1, definitely do read it first as this post builds upon the code I already covered there. »
When I wrapped up writing part 1, I was looking at my new cache actor and I started wondering “What about Combine?”.
What about Swift actors and Combine?
With all the concurrency advancements like async/await
, the Task
APIs, and actors - is Combine going to be still needed?
Some simple use cases would be easier with the new APIs. What’s more the new concurrency APIs are fully integrated with Swift’s error handling which makes the code much simpler to write and read. For example:
- I believe I won’t use
Future
much more since anasync
function is much simpler to write. - Reimplementing publishers that only emit series of values and then complete as an
AsyncSequence
will be easier and more importantly much simpler at the point-of-use.
In any case I think the Combine APIs to manage multiple streams of events over time offer a unique angle to asycnhronous value processing. In fact, I think it’s great we’re getting a simpler way of async/concurrent for simple tasks and still have the option to solve more complex problems with Combine.
So let’s add some Combine to the ActorTest
app from part 1!
Converting an async function to synchronous
Huge disclaimer: This post is purely exploratory and is using the experimental Swift concurrency feature which is a work in progress.
The compute()
function from part 1 which precomputes a bunch of hashes and adds them to the cache currently looks like so:
|
|
I’d very much like to mix-in some Combine in here to provide a way for an observer to track the progress of precomputing the hashes.
Usually I’d return a publisher from the function and let observers subscribe it. However, this function is async
so it doesn’t return a result until it’s finished all its work.
On the other hand, in the structured concurrency proposal I noticed a new function called async(...)
that lets you run async code in synchronous contexts.
Let’s try that and let compute()
return a publisher emitting its progress:
|
|
It took some trial and error to come up with this code but here’s the play-by-play:
compute()
is notasync
anymore and returns anAnyPublisher<Int, Never>
so consumers can subscribe and observe the progress,async(...)
creates an asynchronous task to runwithTaskGroup(...)
so you can actually useawait
inside the closure body,- Since execution is waiting for
withTaskGroup(...)
to complete all the group tasks, I can simply emit the.finished
publisher event on the next line afterwithTaskGroup(...)
. - Finally (and that’s a bit of an assumption) Swift’s structured concurrency allows me to return from
compute()
before the code inasync
completes.async()
spawns a child task which runs asynchronously and upon completion also releases the execution ofcompute()
. So in fact myprogress
subject is alive and well untilgroup
completes all its tasks 🤯🤯🤯.
Great! ⌘+B compiles the code just fine and I’m ready to look into fleshing out the code that emits the progress.
Task groups are lazy sequences
As I mentioned in part 1, the task group API, in its current state, allows you to easily add tasks to the group for concurrent execution by calling spawn(...)
. Before completing, withTaskGroup(...)
waits until all spawned tasks complete.
There is however a group API that also allows you to control and track the completion of tasks. In my code I’d like to send a value via my progress
subject each time a task completes.
This is trivial - peaking in the headers shows me that TaskGroup
conforms to AsyncSequence
and I can get the result of each task when it completes by calling the sequence’s next()
method:
|
|
The first half of the body now spawns all the tasks in the group and the while
loop consumes the results of the concurrent tasks as soon as they complete (but still synchronously).
I love this design because with just few lines of code you create both:
- the concurrent exection that does the heavy work, and
- the synchronized handler where you can safely process the results 🥰.
The code, emitting the progress, counts the completed tasks and calls into progress.send(...)
to emit the current progress value.
Subscribing the progress publisher
The rewrite with Combine is essentially completed. I still need to subscribe compute()
and print the progress to the console.
That’s the easiest task so far:
|
|
compute()
is not async
anymore but since it’s an actor method I still had to use await
. The rest was easy as pie - removeDuplicates()
emits only the unique progress values, and the code in sink(...)
prints out the progress.
When I build and run - the progress shows up in Xcode’s console:
...
Computing: 96%
Computing: 97%
Computing: 98%
Computing: 99%
Computing: 100%
Program ended with exit code: 0
The complete ActorTest app with Combine
Here’s how the complete App.swift developed in this post look like:
|
|
Final disclaimer: Developed using a Swift toolchain from the Swift.org trunk branch. The concurrency feature is a work-in-progress. This code might not work at a later moment.
Where to go from here?
All of the concurrency features in Swift are still work in progress. You don’t need to be learning them as of right now as syntax or behavior might change, so I hope this post does not put any unnecessary pressure on you.
That’s also the reason why I’ve also put so many disclaimers in the post - this write-up is purely exploratory.
Hit me up with your ideas, replies, and feedback on Twitter at https://twitter.com/icanzilb.