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:
|
|
Is Person
thread-safe π€? In other words, will your app –>ever<– crash inside Person
?
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
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):
|
|
hashes
is a dictionary where computed hashes are stored,addHash(for:)
takes a number and adds its text representation’s hash tohashes
, andcompute()
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. π₯
So again, hashes
and 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. π
Let’s rewrite 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 compute()
to:
|
|
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:
Since 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:
|
|
I made 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 compute()
caches:
|
|
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:
|
|
And got:
compute()
is anasync
function so it needs anawait
when you call it,addHash()
is a “vanilla” function but for all purposes it’s treated like anasync
when called from outside the actor, and finallyhashes
also needs to be synchronized when accessed outside the actor so you also need to useawait
here.
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.