Swift Actors: A practical example, part 1
I’ve been re-reading the Swift structured concurrency roadmap and the Swift actors proposal and noticed a note on the latter saying:
“Partially available in recent main snapshots behind the flag -Xfrontend -enable-experimental-concurrency”
So, naturally 🤓, I downloaded the latest snapshot from Swift.org and took it for a spin to try out some actor code!
Installing Swift toolchain with actors support
Huge disclaimer: this is all experimental experience using a trunk code not cut into a release.
I grabbed a May 4th trunk snapshot of the swift toolchain, installed it on my machine, and activated it in my Xcode 12.5 beta via the Xcode/Toolchains… menu. Now I have Xcode running with Swift 5.5 💪🏼:
Next, I needed a project with enabled experimental concurrency support so I created an app from the command line:
Then I cleaned up the Package.swift file and added the swift compiler flag that was mentioned in the actors proposal:
⌘+B to check if the projects builds fine and that’s all -
ActorsTest is now an app sporting experimental Swift concurrency with actors!
Quick detour: What problem are actors solving?
I’ve read few times the various Swift concurrency effort proposals and I’ll try to summarize my understanding of what actors solve (so far).
Note: I might be wrong about some points or things might change considerably later on.
Let’s take this Swift code:
Person thread-safe 🤔? In other words, will your app –>ever<– crash inside
The answer is: “it depends”. It depends how your or others’ code uses
Person. If two pieces of code, running concurrently, update
Person.name at the same time - your app will crash. 🌋
Even if you make
Person.name private, in order to protect it from concurrent reads/writes from outside the class, you can still concurrently read/write from within the class which is also going to crash your app.
At present, if you are writing concurrent code, you are probably using Swift’s thread sanitizer to detect data races like the one I’m talking about above.
This is extremely useful but the problem is that this way you still can write unsafe code. Crashes might still happen at runtime. You just hope you’ll discover and fix all the issues by using the Thread sanitizer. (And won’t introduce new issues later on.)
Actors offer data isolation within a type in such way that the code you write cannot create data races. In other words, thread-safety is built into the type system. Similarly to how mutability is - you simply cannot compile code that mutates immutable data.
Let’s compare a class versus an actor. The
class below is not thread safe and whether it crashes or not depends on the way other code uses it:
In contrast, the following
actor type is safe to use concurrently. Thanks to the work-in-progress actors feature your code accesses the
name-property memory in a safe way. It’s guaranteed by the actor type:
I haven’t delved into the implementation details but from what I’ve read so far in the proposal - actors transparently implement the following compile-time rules:
- Allow synchronous access to actor’s members from within itself,
- Allow only asynchronous access to the actor’s members from any asynchronous context, and
- Allow only asynchronous access to the actor’s members from outside the actor.
This way the actor itself accesses its data synchronously, but any other code from outside is required asynchronous access (with implicit synchronization) to prevent data races.
This is really clever and it leverages all the tech already existing in Swift!
Now let’s give all that a try!
Building a crash-prone app
Going back to my
ActorTest app, I’ll first try some concurrent code that crashes before I add in an actor to solve the crash.
I’ll start with a cache type that computes SHA512 hashes of numbers (a silly example that requires just few lines of Swift to demonstrate the idea):
hashesis a dictionary where computed hashes are stored,
addHash(for:)takes a number and adds its text representation’s hash to
compute()pre-computes some 15000 hashes concurrently.
If you’ve done something along these lines before, you instantly see the issue in this code. When you call
HashCache.compute() you’ll have a crash because multiple threads are calling
addHash(for:) and trying to mutate
hashes at the same time. 💥
addHash(for:) aren’t unsafe per sè - especially if your app is generally running on the main thread. But as soon as some code calls
addHash(for:) from a non-main thread the app becomes prone to data-race crashes.
At present, to solve this issue, you would add locking around your mutable state or use a serial queue to synchronize data-access but that often requires a lot of boilerplate code. 🙃
HashCache as an
actor and get rid of that crash for good.
Converting to actor code
So I’m just going to replace the keyword
class with an
actor and …
It works - the project compiles fine. Running the app though hits the same crash. Don’t forget this feature is work-in-progress. 🤷🏽♂️
It looks at the moment the compiler doesn’t quite catch that the
execute parameter of
DispatchQueue.concurrentPerform(...) needs to be synchronized.
But the compiler catches data-race code if you provide a closure instead! Changing
Spawns a neat error message like so:
The closure in
concurrentPerform(...) is called asynchronously so you are not allowed to synchronously access an actor member because it needs to be synchronized to prevent data races. Awesome!
I won’t try making the code work with
DispatchQueue because at this point it’d be much more fun to try the new
Task-based (and also work-in-progress) concurrency. Let’s replace the
DispatchQueue code with a task group:
This is more sample code from the actor proposal that I mentioned above but on the surface it looks like it just creates a concurrent group that you can add tasks to. A neat detail is that
withTaskGroup(...) waits until all tasks in the group complete.
I checked for a way to create tasks that don’t return anything but couldn’t find one, so my tasks in this group will return a
Bool which I’m gonna discard.
So far so good! I even get this neat error:
withTaskGroup(...) needs to wait for all tasks to complete it is, of course, asynchronous. Swift complains that the context in which I call
withTaskGroup(...) is not asynchronous so let’s fix that:
compute() async and also used
await when calling
withTaskGroup(...). That clears up the previous error message but I get a new one:
If you ⌘+Click on
withTaskGroup(...) you’ll find that the experimental features have a distant future availability:
In any case clicking the third suggested solution makes
HashCache available on macOS 9999 or newer and everything compiles OK.
Next, I want to create a task for each hash I’m about to compute. I found this to work and it looks pretty simple (again, I don’t really use the returned boolean).
Reading up the group API it sounds really great - you can treat it as a lazy sequence and control the tasks execution to batch tasks, cancel one or more tasks, emit progress as tasks complete, and more.
But I won’t go into all that right now as all I want now is to concurrently compute the hashes and be done.
Did you notice that I had to call
addHash(for:) by using
await. What gives???
If you scroll waaay up you’ll notice that
addHash(for:) isn’t an
async function at all. But calling
addHash(for:) synchronously from an asynchronous task might cause a data-race and a crash. That’s why the compiler requires you to use
await so that it can synchronize your access to the actor’s internals:
THIS is what I’m talking about. The 3 simple rules we spelled out in the beginning are so clever that simply prevent you from writing bad code.
So you have to to use
await when calling
addHash(for:) from asynchronous context.
There is, however, no need to convert
addHash(for:) to an
async function altogether. You still might want to call it synchronously when the context permits it.
Now I’m super curious to try how that works. Let’s add one more hash to the lot that
It works! Calling
addHash(for:) from within an actor method can be done synchronously.
I didn’t read in detail what’s the annotation that makes the task closure give that actor isolation error but it’s on my todo.
Access outside the actor
The last few use-cases to verify have to do with accessing actor members from outside of the actor, regardless whether from an asynchronous context or not.
I tried this code from another type:
asyncfunction so it needs an
awaitwhen you call it,
addHash()is a “vanilla” function but for all purposes it’s treated like an
asyncwhen called from outside the actor, and finally
hashesalso needs to be synchronized when accessed outside the actor so you also need to use
The actors implementation design and the experience when writing this code makes me very happy. Having thread-safety checked at compiled time will make Swift apps generally safer, less crash-prone, and hopefully more people will be embracing concurrent programming.
The complete ActorTest app
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?
Update: After wrapping up this post I started thinking about mixing up Swift actors and Combine and wrote a follow-up post: Swift Actors: A practical example, part 2
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.
But the next time anyone tells you that concurrent code is bad because it crashes with strange errors and the best way is to only use the main thread, you’ll know that’s not true if you use the new structured concurrency in Swift :)
Hit me up with your ideas, replies, and feedback on Twitter at https://twitter.com/icanzilb.