Modern Concurrency: Beyond the Basics

Oct 20 2022 · Swift 5.5, iOS 15, Xcode 13.4

Part 2: Concurrent Code

17. Creating a GlobalActor

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 16. GlobalActor Next episode: 18. Using a GlobalActor

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 17. Creating a GlobalActor

The ImageLoader actor implements an in-memory cache. It manages a dictionary of completed, failed and in-progress downloads, so the server doesn’t get duplicate requests.

But, the image cache doesn’t persist on the device. When you quit the app and run it again, it has to fetch all the images from the server all over again.

In this episode, you’ll create a custom GlobalActor to provide a persistent, on-disk image cache that allows easy and safe access to shared resources from anywhere in your app.

  • Continue with your project from episode 15 or open the starter project.

Creating a global actor

  • In the Model group, create a new Swift file named ImageDatabase.swift and replace the import statement:
import UIKit

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

}

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.

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

  • Add an imageLoader property:
let imageLoader = ImageLoader()

Your new actor will use this to automatically fetch images that aren’t already fetched from the server.

  • Add a storage property:
private let storage = DiskStorage()
  • Jump to the definition of DiskStorage: It’s in the Database group and contains simple file operation methods to write, read and remove files.

  • Jump back to ImageDatabase and add an index property:

private var storedImagesIndex = Set<String>()

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.

You’ve only added a few properties, but you should check that you haven’t accidentally introduced some concurrency issues.

Creating a safe silo

You’ve introduced two dependencies into your code: ImageLoader and DiskStorage.

ImageLoader is an actor, so it definitely doesn’t introduce any concurrency issues.

But what about DiskStorage? Could this 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. Click forward to DiskStorage

There’s nothing to stop other threads, actors or functions from creating their own instances of DiskStorage. In that case, the code could be unreliable.

One way to fix 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.

So undo this change.

In this app, what you really need 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.

  • So, annotate DiskStorage:
🟩@ImageDatabase 🟥class DiskStorage {

You move the whole DiskStorage type to the ImageDatabase serial executor. This way, ImageDatabase and DiskStorage can never step on each other’s toes.

  • Click back to ImageDatabase. Moving DiskStorage to ImageDatabase produces this error:
Call to global actor 'ImageDatabase'-isolated initializer 'init()' in a synchronous actor-isolated context

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

  • Change the storage declaration:
private var storage: DiskStorage!  // remember to change = to :

Now, storage is an optional. And add a setUp() method:

func setUp() async throws {
  storage = await DiskStorage()
  for fileURL in try await storage.persistedFiles() {
    storedImagesIndex.insert(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 soon.

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.

  • Add another method to ImageDatabase:
func store(image: UIImage, forKey key: String) async throws {
  guard let data = image.pngData() else {
    throw "Could not save image \(key)"
  }
}

First, you get the image’s PNG data. Then save the data in a file:

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)
  🟥
}
  • Finally, add the asset to the lookup index:
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.insert(fileName)
  🟥
}

Here’s a familiar compiler error:

Expression is 'async' but is not marked with 'await'
  • It’s complaining about the fileName(for:) call, so jump to its definition. Look closely: This is a pure function that uses no state at all, so you can safely make it non-isolated, like loadImages() and downloadImage(_:) in EmojiArtModel.
🟩nonisolated 🟥static func fileName(for path: String) -> String {
  • Click back to ImageDatabase.swift to see this fixed the error.

Fetching images from disk (or elsewhere)

Now, you need 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. Add a method:

func image(_ key: String) async throws -> UIImage {

}

This method takes a path to an asset and either returns an image or throws an error. Fetch the keys in the cache:

func image(_ key: String) async throws -> UIImage {
🟩
  let keys = await imageLoader.cache.keys
🟥
}
  • Before trying the disk or the network, check your local copy of keys for the image. If it’s in memory, fetch it directly from the cache:
func image(_ key: String) async throws -> UIImage {
  let keys = await imageLoader.cache.keys
  🟩
  if keys.contains(key) {
    print("In memory cache.")
    return try await imageLoader.image(key)
  }
  🟥
}

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.

You might think you could’ve just directly called contains(_:) on the cache keys, instead of first fetching a local copy, but this could corrupt memory in release builds when there are concurrent updates.

  • If there’s no cached asset in memory, check the on-disk index:
do {
  let fileName = DiskStorage.fileName(for: key)
  if !storedImagesIndex.contains(fileName) {
    throw "Image not persisted."
  }
} catch {

}

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.

  • But first, if there is a match, try to read the file from disk and initialize a UIImage with its contents:
do {
  let fileName = DiskStorage.fileName(for: key)
  if !storedImagesIndex.contains(fileName) {
    throw "Image not persisted"
  }
🟩
  let data = try await storage.read(name: fileName)
  guard let image = UIImage(data: data) else {
    throw "Invalid image data."
  }
🟥
} catch {
 
}

So if either of these steps fails, throw an error; you’ll try to get the image from the server in the catch block.

  • If you do retrieve a cached image, store it in memory, then return it:
🟩
  print("In disk cache.")
  await imageLoader.add(image, forKey: key)
  return image
🟥
} catch {

}

Storing it in memory will save you a trip to the file system the next time you need it.

  • Finally, fill in the catch closure to fetch the asset from the server:
} catch {
🟩
  let image = try await imageLoader.image(key)
  try await store(image: image, forKey: key)
  return image
🟥
}

If all the other attempts fail, you call ImageLoader.image(_:) to fetch the image, store it on disk, then return it.

Your persistence layer is almost ready. To complete it, you’ll add one final method for debugging purposes.

Purging the cache

  • Add a clear method to ImageDatabase:
func clear() async {
  for name in storedImagesIndex {
    try? await storage.remove(name: name)
  }
  storedImagesIndex.removeAll()
}

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.

This method lets you easily test your caching logic. Your cache is ready. In the next episode, you’ll put it to work in EmojiArt.