跳转至

Chapter 9: Global Actors

In the previous chapter, you got to meet Swift’s actor type, which provides code with safe, concurrent access to its internal state. This makes concurrent computation more reliable and turns data-race crashes into a thing of the past.

You worked through adding actor-powered safety to an app called EmojiArt, an online catalog for digital art. Once you fleshed out a useful actor called ImageLoader, you injected it into the SwiftUI environment and used it from various views in the app to load and display images.

Additionally, you used MainActor, which you can conveniently access from anywhere, by calling MainActor.run(...). That’s pretty handy given how often you need to make quick changes that drive the UI:

img

When you think about it, this is super-duper convenient: Because your app runs on a single main thread, you can’t create a second or a third MainActor. So it does make sense that there’s a default, shared instance of that actor that you can safely use from anywhere.

Some examples of app-wide, single-instance shared state are:

  • The app’s database layer, which is usually a singleton type that manages the state of a file on disk.
  • Image or data caches are also often single-instance types.
  • The authentication status of the user is valid app-wide, whether they have logged in or not.

Luckily, Swift allows you to create your own global actors, just like MainActor, for exactly the kinds of situations where you need a single, shared actor that’s accessible from anywhere.

Getting to meet GlobalActor

In Swift, you can annotate an actor with the @globalActor attribute, which makes it automatically conform to the GlobalActor protocol:

@globalActor actor myActor {
  ...
}

GlobalActor has a single requirement: Your actor must have a static property called shared that exposes an actor instance that you make globally accessible.

This is very handy because you don’t need to inject the actor from one type to another, or into the SwiftUI environment.

Global actors, however, are more than just a stand-in for singleton types.

Just as you annotated methods with @MainActor to allow their code to change the app’s UI, you can use the @-prefixed annotation to automatically execute methods on your own, custom global actor:

@MyActor func say(_ text: String) {
  ... automatically runs on MyActor ...
}

To automatically execute a method on your own global actor, annotate it with the name of your actor prefixed with an @ sign, like so: @MyActor, @DatabaseActor, @ImageLoader and so on.

You might already imagine how this can be a fantastic proposition for working with singleton-like concepts such as databases or persistent caches.

img

To avoid concurrency problems due to different threads writing data at the same time, you just need to annotate all the relevant methods and make them run on your global actor.

By using the @ annotation, you can group methods or entire types that can safely share mutable state in their own synchronized silo:

img

In this chapter, you’ll add a persistent cache layer to the EmojiArt project that you worked on in the last chapter, as shown in the diagram above.

You’ll get plenty of opportunities to learn about global actors in detail while having fun with juggling on-disk and in-memory caches.

Continuing with the EmojiArt project

In this section, you’ll keep working on the last chapter’s project: EmojiArt, your online store for verified, digital emoji art:

img

In Chapter 8, “Getting Started With Actors”, you implemented an actor-based, in-memory cache. Your ImageLoader actor manages a dictionary of completed downloads, failed downloads and those still being processed, so you don’t fire duplicate requests to the server.

However, when you quit the app and run it again, it needs to fetch the images from the server all over again. They don’t persist on the device.

This is a perfect opportunity for you to add a global actor to upgrade your app with a persistent, on-disk cache.

If you worked through the entirety of Chapter 8, “Getting Started With Actors”, you can continue working on your own project. Otherwise, open the EmojiArt starter project in this chapter’s materials from the projects/starter folder.

Before getting started with the project, start the book server. If you haven’t already done that, navigate to the server folder 00-book-server in the book materials-repository and enter swift run. The detailed steps are covered in Chapter 1, “Why Modern Swift Concurrency?”.

At this point, you’re all set to start working!

Creating a global actor

In this section, you’ll enhance EmojiArt with a new global actor that will persist downloaded images on disk.

To start, create a new Swift file and name it ImageDatabase.swift. Replace the placeholder code with the actor’s bare bones:

import UIKit

@globalActor actor ImageDatabase {
  static let shared = ImageDatabase()

}

Here, you declare a new actor called ImageDatabase and annotate it with @globalActor. This makes the type conform to the GlobalActor protocol, which you satisfy by adding the shared property right away.

In more complex use cases, the shared instance could also be an actor of a different type. In this chapter, you’ll use shared simply to facilitate access to the default instance of ImageDatabase.

Now, you can access your new actor type from anywhere by referring to the shared instance ImageDatabase.shared. Additionally, you can move the execution methods of other types to the ImageDatabase serial executor by annotating them with @ImageDatabase.

Note: The Actor and GlobalActor protocols don’t require an initializer. If you’d like to create new instances of your global actor, however, you can add a public or internal initializer. This is a valid approach when, for example, you create a custom instance to use in your unit tests.

On the other hand, if you want to explicitly avoid creating other copies, add an init() and make it private.

To wrap up the basic actor structure and its state, add these properties to it:

let imageLoader = ImageLoader()

private let storage = DiskStorage()
private var storedImagesIndex: [String] = []

Now, your new actor will use an instance of ImageLoader to automatically fetch images that aren’t already fetched from the server.

You also instantiate a class called DiskStorage, which handles the disk-access layer for you, so you don’t have to write non-actor-related code. DiskStorage features simple file operation methods like reading, writing and deleting files from the app’s caches.

Finally, you’ll keep an index of the persisted files on disk in storedImagesIndex. This lets you avoid checking the file system every time you send a request to ImageDatabase.

Is it possible that you introduced some concurrency issues into your code with these few simple lines? You’ll check that out next.

Creating a safe silo

Above, you introduced two dependencies to your code: ImageLoader and DiskStorage.

You can be certain that ImageLoader doesn’t introduce any concurrency issues, since it’s an actor. But what about DiskStorage? Could that type lead to concurrency issues in your global actor?

You could argue that storage belongs to ImageDatabase, which is an actor. Therefore, storage’s code executes serially, and the code in DiskStorage cannot introduce data races.

That’s a valid argument, but other threads, actors or functions can create their own instances of DiskStorage. In that case, the code could be unreliable.

One way to address this is to convert DiskStorage to an actor as well. However, since you mostly expect ImageDatabase to work with DiskStorage, making it an actor will introduce some redundant switching between actors.

What you really need, in this chapter, is to guarantee that the code in DiskStorage always runs on ImageDatabase’s serial executor. This will eliminate concurrency issues and avoid excessive actor hopping.

img

To do this, open DiskStorage.swift and prepend the class declaration with @ImageDatabase, like this:

@ImageDatabase class DiskStorage {

Instead of DiskStorage‘s individual methods, you move the whole type to the ImageDatabase serial executor. This way, ImageDatabase and DiskStorage can never step on each other’s toes.

That wasn’t difficult at all, but you now face an error:

Call to global actor 'ImageDatabase'-isolated initializer 'init()' in a synchronous actor-isolated context

The Swift compiler’s complaint is valid. You cannot create DiskStorage, which runs on ImageDatabase‘s serial executor, before you’ve created ImageDatabase itself.

You’ll fix that by deferring the storage initialization to a new method called setUp(), along with a few other things you need to take care of when you initialize your database.

Initializing the database actor

First, switch back to ImageDatabase.swift. Then, replace:

private let storage = DiskStorage()

With:

private var storage: DiskStorage!

Next, you’ll add setUp(). Add the new method anywhere inside ImageDatabase:

func setUp() async throws {
  storage = await DiskStorage()
  for fileURL in try await storage.persistedFiles() {
    storedImagesIndex.append(fileURL.lastPathComponent)
  }
}

setUp() initializes DiskStorage and reads all the files persisted on disk into the storedImagesIndex lookup index. Any time you save new files to disk, you’ll also update the index.

You’ll need to ensure you call it before any other method in ImageDatabase, because you’ll initialize your storage there. Don’t worry about this for now, though. You’ll take care of it in a moment.

Writing files to disk

The new cache will need to write images to disk. When you fetch an image, you’ll export it to PNG format and save it. To do that, add the following method anywhere inside ImageDatabase:

func store(image: UIImage, forKey key: String) async throws {
  guard let data = image.pngData() else {
    throw "Could not save image \(key)"
  }
  let fileName = DiskStorage.fileName(for: key)
  try await storage.write(data, name: fileName)
  storedImagesIndex.append(fileName)
}

Here, you get the image’s PNG data and save it by using the write(_:name:) storage method. If that goes through successfully, you add the asset to the lookup index, too.

You now face a new compiler error. To fix it, open DiskStorage.swift and scroll to fileName(for:).

Give the method a close inspection. It looks like this is a pure function that uses no state at all, so you can safely make it non-isolated, as you did for similar methods in the last chapter.

Prepend nonisolated to the method definition, like this:

nonisolated static func fileName(for path: String) -> String {

This clears the error and lets you move on.

Fetching images from disk (or elsewhere)

Next, you’ll add a helper method to fetch an image from the database. If the file is already stored on disk, you’ll fetch it from there. Otherwise, you’ll use ImageLoader to make a request to the server. This is how the completed flow will look:

img

To implement this, add the initial code of the new method to ImageDatabase:

func image(_ key: String) async throws -> UIImage {
  if await imageLoader.cache.keys.contains(key) {
    print("Cached in-memory")
    return try await imageLoader.image(key)
  }

}

This method takes a path to an asset and either returns an image or throws an error. Before trying the disk or the network, you check if you find a cached image in memory; if so, you can get it directly from ImageLoader.cache.

Because your caching strategy is getting more complex, you also add a new log message that lets you know you’ve successfully retrieved an in-memory image.

In case there’s no cached asset in memory, you check the on-disk index and, if there’s a match, you read the file and return it.

You’ll now add the rest of the logic for querying the local image database for a cached asset, as well as falling back to fetching from the remote server if one doesn’t exist.

Append the following to the same method:

do {
  // 1
  let fileName = DiskStorage.fileName(for: key)
  if !storedImagesIndex.contains(fileName) {
    throw "Image not persisted"
  }

  // 2
  let data = try await storage.read(name: fileName)
  guard let image = UIImage(data: data) else {
    throw "Invalid image data"
  }

  print("Cached on disk")
  // 3
  await imageLoader.add(image, forKey: key)
  return image
} catch {
  // 4
}

This block of code is a little longer, so look at it step-by-step:

  1. You get the asset file name from DiskStorage.fileName(for:) and check the database index for a match. If the key doesn’t exist, you throw an error that transfers the execution to the catch statement. You’ll try fetching the asset from the server there.
  2. You then try reading the file from disk and initializing a UIImage with its contents. Again, if either of these steps fails, you throw and try to get the image from the server in the catch block.

  3. Finally, if you successfully retrieved the cached image, you store it in memory in ImageLoader. This prevents you from having to make the trip to the file system and back next time.

  4. In the empty catch block, you’ll fetch the asset from the server.

To complete the method, insert this code inside catch:

let image = try await imageLoader.image(key)
try await store(image: image, forKey: key)
return image

This code will run if all other local attempts fail and you have to make a network call to the server. You call ImageLoader.image(_:) to fetch the image and, before returning, store it on disk for future use.

With that, the persistence layer is almost ready. To complete it, you’ll add one final method for debugging purposes, just as you did for the image loader.

Purging the cache

To easily test the caching logic, you’ll add one more method to ImageDatabase. clear() will delete all the asset files on disk and empty the index. Add the following anywhere in ImageDatabase:

func clear() async {
  for name in storedImagesIndex {
    try? await storage.remove(name: name)
  }
  storedImagesIndex.removeAll()
}

Here, you iterate over all the indexed files in storedImagesIndex and try to delete the matching files on disk. Finally, you remove all values from the index as well.

The cache is ready; it’s time to use it in EmojiArt.

Wiring up the persistence layer

As noted earlier, before you do anything with the new database type, you need to set it up safely by calling ImageDatabase‘s setUp method. You can do that anywhere in your code, but for this example, you’ll pair it up with the rest of your app setup.

Open LoadingView.swift and scroll to task(...).

The first thing you currently do in the app is to call model.loadImages() in that task modifier. Insert the following before the line that calls loadImages():

try await ImageDatabase.shared.setUp()

With that wrinkle out of the way, your next step is to replace all the current calls to ImageLoader with ImageDatabase, instead.

Once you do this, you’ll always make requests to ImageDatabase, which serializes the access and transparently uses the image loader when an image isn’t cached locally. There are, all in all, only two occurrences you need to replace.

First, open ThumbImage.swift and replace imageLoader.image(file.url) inside the task(...) modifier with:

ImageDatabase.shared.image(file.url)

That will check the in-memory cache, then the on-disk cache and then, if all else fails, the network.

The completed task code should now look like this:

.task {
  guard let image = try? await 
    ImageDatabase.shared.image(file.url) else {
    overlay = "camera.metering.unknown"
    return
  }
  updateImage(image)
}

You can also delete the imageLoader property, since you’re not using it anymore.

Secondly, open DetailsView.swift and replace imageLoader.image(file.url) with:

ImageDatabase.shared.image(file.url)

Delete imageLoader here, as well.

Build and run. Direct your attention to the output console; you’ll see a healthy mix of network requests and assets cached in memory, like so:

Download: http://localhost:8080/gallery/image?26
Cached in-memory
Cached in-memory
Download: http://localhost:8080/gallery/image?2
Cached in-memory
Download: http://localhost:8080/gallery/image?9
Download: http://localhost:8080/gallery/image?22
...

Without losing sight of the output console, scroll all the way to the bottom of the image feed, then scroll back to the top. Once you’ve downloaded all the images, you’ll only see memory hits like this:

Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory

So far, so good! This is exactly how your pair of star actors should behave.

Now, for the ultimate test: Stop the app and run it again. Don’t scroll the feed just yet!

This time, the disk cache serves all the content without you having to fetch it from the network:

Cached on disk
Cached on disk
Download: http://localhost:8080/gallery/image?10
Cached on disk
Cached on disk

Every now and again, you’ll see a network request go through; these are the assets that failed to download on the last run of the app. You retry fetching those because they’re not persisted on disk.

Scroll down to the bottom and up again. You’ll see that after loading all the assets from disk, the log again fills up with messages for memory-cached assets.

Congratulations, it seems like all the pieces of the jigsaw puzzle have come together to create a super-powerful image caching mechanism for your project.

No time to spend gloating, though; you have a few more tasks to complete before wrapping up.

Adding a cache hit counter

In this section, you’ll add code to activate the bottom bar in the feed screen to help you debug your caching mechanism. This is how the toolbar will look when you finish:

img

The toolbar consists of two buttons on the left side: one to clear the disk cache and one to clear the in-memory cache. On the right side, there’s a cache hit counter that shows you how many assets you loaded from disk and how many from memory.

Right now, the toolbar doesn’t do anything or show any real information. You’ll work on it in this section.

First, you need to add a way for ImageLoader to continuously publish the count of cache hits. And, you guessed it, that sounds like a case for AsyncStream!

Open ImageLoader.swift and add these new properties:

@MainActor private(set) var inMemoryAccess: AsyncStream<Int>?

private var inMemoryAcccessContinuation: AsyncStream<Int>.Continuation?
private var inMemoryAccessCounter = 0 {
  didSet { inMemoryAcccessContinuation?.yield(inMemoryAccessCounter) }
}

Here, you add a new asynchronous stream called inMemoryAccess that runs on the main actor. Your views can access and subscribe to this property without worrying about any background UI updates.

Additionally, you protect the current count in inMemoryAccessCounter, by leveraging ImageLoader‘s actor semantics. You’ll store the stream continuation in inMemoryAcccessContinuation so you can easily produce ongoing updates. Finally, the didSet accessor ensures that any updates to inMemoryAccessCounter are relayed to the continuation, if one exists.

To correctly initialize the stream, you’ll add setUp() to ImageLoader, as you previously did for your other actor. Insert the following anywhere inside the type:

func setUp() async {
  let accessStream = AsyncStream<Int> { continuation in
    inMemoryAcccessContinuation = continuation
  }
  await MainActor.run { inMemoryAccess = accessStream }
}

In setUp(), you create a new AsyncStream and store its continuation in inMemoryAcccessContinuation. Then, switching to the main actor, you store the stream itself in inMemoryAccess.

With this setup, you can produce new values at any given time by calling inMemoryAcccessContinuation.yield(...). To do that, scroll to image(_:) and find this case: case .completed(let image). Insert this code on the next line, before the return statement:

inMemoryAccessCounter += 1

Here, you increase the hit counter, which in return yields the result to the stored continuation. Since both properties are on the actor, you perform both operations synchronously. However, the @MainActor annotation causes the stream to produce the value on the main actor asynchronously:

img

As a good developer, you’ll also add a deinitializer to manually complete the stream when the actor is released from memory:

deinit {
  inMemoryAcccessContinuation?.finish()
}

Displaying the counter

You’ll get around to updating your view code in a moment, but don’t forget that the image loader will not set itself up automatically. You’ll now add the call to ImageLoader.setUp(), just like you did for ImageDatabase.

A safe place to call ImageLoader.setUp() is your database’s own setUp(). Open ImageDatabase.swift and find setUp(). Append the following to the bottom of the method:

await imageLoader.setUp()

With that out of the way, you can move on to updating the UI code that displays the debugging toolbar at the bottom of the image feed.

Open BottomToolbar.swift; add a new task modifier after the last padding in the code:

.task {
  guard let memoryAccessSequence = 
    ImageDatabase.shared.imageLoader.inMemoryAccess else {
    return
  }
  for await count in memoryAccessSequence {
    inMemoryAccessCount = count
  }
}

Above, you unwrap the optional stream and use a for await loop to asynchronously iterate over the sequence.

Each time the stream produces a value, you assign it to inMemoryAccessCount — a state property on the toolbar view that you use to display the text in the toolbar.

Build and run again. Scroll up and down a little, and you’ll see the in-memory counter give you updates in real-time:

img

Purging the in-memory cache

To complete the last exercise for this chapter, you’ll wire up the button that clears the memory cache.

First, you’ll add a new method to ImageLoader to purge the in-memory assets. Then, you’ll wire up the toolbar button.

Open ImageDatabase.swift and add this new method that encapsulates clearing the image loader cache:

func clearInMemoryAssets() async {
  await imageLoader.clear()
}

Then switch back to BottomToolbar.swift and find the comment that reads // Clear in-memory cache.

This code is for the right button in the toolbar. Replace the comment with the actual code to clear the memory cache:

Task {
  await ImageDatabase.shared.clearInMemoryAssets()
  try await model.loadImages()
}

In the code above, you first clear the in-memory cache, then reload the images from the server.

Build and run. Tap the button to check that the app correctly clears the memory, then gets all the assets from the network once again.

Now that you’ve completed that last feature, the EmojiArt app is complete. You’ve done a fantastic job working through all the steps in this chapter.

Feel free to jump over to the next chapter if you’re eager to move on to the next topic: distributed actors. If you’d like to work on EmojiArt a bit longer, stay for this chapter’s challenge.

Challenges:

Challenge: Updating the number of disk fetches

In this challenge, you’ll finish the debugging toolbar by connecting the second counter, which displays on-disk cache hits.

Your approach should be similar to what you did in the last section of the chapter for the in-memory counter; it shouldn’t take you long.

In your implementation, follow these general steps, mirroring what you did for ImageLoader:

  • Add an async stream for the counter to ImageDatabase.
  • Set up the stream in the actor’s setUp().
  • Complete the stream in a deinitializer.
  • Increment the counter when you have an actual disk cache hit.
  • Finally, update the toolbar view to iterate over the stream and make the last toolbar button clear the disk cache.

After you’ve finished working through these steps, the toolbar will be an excellent debugging tool to verify and test your caching logic:

img

Key points

  • Global actors protect the global mutable state within your app.
  • Use @globalActor to annotate an actor as global and make it conform to the GlobalActor protocol.
  • Use a global actor’s serial executor to form concurrency-safe silos out of code that needs to work with the same mutable state.
  • Use a mix of actors and global actors, along with async/await and asynchronous sequences, to make your concurrent code safe.

By completing the EmojiArt project, you’ve gained a solid understanding of the problems that actors solve and how to use these fancy APIs to write solid and safe concurrent code.

However, there’s still one kind of actor you haven’t tried yet. Distributed actors are, in fact, so bleeding-edge that they’re still a work in progress, and you’ll have to partially implement them on your own. If that sounds like a cool challenge, turn the page to the next and final chapter of this book.