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 πŸ’ͺ🏼:

Xcode 12.5 with swift 5.5 toolchain.

Next, I needed a project with enabled experimental concurrency support so I created an app from the command line:

1
swift package init --type=executable

Then I cleaned up the Package.swift file and added the swift compiler flag that was mentioned in the actors proposal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// swift-tools-version:5.4
import PackageDescription

let package = Package(
    name: "ActorTest",
    platforms: [.macOS(.v11)],
    targets: [
      .executableTarget(
        name: "ActorTest",
        swiftSettings: [
          .unsafeFlags([
            "-Xfrontend", 
            "-enable-experimental-concurrency"
          ])
        ]
      )
    ]
)

⌘+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:

1
2
3
class Person {
  var name: String
}

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.

Thread sanitizer options in Xcode

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:

1
2
3
class Person {
  var name: String
}

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:

1
2
3
actor Person {
  var name: String
}

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:

  1. Allow synchronous access to actor’s members from within itself,
  2. Allow only asynchronous access to the actor’s members from any asynchronous context, and
  3. 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import CryptoKit

class HashCache {
  private(set) var hashes = [Int: String]()
    
  func addHash(for number: Int) {
    let string = SHA512.hash(data: 
      Data(String(number).utf8)
    ).description
        
    hashes[number] = string
  }
  
  func compute() {
    DispatchQueue.concurrentPerform(iterations: 15000, 
      execute: addHash(for:))  
  }
}
  • hashes is a dictionary where computed hashes are stored,
  • addHash(for:) takes a number and adds its text representation’s hash to hashes, and
  • 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. πŸ’₯

Data race crash in Xode

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 …

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import CryptoKit

actor HashCache {
  private(set) var hashes = [Int: String]()
    
  func addHash(for number: Int) {
    let string = SHA512.hash(data: 
      Data(String(number).utf8)
    ).description
        
    hashes[number] = string
  }

  func compute() {
    DispatchQueue.concurrentPerform(iterations: 15000, 
      execute: addHash(for:))
  }
}

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:

1
2
3
4
5
func compute() {
  DispatchQueue.concurrentPerform(iterations: 15000) { number in
    self.addHash(for: number)
  }
}

Spawns a neat error message like so:

Data race compile error message in Xcode

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:

1
2
3
4
5
func compute() {
  withTaskGroup(of: Bool.self) { group in
    
  }
}

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:

Not async context compile error message in Xode

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:

1
2
3
4
5
func compute() async {
  await withTaskGroup(of: Bool.self) { group in
    
  }
}

I made compute() async and also used await when calling withTaskGroup(...). That clears up the previous error message but I get a new one:

Incompatible macOS version error message in Xcode

If you ⌘+Click on withTaskGroup(...) you’ll find that the experimental features have a distant future availability:

Future availability of experimental features

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).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func compute() async {
  await withTaskGroup(of: Bool.self) { group in
    for number in 0 ... 15_000 {
      group.spawn {
        await self.addHash(for: number)
        return true
      }
    }
  }
}

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:

Synchronous access error message in Xcode

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func compute() async {
  addHash(for: 42)

  await withTaskGroup(of: Bool.self) { group in
    for number in 0 ... 15_000 {
      group.spawn {
        await self.addHash(for: number)
        return true
      }
    }
  }
}

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:

1
2
3
cache.addHash(for: 7778)
cache.compute()
print(cache.hashes[34]!)

And got:

Not Asynchronous access error message in Xcode

  • compute() is an async function so it needs an await when you call it,
  • addHash() is a “vanilla” function but for all purposes it’s treated like an async when called from outside the actor, and finally
  • hashes also needs to be synchronized when accessed outside the actor so you also need to use await 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import Foundation
import CryptoKit

@available(macOS 9999, *)
@main
struct App {
  static let cache = HashCache()
  
  static func main() async {
    await cache.addHash(for: 7778)
    await cache.compute()
    await print(cache.hashes[34]!)
  }
}

@available(macOS 9999, *)
actor HashCache {
  private(set) var hashes = [Int: String]()
  
  func addHash(for number: Int) {
    let string = SHA512.hash(data: 
      Data(String(number).utf8)
    ).description
        
    hashes[number] = string
  }
  
  func compute() async {
    addHash(for: 42)
    
    await withTaskGroup(of: Bool.self) { group in
      for number in 0 ... 15_000 {
        group.spawn {
          await self.addHash(for: number)
          return true
        }
      }
    }
  }
  
}

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.